diff --git a/backend/app/core/sse_broker.py b/backend/app/core/sse_broker.py index f409e32..7acf800 100644 --- a/backend/app/core/sse_broker.py +++ b/backend/app/core/sse_broker.py @@ -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*. diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index bc31779..f9ce2fb 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -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 diff --git a/backend/app/services/purge.py b/backend/app/services/purge.py index 20c8a68..0331904 100644 --- a/backend/app/services/purge.py +++ b/backend/app/services/purge.py @@ -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( diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index c3bd06a..2ad52cf 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -247,160 +247,171 @@ function formatFilesize(bytes: number | null): string {
- -
- -

- When enabled, download history, session logs, and files are automatically purged - after the configured retention period. -

-
- -
- - hours -
-

- Data older than this is automatically purged (default: 24 hours). -

+ +
+

Appearance & Defaults

+ +
+ +

Displayed above the URL input on the main page. Leave empty to hide.

+
-
-
- - -
- -

- Immediately remove expired downloads and their files from disk. - Active downloads are never affected. -

- -
-

Rows deleted: {{ store.purgeResult.rows_deleted }}

-

Files deleted: {{ store.purgeResult.files_deleted }}

-

Files already gone: {{ store.purgeResult.files_missing }}

-

Active jobs skipped: {{ store.purgeResult.active_skipped }}

-
-
- -
- -
- -

Displayed above the URL input on the main page. Leave empty to hide.

- -
- -
- -

When "Auto" is selected, files are converted to these formats instead of the native container.

-
-
- Video - -
-
- Audio - +
+ +

When "Auto" is selected, files are converted to these formats instead of the native container.

+
+
+ Video + +
+
+ Audio + +
-
-
- - ✓ Saved -
-

- Changes are applied immediately but reset on server restart. -

- -
- -
- -

Update the admin password. Takes effect immediately but resets on server restart.

-
- - - - - Passwords don't match - -
-
+
- ✓ Password changed - {{ passwordError }} + ✓ Saved +
+

+ Changes are applied immediately but reset on server restart. +

+
+ +
+ + +
+

Privacy & Data

+ +
+ +

+ Automatically purge download history, files, and session data + after the retention period. Changes are saved with the button above. +

+
+ +
+ + hours +
+

+ Data older than this is automatically purged (default: 24 hours). +

+
+
+ +
+ +

+ Immediately clear all completed and failed downloads — removes + database records and files from disk. Active downloads are never affected. +

+ +
+

Rows deleted: {{ store.purgeResult.rows_deleted }}

+

Files deleted: {{ store.purgeResult.files_deleted }}

+

Files already gone: {{ store.purgeResult.files_missing }}

+

Active jobs skipped: {{ store.purgeResult.active_skipped }}

+
+
+
+ +
+ + +
+

Security

+ +
+ +

Update the admin password. Takes effect immediately but resets on server restart.

+
+ + + + + Passwords don't match + +
+
+ + ✓ Password changed + {{ passwordError }} +
@@ -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;