Added api backend

This commit is contained in:
Dispatcharr 2025-09-17 21:05:58 -05:00
parent 2e4dec6de3
commit 9c9ec3ade3
5 changed files with 163 additions and 4 deletions

View file

@ -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):

View file

@ -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()

View file

@ -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();

View file

@ -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.',

View file

@ -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));
});
}),
}))
);