Settings layout rework, purge fix, SSE broadcast

Settings tab reorganized into 3 sections:
- Appearance & Defaults: welcome message + output formats + Save
- Privacy & Data: privacy mode toggle + manual purge
- Security: change password

Manual purge fix:
- purge_all=True clears ALL completed/failed jobs regardless of age
- Previously only cleared jobs older than max_age_hours (7 days),
  so recent downloads were never purged on manual trigger

SSE broadcast for purge:
- Added SSEBroker.publish_all() for cross-session broadcasts
- Purge endpoint sends job_removed events for each deleted job
- Frontend queue clears in real-time when admin purges
This commit is contained in:
xpltd 2026-03-19 06:04:59 -05:00
parent c3278fcac2
commit dd60505f5a
4 changed files with 220 additions and 160 deletions

View file

@ -57,6 +57,24 @@ class SSEBroker:
""" """
self._loop.call_soon_threadsafe(self._publish_sync, session_id, event) self._loop.call_soon_threadsafe(self._publish_sync, session_id, event)
def publish_all(self, event: object) -> None:
"""Publish *event* to ALL sessions — thread-safe.
Used for broadcasts like purge notifications.
"""
self._loop.call_soon_threadsafe(self._publish_all_sync, event)
def _publish_all_sync(self, event: object) -> None:
"""Deliver *event* to all queues across all sessions."""
for session_id, queues in self._subscribers.items():
for queue in queues:
try:
queue.put_nowait(event)
except asyncio.QueueFull:
logger.warning(
"Queue full for session %s — dropping broadcast", session_id
)
def _publish_sync(self, session_id: str, event: object) -> None: def _publish_sync(self, session_id: str, event: object) -> None:
"""Deliver *event* to all queues for *session_id*. """Deliver *event* to all queues for *session_id*.

View file

