mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
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:
parent
c3278fcac2
commit
dd60505f5a
4 changed files with 220 additions and 160 deletions
|
|
@ -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*.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 & 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 & 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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue