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. -
-- Data older than this is automatically purged (default: 24 hours). -
+ +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.
-When "Auto" is selected, files are converted to these formats instead of the native container.
+- Changes are applied immediately but reset on server restart. -
- -Update the admin password. Takes effect immediately but resets on server restart.
-+ Changes are applied immediately but reset on server restart. +
++ Automatically purge download history, files, and session data + after the retention period. Changes are saved with the button above. +
++ 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 }}
+Update the admin password. Takes effect immediately but resets on server restart.
+