diff --git a/apps/media_library/api_views.py b/apps/media_library/api_views.py index efc00239..8d4c7e2c 100644 --- a/apps/media_library/api_views.py +++ b/apps/media_library/api_views.py @@ -13,7 +13,13 @@ from rest_framework.exceptions import NotFound, ValidationError from apps.accounts.permissions import Authenticated from apps.media_library import models, serializers -from apps.media_library.tasks import enqueue_library_scan, sync_metadata_task +from apps.media_library.tasks import ( + enqueue_library_scan, + sync_metadata_task, + cancel_library_scan, + revoke_scan_task, + start_next_library_scan, +) logger = logging.getLogger(__name__) @@ -53,7 +59,7 @@ class LibraryViewSet(viewsets.ModelViewSet): return Response(serializer.data, status=status.HTTP_202_ACCEPTED) -class LibraryScanViewSet(viewsets.ReadOnlyModelViewSet): +class LibraryScanViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet): queryset = models.LibraryScan.objects.select_related("library", "created_by") serializer_class = serializers.LibraryScanSerializer permission_classes = [Authenticated] @@ -61,6 +67,31 @@ class LibraryScanViewSet(viewsets.ReadOnlyModelViewSet): filterset_fields = ["library", "status"] ordering_fields = ["created_at", "started_at", "finished_at"] ordering = ["-created_at"] + http_method_names = ["get", "head", "options", "delete", "post"] + + def destroy(self, request, *args, **kwargs): + scan = self.get_object() + + if scan.status != models.LibraryScan.STATUS_PENDING: + raise ValidationError({"detail": "Only pending scans can be removed from the queue."}) + + revoke_scan_task(scan.task_id, terminate=False) + library = scan.library + response = super().destroy(request, *args, **kwargs) + start_next_library_scan(library) + return response + + @action(detail=True, methods=["post"], url_path="cancel") + def cancel(self, request, pk=None): + scan = self.get_object() + summary = request.data.get("summary") + try: + cancelled = cancel_library_scan(scan, summary=summary) + except ValueError as exc: # noqa: BLE001 + raise ValidationError({"detail": str(exc)}) + + serializer = self.get_serializer(cancelled) + return Response(serializer.data, status=status.HTTP_200_OK) class MediaItemViewSet(viewsets.ModelViewSet): diff --git a/apps/media_library/tasks.py b/apps/media_library/tasks.py index 39ca6813..747fcc34 100644 --- a/apps/media_library/tasks.py +++ b/apps/media_library/tasks.py @@ -3,6 +3,7 @@ from datetime import timedelta from typing import Optional, Set from celery import shared_task +from celery.result import AsyncResult from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.db import transaction @@ -80,6 +81,73 @@ def enqueue_library_scan( return scan +def _revoke_scan_task(task_id: str | None, *, terminate: bool = False) -> None: + if not task_id: + return + try: + AsyncResult(task_id).revoke(terminate=terminate) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to revoke scan task %s: %s", task_id, exc) + + +def start_next_library_scan(library: Library) -> None: + """Public helper for kicking off the next pending scan.""" + + _start_next_scan(library) + + +def revoke_scan_task(task_id: str | None, *, terminate: bool = False) -> None: + """Public helper to revoke a Celery scan task safely.""" + + _revoke_scan_task(task_id, terminate=terminate) + + +def cancel_library_scan(scan: LibraryScan, *, summary: str | None = None) -> LibraryScan: + """Cancel a running or pending library scan.""" + + if scan.status not in {LibraryScan.STATUS_RUNNING, LibraryScan.STATUS_PENDING}: + raise ValueError("Only running or pending scans can be cancelled") + + library = scan.library + terminate = scan.status == LibraryScan.STATUS_RUNNING + _revoke_scan_task(scan.task_id, terminate=terminate) + + now = timezone.now() + scan.status = LibraryScan.STATUS_CANCELLED + scan.finished_at = now + update_fields = ["status", "finished_at", "updated_at"] + + if summary: + scan.summary = summary + update_fields.append("summary") + elif not scan.summary: + scan.summary = "Cancelled by user" + update_fields.append("summary") + + if scan.task_id: + scan.task_id = None + update_fields.append("task_id") + + scan.save(update_fields=update_fields) + + _send_scan_event( + { + "status": "cancelled", + "scan_id": str(scan.id), + "library_id": scan.library_id, + "library_name": library.name if library else "", + "summary": scan.summary, + "processed": scan.processed_files, + "processed_files": scan.processed_files, + "total": scan.total_files, + } + ) + + _start_next_scan(library) + scan.refresh_from_db() + return scan + + def _send_scan_event(event: dict) -> None: try: channel_layer = get_channel_layer() diff --git a/frontend/src/api.js b/frontend/src/api.js index 4fb2e19d..8009e238 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2289,6 +2289,30 @@ export default class API { } } + static async cancelLibraryScan(id, payload = {}) { + try { + const response = await request(`${host}/api/media/scans/${id}/cancel/`, { + method: 'POST', + body: payload, + }); + return response; + } catch (e) { + errorNotification('Failed to cancel library scan', e); + throw e; + } + } + + static async deleteLibraryScan(id) { + try { + await request(`${host}/api/media/scans/${id}/`, { + method: 'DELETE', + }); + } catch (e) { + errorNotification('Failed to remove library scan', e); + throw e; + } + } + static async getMediaItems(params = new URLSearchParams()) { try { const query = params.toString(); diff --git a/frontend/src/pages/Library.jsx b/frontend/src/pages/Library.jsx index 7aa71c8a..a5edc595 100644 --- a/frontend/src/pages/Library.jsx +++ b/frontend/src/pages/Library.jsx @@ -97,6 +97,8 @@ const LibraryPage = () => { const fetchLibraries = useLibraryStore((s) => s.fetchLibraries); const createLibrary = useLibraryStore((s) => s.createLibrary); const triggerScan = useLibraryStore((s) => s.triggerScan); + const upsertScan = useLibraryStore((s) => s.upsertScan); + const removeScan = useLibraryStore((s) => s.removeScan); const selectedLibraryId = useLibraryStore((s) => s.selectedLibraryId); const setSelectedLibrary = useLibraryStore((s) => s.setSelectedLibrary); @@ -420,7 +422,10 @@ const LibraryPage = () => { // Cancel a running scan by job id const handleCancelScanJob = async (jobId) => { try { - await API.cancelLibraryScan(jobId); // implement in API + const updated = await API.cancelLibraryScan(jobId); + if (updated) { + upsertScan(updated); + } notifications.show({ title: 'Scan canceled', message: 'The running scan has been stopped.', @@ -439,7 +444,8 @@ const LibraryPage = () => { // Remove a queued scan by job id const handleDeleteQueuedScan = async (jobId) => { try { - await API.deleteLibraryScan(jobId); // implement in API + await API.deleteLibraryScan(jobId); + removeScan(jobId); notifications.show({ title: 'Removed from queue', message: 'The queued scan was removed.', diff --git a/frontend/src/store/library.jsx b/frontend/src/store/library.jsx index 3de8eff6..21fb5ec6 100644 --- a/frontend/src/store/library.jsx +++ b/frontend/src/store/library.jsx @@ -201,6 +201,36 @@ const useLibraryStore = create( state.scans[key] = updateList(state.scans[key]); }); }), + + upsertScan: (scan) => + set((state) => { + if (!scan || !scan.id) return; + const normalized = normalizeScanEntry(scan); + const targetKeys = new Set(['all']); + if (normalized.library) { + targetKeys.add(normalized.library); + } + targetKeys.forEach((key) => { + const items = state.scans[key] ? [...state.scans[key]] : []; + const index = items.findIndex((entry) => String(entry.id) === String(normalized.id)); + if (index >= 0) { + items[index] = { ...items[index], ...normalized }; + } else { + items.unshift(normalized); + } + state.scans[key] = items; + }); + }), + + removeScan: (scanId) => + set((state) => { + if (!scanId) return; + Object.keys(state.scans).forEach((key) => { + const list = state.scans[key]; + if (!Array.isArray(list)) return; + state.scans[key] = list.filter((scan) => String(scan.id) !== String(scanId)); + }); + }), })) );