@ -158,7 +158,15 @@ async def manual_purge(
# Attach runtime overrides so purge service can read them # Attach runtime overrides so purge service can read them
overrides = getattr(request.app.state, "settings_overrides", {}) overrides = getattr(request.app.state, "settings_overrides", {})
config._runtime_overrides = overrides config._runtime_overrides = overrides
result = await run_purge(db, config) result = await run_purge(db, config, purge_all=True)
# Broadcast job_removed events to all SSE clients
broker = request.app.state.broker
for job_id in result.get("deleted_job_ids", []):
broker.publish_all({"event": "job_removed", "data": {"job_id": job_id}})
# Don't send internal field to client
result.pop("deleted_job_ids", None)
return result return result

View file

@ -17,31 +17,39 @@ from app.core.config import AppConfig
logger = logging.getLogger("mediarip.purge") logger = logging.getLogger("mediarip.purge")
async def run_purge(db: aiosqlite.Connection, config: AppConfig) -> dict: async def run_purge(
db: aiosqlite.Connection,
config: AppConfig,
*,
purge_all: bool = False,
) -> dict:
"""Execute a purge cycle. """Execute a purge cycle.
When privacy_mode is active, uses privacy_retention_hours. When *purge_all* is True, deletes ALL completed/failed jobs regardless
Otherwise uses max_age_hours. of age (manual "clear everything" behavior).
Deletes completed/failed/expired jobs older than the configured Otherwise respects retention: privacy_retention_hours when privacy mode
retention period and their associated files from disk. is active, max_age_hours otherwise.
Returns a summary dict with counts. Returns a summary dict with counts.
""" """
overrides = getattr(config, "_runtime_overrides", {}) overrides = getattr(config, "_runtime_overrides", {})
privacy_on = overrides.get("privacy_mode", config.purge.privacy_mode)
if privacy_on: if purge_all:
retention = overrides.get( cutoff = datetime.now(timezone.utc).isoformat() # everything up to now
"privacy_retention_hours", config.purge.privacy_retention_hours logger.info("Purge ALL starting (manual clear)")
)
else: else:
retention = config.purge.max_age_hours privacy_on = overrides.get("privacy_mode", config.purge.privacy_mode)
if privacy_on:
retention = overrides.get(
"privacy_retention_hours", config.purge.privacy_retention_hours
)
else:
retention = config.purge.max_age_hours
cutoff = (datetime.now(timezone.utc) - timedelta(hours=retention)).isoformat()
logger.info("Purge starting: retention=%dh (privacy=%s), cutoff=%s", retention, privacy_on, cutoff)
output_dir = Path(config.downloads.output_dir) 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(
@ -58,6 +66,7 @@ async def run_purge(db: aiosqlite.Connection, config: AppConfig) -> dict:
files_deleted = 0 files_deleted = 0
files_missing = 0 files_missing = 0
rows_deleted = 0 rows_deleted = 0
deleted_job_ids: list[str] = []
for row in rows: for row in rows:
job_id = row["id"] job_id = row["id"]
@ -80,6 +89,7 @@ async def run_purge(db: aiosqlite.Connection, config: AppConfig) -> dict:
# Delete DB row # Delete DB row
await db.execute("DELETE FROM jobs WHERE id = ?", (job_id,)) await db.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
rows_deleted += 1 rows_deleted += 1
deleted_job_ids.append(job_id)
await db.commit() await db.commit()
@ -95,6 +105,7 @@ async def run_purge(db: aiosqlite.Connection, config: AppConfig) -> dict:
"files_deleted": files_deleted, "files_deleted": files_deleted,
"files_missing": files_missing, "files_missing": files_missing,
"active_skipped": active_skipped, "active_skipped": active_skipped,
"deleted_job_ids": deleted_job_ids,
} }
logger.info( logger.info(

View file

@ -247,160 +247,171 @@ function formatFilesize(bytes: number | null): string {
<!-- Settings tab --> <!-- Settings tab -->
<div v-if="activeTab === 'settings'" class="tab-content"> <div v-if="activeTab === 'settings'" class="tab-content">
<!-- Privacy Mode --> <!-- Section: Appearance & Defaults -->
<div class="settings-field"> <div class="settings-section">
<label class="toggle-label"> <h3 class="section-heading">Appearance &amp; Defaults</h3>
<span>Privacy Mode</span>
<label class="toggle-switch"> <div class="settings-field">
<input type="checkbox" v-model="privacyMode" /> <label for="welcome-msg">Welcome Message</label>
<span class="toggle-slider"></span> <p class="field-hint">Displayed above the URL input on the main page. Leave empty to hide.</p>
</label> <textarea
</label> id="welcome-msg"
<p class="field-hint"> v-model="welcomeMessage"
When enabled, download history, session logs, and files are automatically purged rows="3"
after the configured retention period. class="settings-textarea"
</p> placeholder="Enter a welcome message…"
<div v-if="privacyMode" class="retention-setting"> ></textarea>
<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>
</div>
<hr class="settings-divider" /> <div class="settings-field" style="margin-top: var(--space-lg);">
<label>Default Output Formats</label>
<!-- Manual Purge --> <p class="field-hint">When "Auto" is selected, files are converted to these formats instead of the native container.</p>
<div class="settings-field"> <div class="format-defaults">
<label>Manual Purge</label> <div class="format-default-row">
<p class="field-hint"> <span class="format-default-label">Video</span>
Immediately remove expired downloads and their files from disk. <select v-model="defaultVideoFormat" class="settings-select">
Active downloads are never affected. <option value="auto">Auto (native container)</option>
</p> <option value="mp4">MP4</option>
<button <option value="webm">WebM</option>
@click="store.triggerPurge()" </select>
:disabled="store.isLoading" </div>
class="btn-purge" <div class="format-default-row">
> <span class="format-default-label">Audio</span>
{{ store.isLoading ? 'Purging…' : 'Run Purge Now' }} <select v-model="defaultAudioFormat" class="settings-select">
</button> <option value="auto">Auto (native container)</option>
<div v-if="store.purgeResult" class="purge-result"> <option value="mp3">MP3</option>
<p>Rows deleted: {{ store.purgeResult.rows_deleted }}</p> <option value="m4a">M4A (AAC)</option>
<p>Files deleted: {{ store.purgeResult.files_deleted }}</p> <option value="flac">FLAC</option>
<p>Files already gone: {{ store.purgeResult.files_missing }}</p> <option value="wav">WAV</option>
<p>Active jobs skipped: {{ store.purgeResult.active_skipped }}</p> <option value="opus">Opus</option>
</div> </select>
</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>
<textarea
id="welcome-msg"
v-model="welcomeMessage"
rows="3"
class="settings-textarea"
placeholder="Enter a welcome message…"
></textarea>
</div>
<div class="settings-field" style="margin-top: var(--space-lg);">
<label>Default Output Formats</label>
<p class="field-hint">When "Auto" is selected, files are converted to these formats instead of the native container.</p>
<div class="format-defaults">
<div class="format-default-row">
<span class="format-default-label">Video</span>
<select v-model="defaultVideoFormat" class="settings-select">
<option value="auto">Auto (native container)</option>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</div>
<div class="format-default-row">
<span class="format-default-label">Audio</span>
<select v-model="defaultAudioFormat" class="settings-select">
<option value="auto">Auto (native container)</option>
<option value="mp3">MP3</option>
<option value="m4a">M4A (AAC)</option>
<option value="flac">FLAC</option>
<option value="wav">WAV</option>
<option value="opus">Opus</option>
</select>
</div> </div>
</div> </div>
</div>
<div class="settings-actions"> <div class="settings-actions">
<button
@click="saveSettings"
:disabled="store.isLoading"
class="btn-save"
>
{{ store.isLoading ? 'Saving…' : 'Save Settings' }}
</button>
<span v-if="settingsSaved" class="save-confirm"> Saved</span>
</div>
<p class="field-hint" style="margin-top: var(--space-md);">
Changes are applied immediately but reset on server restart.
</p>
<hr class="settings-divider" />
<div class="settings-field">
<label>Change Password</label>
<p class="field-hint">Update the admin password. Takes effect immediately but resets on server restart.</p>
<div class="password-fields">
<input
v-model="currentPassword"
type="password"
placeholder="Current password"
autocomplete="current-password"
class="settings-input"
/>
<input
v-model="newPassword"
type="password"
placeholder="New password"
autocomplete="new-password"
class="settings-input"
/>
<input
v-model="confirmPassword"
type="password"
placeholder="Confirm new password"
autocomplete="new-password"
class="settings-input"
@keydown.enter="changePassword"
/>
<span
v-if="confirmPassword && newPassword && confirmPassword !== newPassword"
class="password-mismatch"
>
Passwords don't match
</span>
</div>
<div class="settings-actions" style="margin-top: var(--space-sm);">
<button <button
@click="changePassword" @click="saveSettings"
:disabled="!canChangePassword || changingPassword" :disabled="store.isLoading"
class="btn-save" class="btn-save"
> >
{{ changingPassword ? 'Changing…' : 'Change Password' }} {{ store.isLoading ? 'Saving…' : 'Save Settings' }}
</button> </button>
<span v-if="passwordChanged" class="save-confirm"> Password changed</span> <span v-if="settingsSaved" class="save-confirm"> Saved</span>
<span v-if="passwordError" class="password-error">{{ passwordError }}</span> </div>
<p class="field-hint" style="margin-top: var(--space-sm);">
Changes are applied immediately but reset on server restart.
</p>
</div>
<hr class="settings-divider" />
<!-- Section: Privacy & Data -->
<div class="settings-section">
<h3 class="section-heading">Privacy &amp; Data</h3>
<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">
Automatically purge download history, files, and session data
after the retention period. Changes are saved with the button above.
</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>
<div class="settings-field" style="margin-top: var(--space-md);">
<label>Manual Purge</label>
<p class="field-hint">
Immediately clear all completed and failed downloads removes
database records and 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>
</div>
<hr class="settings-divider" />
<!-- Section: Security -->
<div class="settings-section">
<h3 class="section-heading">Security</h3>
<div class="settings-field">
<label>Change Password</label>
<p class="field-hint">Update the admin password. Takes effect immediately but resets on server restart.</p>
<div class="password-fields">
<input
v-model="currentPassword"
type="password"
placeholder="Current password"
autocomplete="current-password"
class="settings-input"
/>
<input
v-model="newPassword"
type="password"
placeholder="New password"
autocomplete="new-password"
class="settings-input"
/>
<input
v-model="confirmPassword"
type="password"
placeholder="Confirm new password"
autocomplete="new-password"
class="settings-input"
@keydown.enter="changePassword"
/>
<span
v-if="confirmPassword && newPassword && confirmPassword !== newPassword"
class="password-mismatch"
>
Passwords don't match
</span>
</div>
<div class="settings-actions" style="margin-top: var(--space-sm);">
<button
@click="changePassword"
:disabled="!canChangePassword || changingPassword"
class="btn-save"
>
{{ changingPassword ? 'Changing…' : 'Change Password' }}
</button>
<span v-if="passwordChanged" class="save-confirm"> Password changed</span>
<span v-if="passwordError" class="password-error">{{ passwordError }}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -541,6 +552,18 @@ h3 {
text-align: center; text-align: center;
} }
.settings-section {
margin-bottom: var(--space-sm);
}
.section-heading {
font-size: var(--font-size-md);
font-weight: 600;
color: var(--color-text);
margin: 0 0 var(--space-md) 0;
letter-spacing: 0.02em;
}
.settings-field { .settings-field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;