Every auto-matched, duplicate or manually resolved upload left a completed
TempUpload row on the board until it was dismissed by hand, so the rows
accumulated without bound and the bulk dismiss (capped at 1000 ids) failed
once there were more. The original app never stored these: they are
notifications, not records.
- complete_temp_upload now appends {filename, J-ID, resolution, post} to a
bounded recent_completions feed on UploadRun and deletes the staged row
- staging duplicates never create a board record either; the create response
carries the J-ID and preview so the SPA can show the card immediately
- status_payload returns the feed (newest first, signed thumbnails) for the
live board; finalize_round counts deleted matches in processed
- resolve/link-bulk return synthetic completion payloads
- migration 0012 adds the field and purges the existing completed backlog
(and any stray staged files) on deploy
687 lines
28 KiB
Python
687 lines
28 KiB
Python
"""Staged uploads: files land in a temp folder until they are resolved.
|
|
|
|
Flow:
|
|
- POST /api/uploads/ stage a file (computes MD5, detects duplicates)
|
|
- GET /api/uploads/ list my staged uploads
|
|
- GET /api/uploads/<id>/file/ serve the staged file (previews, IQDB)
|
|
- POST /api/uploads/<id>/iqdb/ store IQDB candidates -> visual_match
|
|
- POST /api/uploads/<id>/resolve/ link to a post or apply custom metadata
|
|
- DELETE /api/uploads/<id>/ discard a staged upload / dismiss a record
|
|
"""
|
|
|
|
import logging
|
|
import shutil
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from django.conf import settings
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.exceptions import ValidationError
|
|
from django.http import Http404
|
|
from django.utils import timezone
|
|
from rest_framework import mixins, status, viewsets
|
|
from rest_framework.decorators import action
|
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
|
from rest_framework.permissions import AllowAny
|
|
from rest_framework.response import Response
|
|
|
|
from . import services
|
|
from .models import MediaItem, TempUpload
|
|
from .permissions import CanUpload
|
|
from .serializers import TempUploadListSerializer, TempUploadSerializer
|
|
from .signing_urls import load_payload
|
|
from .tools import HASH_FIELDS, hashed_items, hashes_similarity
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def build_hash_index():
|
|
"""Library hash mappings for similarity scans, loaded once per batch.
|
|
|
|
Only items that actually carry perceptual hashes are included; the old
|
|
per-file scan walked every row (including videos and unchecked items).
|
|
"""
|
|
algorithms = list(HASH_FIELDS)
|
|
return [
|
|
(item, {field: getattr(item, field, "") for field in algorithms})
|
|
for item in hashed_items(algorithms)
|
|
]
|
|
|
|
|
|
def match_hashes(hashes, index, limit=10, user=None, request=None):
|
|
"""Library items whose perceptual hashes are close to ``hashes``."""
|
|
algorithms = list(HASH_FIELDS)
|
|
threshold = settings.VISUAL_MATCH_THRESHOLD
|
|
matches = []
|
|
for item, item_hashes in index:
|
|
similarity = hashes_similarity(hashes, item_hashes, algorithms, threshold)
|
|
if similarity is None:
|
|
continue
|
|
location = item.locations.first()
|
|
matches.append(
|
|
{
|
|
"j_id": f"J-{item.id}",
|
|
"filename": Path(location.rel_path).name if location else item.md5,
|
|
"similarity": round(similarity * 100, 1),
|
|
"thumbnail_url": services.signed_media_url(
|
|
item, user, "thumbnail", request=request
|
|
),
|
|
}
|
|
)
|
|
matches.sort(key=lambda entry: entry["similarity"], reverse=True)
|
|
return matches[:limit]
|
|
|
|
|
|
def find_library_matches(path, limit=10, user=None, request=None, index=None):
|
|
"""Library items visually similar to a staged file.
|
|
|
|
Pass a prebuilt ``index`` (see build_hash_index) to reuse it across a
|
|
whole batch instead of rescanning the library per file.
|
|
"""
|
|
hashes = services.compute_visual_hashes(path)
|
|
if not hashes:
|
|
return []
|
|
if index is None:
|
|
index = build_hash_index()
|
|
return match_hashes(hashes, index, limit=limit, user=user, request=request)
|
|
|
|
|
|
def complete_temp_upload(temp, download_url=None):
|
|
"""Index the upload into the library.
|
|
|
|
With a ``download_url`` the e621 original is fetched and the staged copy
|
|
is dropped; otherwise the staged file itself moves into the library.
|
|
"""
|
|
folder = Path(settings.WATCHED_FOLDER)
|
|
|
|
if download_url:
|
|
file_data = {}
|
|
if isinstance(temp.e621_data, dict):
|
|
file_data = temp.e621_data.get("file") or {}
|
|
extension = Path(urlparse(download_url).path).suffix
|
|
if not extension:
|
|
extension = Path(temp.original_filename).suffix
|
|
md5 = file_data.get("md5")
|
|
name = f"{md5}{extension}" if md5 else (temp.original_filename or "download")
|
|
destination = services.unique_destination(folder, name)
|
|
try:
|
|
services.download_file(download_url, destination)
|
|
except Exception:
|
|
destination.unlink(missing_ok=True)
|
|
raise
|
|
item, _, location, _ = services.index_file(destination, folder)
|
|
services.rename_location_to_j_id(item, location)
|
|
services.ensure_visual_hashes(item)
|
|
if temp.file:
|
|
temp.file.delete(save=False)
|
|
else:
|
|
if not temp.file:
|
|
raise ValueError("staged file is missing")
|
|
destination = services.unique_destination(
|
|
folder, temp.original_filename or "upload"
|
|
)
|
|
with temp.file.open("rb") as source, open(destination, "wb") as target:
|
|
shutil.copyfileobj(source, target)
|
|
item, _, location, _ = services.index_file(destination, folder)
|
|
services.rename_location_to_j_id(item, location)
|
|
services.ensure_visual_hashes(item)
|
|
temp.file.delete(save=False)
|
|
|
|
temp.library_item = item
|
|
temp.status = TempUpload.STATUS_COMPLETED
|
|
|
|
update_fields = []
|
|
if item.uploaded_by_id is None:
|
|
item.uploaded_by = temp.user
|
|
update_fields.append("uploaded_by")
|
|
if temp.e621_post_id is not None:
|
|
item.e621_post_id = temp.e621_post_id
|
|
item.e621_data = temp.e621_data
|
|
item.e621_match_status = MediaItem.E621_MATCHED
|
|
item.e621_checked_at = timezone.now()
|
|
update_fields += [
|
|
"e621_post_id",
|
|
"e621_data",
|
|
"e621_match_status",
|
|
"e621_checked_at",
|
|
]
|
|
if temp.custom_rating in {"s", "q", "e"} and not item.rating:
|
|
item.rating = temp.custom_rating
|
|
update_fields.append("rating")
|
|
if temp.custom_tags:
|
|
item.tags = temp.custom_tags
|
|
update_fields.append("tags")
|
|
if temp.custom_notes:
|
|
item.notes = temp.custom_notes
|
|
update_fields.append("notes")
|
|
if update_fields:
|
|
item.save(update_fields=update_fields + ["updated_at"])
|
|
|
|
temp.save()
|
|
from .upload_pipeline import record_completion
|
|
|
|
record_completion(temp, item)
|
|
# The board learns about completions from the live feed, so the record is
|
|
# deleted as soon as the file is indexed: nothing left to dismiss. Delete
|
|
# through the queryset so callers keep ``temp.pk`` for their response.
|
|
TempUpload.objects.filter(pk=temp.pk).delete()
|
|
return item
|
|
|
|
|
|
class TempUploadViewSet(
|
|
mixins.ListModelMixin,
|
|
mixins.RetrieveModelMixin,
|
|
mixins.DestroyModelMixin,
|
|
viewsets.GenericViewSet,
|
|
):
|
|
serializer_class = TempUploadSerializer
|
|
permission_classes = [CanUpload]
|
|
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
|
# Unpaginated: the board shows every staged upload (69-file batches were
|
|
# silently cut to the API's 48-item page).
|
|
pagination_class = None
|
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
|
|
|
def get_serializer_class(self):
|
|
# The board polls the list, so its payload stays small; the metadata
|
|
# modal fetches the full row from the detail endpoint.
|
|
if self.action == "list":
|
|
return TempUploadListSerializer
|
|
return TempUploadSerializer
|
|
|
|
def get_queryset(self):
|
|
# Staged uploads are private: everyone, staff included, only sees
|
|
# their own board. (The file action still lets staff read bytes by id
|
|
# for support purposes.)
|
|
return TempUpload.objects.select_related("library_item").filter(
|
|
user=self.request.user
|
|
)
|
|
|
|
def _completed_payload(self, temp, item):
|
|
"""Synthetic row for an upload that is indexed immediately.
|
|
|
|
Duplicates and resolved uploads never leave a board record; the SPA
|
|
turns this response (or the live completion feed) into a "J-x
|
|
uploaded" card that lives only in the page session.
|
|
"""
|
|
return {
|
|
"temp_id": str(temp.pk),
|
|
"original_filename": temp.original_filename,
|
|
"md5": temp.md5,
|
|
"size": temp.size,
|
|
"status": TempUpload.STATUS_COMPLETED,
|
|
"resolution": temp.resolution,
|
|
"e621_post_id": temp.e621_post_id,
|
|
"library_j_id": f"J-{item.id}",
|
|
"file_url": None,
|
|
"preview_url": services.signed_media_url(
|
|
item, self.request.user, "thumbnail", request=self.request
|
|
),
|
|
}
|
|
|
|
def create(self, request):
|
|
upload = request.FILES.get("file")
|
|
if upload is None:
|
|
return Response(
|
|
{"detail": "A file is required."}, status=status.HTTP_400_BAD_REQUEST
|
|
)
|
|
extension = Path(upload.name).suffix.lower()
|
|
if extension not in services.ALLOWED_EXTENSIONS:
|
|
return Response(
|
|
{"detail": f"Unsupported file type: {extension or 'unknown'}"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
temp = TempUpload.objects.create(
|
|
user=request.user,
|
|
file=upload,
|
|
original_filename=upload.name,
|
|
size=upload.size,
|
|
)
|
|
temp.md5 = services.compute_md5(temp.file.path)
|
|
|
|
existing = MediaItem.objects.filter(md5=temp.md5).first()
|
|
if existing is not None:
|
|
temp.status = TempUpload.STATUS_COMPLETED
|
|
temp.resolution = TempUpload.RESOLUTION_DUPLICATE
|
|
temp.library_item = existing
|
|
temp.file.delete(save=False)
|
|
temp.save()
|
|
from .upload_pipeline import record_completion
|
|
|
|
record_completion(temp, existing)
|
|
payload = self._completed_payload(temp, existing)
|
|
TempUpload.objects.filter(pk=temp.pk).delete()
|
|
return Response(payload, status=status.HTTP_201_CREATED)
|
|
# Visual similarity and IQDB run in the background pipeline so a large
|
|
# batch uploads at full speed and the work survives the browser.
|
|
temp.save()
|
|
# Kick the server-side pipeline; staging no longer waits on e621 and
|
|
# the work continues even if the browser navigates away.
|
|
from .upload_pipeline import start_pipeline
|
|
|
|
start_pipeline(request.user)
|
|
return Response(
|
|
self.get_serializer(temp).data, status=status.HTTP_201_CREATED
|
|
)
|
|
|
|
@action(detail=True, methods=["post"], url_path="visual-match")
|
|
def visual_match(self, request, pk=None):
|
|
"""Run the local visual-similarity pass for one staged upload."""
|
|
temp = self.get_object()
|
|
if temp.status == TempUpload.STATUS_COMPLETED:
|
|
return Response(
|
|
{"detail": "This upload is already in the library."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if not temp.file:
|
|
return Response(
|
|
{"detail": "The staged file is missing."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
matches = find_library_matches(
|
|
temp.file.path, user=request.user, request=request
|
|
)
|
|
temp.visual_matches = matches
|
|
if matches and temp.status == TempUpload.STATUS_PENDING:
|
|
temp.status = TempUpload.STATUS_VISUAL_MATCH
|
|
temp.save(update_fields=["visual_matches", "status", "updated_at"])
|
|
return Response(self.get_serializer(temp).data)
|
|
|
|
@action(detail=False, methods=["get"])
|
|
def status(self, request):
|
|
"""Cheap pipeline state for the shell indicator and the upload page."""
|
|
from .upload_pipeline import status_payload
|
|
|
|
return Response(status_payload(request.user, request=request))
|
|
|
|
@action(detail=False, methods=["post"])
|
|
def process(self, request):
|
|
"""Start (or resume) the pipeline for the caller's staged uploads.
|
|
|
|
Idempotent: the client calls this after staging files, on page load
|
|
and when a paused run should be retried.
|
|
"""
|
|
from .upload_pipeline import start_pipeline, status_payload
|
|
|
|
start_pipeline(request.user)
|
|
return Response(status_payload(request.user, request=request))
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def retry(self, request, pk=None):
|
|
"""Queue one staged upload for another pipeline pass."""
|
|
from .upload_pipeline import start_pipeline, status_payload
|
|
|
|
temp = self.get_object()
|
|
if temp.status == TempUpload.STATUS_COMPLETED:
|
|
return Response(
|
|
{"detail": "This upload is already in the library."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if not temp.file:
|
|
return Response(
|
|
{"detail": "The staged file is missing."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
update = {
|
|
"claimed_at": None,
|
|
"attempts": 0,
|
|
"pipeline_error": "",
|
|
"updated_at": timezone.now(),
|
|
}
|
|
# An explicit phase re-runs that one check even if it already ran.
|
|
phase = str(request.data.get("phase") or "").strip()
|
|
if phase == "md5":
|
|
update["e621_checked_at"] = None
|
|
elif phase == "visual":
|
|
update["visual_matches"] = None
|
|
update["visual_checked_at"] = None
|
|
elif phase == "iqdb":
|
|
update["iqdb_data"] = None
|
|
if temp.status == TempUpload.STATUS_ERROR:
|
|
# A failed import has to go through the MD5 phase again so the
|
|
# completion is retried; other errors only re-run missing phases.
|
|
update["status"] = TempUpload.STATUS_PENDING
|
|
update["e621_checked_at"] = None
|
|
elif temp.status not in (
|
|
TempUpload.STATUS_PENDING,
|
|
TempUpload.STATUS_VISUAL_MATCH,
|
|
):
|
|
update["status"] = TempUpload.STATUS_PENDING
|
|
TempUpload.objects.filter(pk=temp.pk).update(**update)
|
|
start_pipeline(request.user)
|
|
return Response(status_payload(request.user, request=request))
|
|
|
|
@action(detail=False, methods=["post"], url_path="retry-all")
|
|
def retry_all(self, request):
|
|
"""Queue every retryable staged upload for another pipeline pass."""
|
|
from .upload_pipeline import start_pipeline, status_payload
|
|
|
|
now = timezone.now()
|
|
retryable = self.get_queryset().filter(
|
|
status__in=[
|
|
TempUpload.STATUS_PENDING,
|
|
TempUpload.STATUS_VISUAL_MATCH,
|
|
TempUpload.STATUS_ERROR,
|
|
]
|
|
)
|
|
retryable.exclude(file="").update(
|
|
claimed_at=None,
|
|
attempts=0,
|
|
pipeline_error="",
|
|
updated_at=now,
|
|
)
|
|
retryable.exclude(file="").filter(status=TempUpload.STATUS_ERROR).update(
|
|
status=TempUpload.STATUS_PENDING,
|
|
e621_checked_at=None,
|
|
)
|
|
start_pipeline(request.user)
|
|
return Response(status_payload(request.user, request=request))
|
|
|
|
@action(detail=False, methods=["post"], url_path="discard-bulk")
|
|
def discard_bulk(self, request):
|
|
"""Discard many staged uploads in one request (the board's "all")."""
|
|
ids = request.data.get("temp_ids")
|
|
if not isinstance(ids, list) or not ids:
|
|
return Response(
|
|
{"detail": "temp_ids must be a non-empty list."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if len(ids) > 1000:
|
|
return Response(
|
|
{"detail": "Too many ids in one request (max 1000)."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
values = list(dict.fromkeys(str(value) for value in ids))
|
|
try:
|
|
queryset = self.get_queryset().filter(pk__in=values)
|
|
except (ValidationError, ValueError):
|
|
return Response(
|
|
{"detail": "One or more temp_ids are not valid upload ids."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
by_id = {str(temp.pk): temp for temp in queryset}
|
|
|
|
discarded: list[str] = []
|
|
errors: list[dict[str, str]] = []
|
|
for value in values:
|
|
temp = by_id.get(value)
|
|
if temp is None:
|
|
errors.append({"temp_id": value, "error": "not found"})
|
|
continue
|
|
try:
|
|
self.perform_destroy(temp)
|
|
discarded.append(value)
|
|
except Exception as exc: # noqa: BLE001 - report per-file failures
|
|
logger.exception("Could not discard staged upload %s", value)
|
|
errors.append({"temp_id": value, "error": str(exc)})
|
|
return Response({"discarded": discarded, "errors": errors})
|
|
|
|
@action(
|
|
detail=True,
|
|
methods=["get", "head"],
|
|
permission_classes=[AllowAny],
|
|
throttle_classes=[],
|
|
)
|
|
def file(self, request, pk=None):
|
|
"""Serve the staged file; accepts a signed URL for media tags."""
|
|
user = request.user if request.user.is_authenticated else None
|
|
if user is None:
|
|
signature = request.query_params.get("sig")
|
|
if signature:
|
|
payload = load_payload(signature, services.UPLOAD_FILE_SALT)
|
|
if payload and str(payload.get("temp")) == str(pk):
|
|
user = (
|
|
get_user_model()
|
|
.objects.filter(pk=payload.get("user"))
|
|
.first()
|
|
)
|
|
if user is None or not user.can_upload:
|
|
return Response(
|
|
{"detail": "Authentication required."},
|
|
status=status.HTTP_401_UNAUTHORIZED,
|
|
)
|
|
|
|
temp = TempUpload.objects.filter(pk=pk).first()
|
|
is_owner = temp is not None and temp.user_id == user.id
|
|
if temp is None or not (is_owner or user.is_app_staff):
|
|
raise Http404
|
|
if not temp.file:
|
|
raise Http404
|
|
return services.serve_file(request, temp.file.path)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def iqdb(self, request, pk=None):
|
|
temp = self.get_object()
|
|
results = request.data.get("results")
|
|
if not isinstance(results, list):
|
|
return Response(
|
|
{"detail": "results must be a list."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
temp.iqdb_data = services.sanitize_iqdb_results(results)
|
|
if temp.status == TempUpload.STATUS_PENDING and temp.iqdb_data:
|
|
temp.status = TempUpload.STATUS_VISUAL_MATCH
|
|
temp.save(update_fields=["iqdb_data", "status", "updated_at"])
|
|
return Response(self.get_serializer(temp).data)
|
|
|
|
@action(detail=True, methods=["post"])
|
|
def resolve(self, request, pk=None):
|
|
temp = self.get_object()
|
|
if temp.status == TempUpload.STATUS_COMPLETED:
|
|
return Response(
|
|
{"detail": "This upload is already in the library."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
mode = str(request.data.get("mode") or "").strip()
|
|
download_url = None
|
|
if mode == "link":
|
|
post = request.data.get("post")
|
|
post_id = request.data.get("post_id")
|
|
trimmed = services.trim_e621_post(post)
|
|
has_numeric_id = post_id is not None and str(post_id).isdigit()
|
|
if trimmed is None and not has_numeric_id:
|
|
return Response(
|
|
{"detail": "A post payload or post_id is required."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
if trimmed is not None and trimmed.get("id") is not None:
|
|
temp.e621_post_id = int(trimmed["id"])
|
|
elif has_numeric_id:
|
|
temp.e621_post_id = int(post_id)
|
|
temp.e621_data = trimmed
|
|
temp.resolution = (
|
|
TempUpload.RESOLUTION_AUTO_MD5
|
|
if request.data.get("auto")
|
|
else TempUpload.RESOLUTION_LINKED
|
|
)
|
|
rating = str(request.data.get("rating") or "")
|
|
if rating in {"s", "q", "e"}:
|
|
temp.custom_rating = rating
|
|
tags = services.parse_tags(request.data.get("tags"))
|
|
if tags is not None:
|
|
temp.custom_tags = tags
|
|
notes = request.data.get("notes")
|
|
if notes is not None:
|
|
temp.custom_notes = str(notes)
|
|
|
|
# Prefer the e621 original; fall back to the staged file when the
|
|
# post has no URL or its file is byte-identical to the upload.
|
|
file_data = (trimmed or {}).get("file") or {}
|
|
candidate_url = str(
|
|
request.data.get("file_url") or file_data.get("url") or ""
|
|
).strip()
|
|
post_md5 = str(file_data.get("md5") or "").strip().lower()
|
|
if candidate_url and post_md5 and post_md5 == temp.md5.lower():
|
|
candidate_url = ""
|
|
download_url = candidate_url or None
|
|
elif mode == "custom":
|
|
rating = str(request.data.get("rating") or "")
|
|
temp.custom_rating = rating if rating in {"s", "q", "e"} else ""
|
|
temp.custom_tags = services.parse_tags(request.data.get("tags")) or []
|
|
temp.custom_notes = str(request.data.get("notes") or "")
|
|
temp.resolution = TempUpload.RESOLUTION_CUSTOM
|
|
else:
|
|
return Response(
|
|
{"detail": "mode must be 'link' or 'custom'."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
temp.save()
|
|
try:
|
|
item = complete_temp_upload(temp, download_url=download_url)
|
|
except Exception as exc: # noqa: BLE001 - report completion failures
|
|
logger.exception("Could not complete staged upload %s", temp.id)
|
|
temp.status = TempUpload.STATUS_ERROR
|
|
temp.save(update_fields=["status", "updated_at"])
|
|
return Response(
|
|
{"detail": f"Could not finish the upload: {exc}"},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
return Response(self._completed_payload(temp, item))
|
|
|
|
@action(detail=False, methods=["post"], url_path="link-bulk")
|
|
def link_bulk(self, request):
|
|
"""Attach e621 posts to many staged uploads in one request.
|
|
|
|
The upload board's MD5 phase sends one posts.json query per 75 files
|
|
and hands the matches over here, so a whole batch moves into the
|
|
library in one update instead of one request per file.
|
|
"""
|
|
links = request.data.get("links")
|
|
if not isinstance(links, list) or not links:
|
|
return Response(
|
|
{"detail": "links must be a non-empty list."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
ids = []
|
|
for link in links:
|
|
if not isinstance(link, dict) or link.get("temp_id") is None:
|
|
return Response(
|
|
{"detail": "Each link needs a temp_id."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
ids.append(str(link["temp_id"]))
|
|
try:
|
|
queryset = self.get_queryset().filter(pk__in=ids)
|
|
except (ValidationError, ValueError):
|
|
return Response(
|
|
{"detail": "One or more temp_ids are not valid upload ids."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
by_id = {str(temp.id): temp for temp in queryset}
|
|
|
|
updated = []
|
|
errors = []
|
|
for link in links:
|
|
temp_id = str(link["temp_id"])
|
|
temp = by_id.get(temp_id)
|
|
if temp is None:
|
|
errors.append({"temp_id": temp_id, "error": "not found"})
|
|
continue
|
|
if temp.status == TempUpload.STATUS_COMPLETED:
|
|
errors.append({"temp_id": temp_id, "error": "already in the library"})
|
|
continue
|
|
trimmed = services.trim_e621_post(link.get("post"))
|
|
post_id = link.get("post_id")
|
|
has_numeric_id = post_id is not None and str(post_id).isdigit()
|
|
if trimmed is None and not has_numeric_id:
|
|
errors.append({"temp_id": temp_id, "error": "no post payload"})
|
|
continue
|
|
if trimmed is not None and trimmed.get("id") is not None:
|
|
temp.e621_post_id = int(trimmed["id"])
|
|
elif has_numeric_id:
|
|
temp.e621_post_id = int(post_id)
|
|
temp.e621_data = trimmed
|
|
temp.resolution = (
|
|
TempUpload.RESOLUTION_AUTO_MD5
|
|
if link.get("auto", True)
|
|
else TempUpload.RESOLUTION_LINKED
|
|
)
|
|
# An MD5 match means the staged file is byte-identical, so there
|
|
# is nothing to download from e621; only link a remote URL when
|
|
# the post's file differs.
|
|
file_data = (trimmed or {}).get("file") or {}
|
|
candidate_url = str(
|
|
link.get("file_url") or file_data.get("url") or ""
|
|
).strip()
|
|
post_md5 = str(file_data.get("md5") or "").strip().lower()
|
|
if candidate_url and post_md5 and post_md5 == temp.md5.lower():
|
|
candidate_url = ""
|
|
temp.save()
|
|
try:
|
|
item = complete_temp_upload(temp, download_url=candidate_url or None)
|
|
except Exception as exc: # noqa: BLE001 - report per-file failures
|
|
logger.exception("Could not complete staged upload %s", temp.id)
|
|
temp.status = TempUpload.STATUS_ERROR
|
|
temp.save(update_fields=["status", "updated_at"])
|
|
errors.append({"temp_id": temp_id, "error": str(exc)})
|
|
continue
|
|
updated.append(self._completed_payload(temp, item))
|
|
|
|
return Response({"updated": updated, "errors": errors})
|
|
|
|
@action(detail=False, methods=["post"], url_path="resolve-bulk")
|
|
def resolve_bulk(self, request):
|
|
"""Move many staged uploads into the library with one rating.
|
|
|
|
The upload board's bulk tool: pick a rating, tick pending files, and
|
|
they are all resolved as custom entries (their staged tags/notes are
|
|
kept). Own rows only, like the rest of the viewset.
|
|
"""
|
|
ids = request.data.get("temp_ids")
|
|
if not isinstance(ids, list) or not ids:
|
|
return Response(
|
|
{"detail": "temp_ids must be a non-empty list."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
rating = str(request.data.get("rating") or "").strip()
|
|
if rating not in {"s", "q", "e"}:
|
|
return Response(
|
|
{"detail": "rating must be one of s, q or e."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
try:
|
|
queryset = self.get_queryset().filter(
|
|
pk__in=[str(value) for value in ids]
|
|
)
|
|
except (ValidationError, ValueError):
|
|
return Response(
|
|
{"detail": "One or more ids are not valid upload ids."},
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
)
|
|
|
|
resolved: list[str] = []
|
|
errors: list[dict[str, str]] = []
|
|
for temp in queryset:
|
|
if temp.status == TempUpload.STATUS_COMPLETED:
|
|
errors.append(
|
|
{"temp_id": str(temp.id), "error": "already in the library"}
|
|
)
|
|
continue
|
|
temp.custom_rating = rating
|
|
temp.resolution = TempUpload.RESOLUTION_CUSTOM
|
|
temp.save(update_fields=["custom_rating", "resolution", "updated_at"])
|
|
try:
|
|
complete_temp_upload(temp)
|
|
except Exception as exc: # noqa: BLE001 - report per-file failures
|
|
logger.exception("Could not complete staged upload %s", temp.id)
|
|
temp.status = TempUpload.STATUS_ERROR
|
|
temp.save(update_fields=["status", "updated_at"])
|
|
errors.append({"temp_id": str(temp.id), "error": str(exc)})
|
|
continue
|
|
resolved.append(str(temp.id))
|
|
|
|
found = {str(temp.id) for temp in queryset}
|
|
for value in sorted({str(value) for value in ids} - found):
|
|
errors.append({"temp_id": value, "error": "not found"})
|
|
|
|
return Response({"resolved": resolved, "errors": errors})
|
|
|
|
def perform_destroy(self, instance):
|
|
if instance.file:
|
|
instance.file.delete(save=False)
|
|
instance.delete()
|