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)
|
||||
|
||||
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:
|
||||
"""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
|
||||
overrides = getattr(request.app.state, "settings_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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -17,31 +17,39 @@ from app.core.config import AppConfig
|
|||
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.
|
||||
|
||||
When privacy_mode is active, uses privacy_retention_hours.
|
||||
Otherwise uses max_age_hours.
|
||||
When *purge_all* is True, deletes ALL completed/failed jobs regardless
|
||||
of age (manual "clear everything" behavior).
|
||||
|
||||
Deletes completed/failed/expired jobs older than the configured
|
||||
retention period and their associated files from disk.
|
||||
Otherwise respects retention: privacy_retention_hours when privacy mode
|
||||
is active, max_age_hours otherwise.
|
||||
|
||||
Returns a summary dict with counts.
|
||||
"""
|
||||
overrides = getattr(config, "_runtime_overrides", {})
|
||||
privacy_on = overrides.get("privacy_mode", config.purge.privacy_mode)
|
||||
|
||||
if privacy_on:
|
||||
retention = overrides.get(
|
||||
"privacy_retention_hours", config.purge.privacy_retention_hours
|
||||
)
|
||||
if purge_all:
|
||||
cutoff = datetime.now(timezone.utc).isoformat() # everything up to now
|
||||
logger.info("Purge ALL starting (manual clear)")
|
||||
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)
|
||||
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(
|
||||
|
|
@ -58,6 +66,7 @@ async def run_purge(db: aiosqlite.Connection, config: AppConfig) -> dict:
|
|||
files_deleted = 0
|
||||
files_missing = 0
|
||||
rows_deleted = 0
|
||||
deleted_job_ids: list[str] = []
|
||||
|
||||
for row in rows:
|
||||
job_id = row["id"]
|
||||
|
|
@ -80,6 +89,7 @@ async def run_purge(db: aiosqlite.Connection, config: AppConfig) -> dict:
|
|||
# Delete DB row
|
||||
await db.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
|
||||
rows_deleted += 1
|
||||
deleted_job_ids.append(job_id)
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
|
@ -95,6 +105,7 @@ async def run_purge(db: aiosqlite.Connection, config: AppConfig) -> dict:
|
|||
"files_deleted": files_deleted,
|
||||
"files_missing": files_missing,
|
||||
"active_skipped": active_skipped,
|
||||
"deleted_job_ids": deleted_job_ids,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -247,160 +247,171 @@ function formatFilesize(bytes: number | null): string {
|
|||
|
||||
<!-- 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>
|
||||
<!-- Section: Appearance & Defaults -->
|
||||
<div class="settings-section">
|
||||
<h3 class="section-heading">Appearance & Defaults</h3>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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 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 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);">
|
||||
<div class="settings-actions">
|
||||
<button
|
||||
@click="changePassword"
|
||||
:disabled="!canChangePassword || changingPassword"
|
||||
@click="saveSettings"
|
||||
:disabled="store.isLoading"
|
||||
class="btn-save"
|
||||
>
|
||||
{{ changingPassword ? 'Changing…' : 'Change Password' }}
|
||||
{{ store.isLoading ? 'Saving…' : 'Save Settings' }}
|
||||
</button>
|
||||
<span v-if="passwordChanged" class="save-confirm">✓ Password changed</span>
|
||||
<span v-if="passwordError" class="password-error">{{ passwordError }}</span>
|
||||
<span v-if="settingsSaved" class="save-confirm">✓ Saved</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>
|
||||
|
|
@ -541,6 +552,18 @@ h3 {
|
|||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue