mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Added api backend
This commit is contained in:
parent
2e4dec6de3
commit
9c9ec3ade3
5 changed files with 163 additions and 4 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}),
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue