Backend: - Perceptual hashes (aHash/dHash/pHash/wHash via imagehash, no imgdd) stored on items, computed on upload/download and by the new compute_visual_hashes command - Duplicates API: exact duplicates (multi-location items), visual matches for one item, union-find similarity groups with pagination - Delete API with ownership/staff checks, per-item and per-copy deletion, watched-folder path validation; storage overview and temp cleanup; file list accepts j_ids batches - Staged uploads are flagged visual_match with their library matches (threshold via VISUAL_MATCH_THRESHOLD) - Staff users API: list with upload counts, set role and avatar by J-ID; User.avatar FK with signed avatar URLs - Download threads close their DB connection and stale tasks are reaped, keeping behaviour Gunicorn-friendly Frontend: - /duplicates: exact duplicate groups with per-copy delete, visual similarity controls, search similar to a J-ID, paginated groups with selection, bulk delete and dismiss - /delete: storage cards, delete by J-ID with preview grid, temp cleanup - /users: staff directory with role selects and avatar J-ID inputs - Nav + command palette entries; top-bar avatar; upload cards and the metadata modal show library visual matches
117 lines
3.4 KiB
Python
117 lines
3.4 KiB
Python
from pathlib import Path
|
|
|
|
from django.contrib.auth.password_validation import validate_password
|
|
from django.core import signing
|
|
from rest_framework import serializers
|
|
|
|
from apps.library.services import MEDIA_FILE_SALT, VIDEO_EXTENSIONS
|
|
|
|
from .models import User
|
|
|
|
|
|
def signed_media_url(request, item):
|
|
"""A signed media URL an <img> tag can load for an authenticated user."""
|
|
if item is None:
|
|
return None
|
|
user = getattr(request, "user", None)
|
|
if user is None or not getattr(user, "is_authenticated", False):
|
|
return None
|
|
location = item.locations.first()
|
|
is_video = (
|
|
location is not None
|
|
and Path(location.rel_path).suffix.lower() in VIDEO_EXTENSIONS
|
|
)
|
|
action = "thumbnail" if is_video else "raw"
|
|
signature = signing.dumps(
|
|
{"item": item.id, "user": user.id, "action": action},
|
|
salt=MEDIA_FILE_SALT,
|
|
)
|
|
return f"/api/files/J-{item.id}/{action}/?sig={signature}"
|
|
|
|
|
|
class UserSerializer(serializers.ModelSerializer):
|
|
e621_configured = serializers.BooleanField(read_only=True)
|
|
can_upload = serializers.BooleanField(read_only=True)
|
|
avatar_j_id = serializers.SerializerMethodField()
|
|
avatar_url = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = User
|
|
fields = [
|
|
"id",
|
|
"username",
|
|
"email",
|
|
"is_staff",
|
|
"is_superuser",
|
|
"date_joined",
|
|
"role",
|
|
"can_upload",
|
|
"avatar_j_id",
|
|
"avatar_url",
|
|
"e621_username",
|
|
"e621_configured",
|
|
]
|
|
read_only_fields = fields
|
|
|
|
def get_avatar_j_id(self, obj):
|
|
return f"J-{obj.avatar_id}" if obj.avatar_id else None
|
|
|
|
def get_avatar_url(self, obj):
|
|
return signed_media_url(self.context.get("request"), obj.avatar)
|
|
|
|
|
|
class UserListSerializer(serializers.ModelSerializer):
|
|
avatar_j_id = serializers.SerializerMethodField()
|
|
avatar_url = serializers.SerializerMethodField()
|
|
uploads_count = serializers.IntegerField(read_only=True)
|
|
|
|
class Meta:
|
|
model = User
|
|
fields = [
|
|
"id",
|
|
"username",
|
|
"email",
|
|
"role",
|
|
"is_staff",
|
|
"is_superuser",
|
|
"date_joined",
|
|
"avatar_j_id",
|
|
"avatar_url",
|
|
"uploads_count",
|
|
]
|
|
read_only_fields = fields
|
|
|
|
def get_avatar_j_id(self, obj):
|
|
return f"J-{obj.avatar_id}" if obj.avatar_id else None
|
|
|
|
def get_avatar_url(self, obj):
|
|
return signed_media_url(self.context.get("request"), obj.avatar)
|
|
|
|
|
|
class UserUpdateSerializer(serializers.Serializer):
|
|
avatar_j_id = serializers.CharField(
|
|
required=False, allow_blank=True, allow_null=True
|
|
)
|
|
role = serializers.ChoiceField(choices=User.ROLE_CHOICES, required=False)
|
|
|
|
|
|
class RegisterSerializer(serializers.ModelSerializer):
|
|
password = serializers.CharField(write_only=True, validators=[validate_password])
|
|
|
|
class Meta:
|
|
model = User
|
|
fields = ["username", "email", "password"]
|
|
|
|
def create(self, validated_data):
|
|
return User.objects.create_user(**validated_data)
|
|
|
|
|
|
class E621CredentialsSerializer(serializers.Serializer):
|
|
username = serializers.CharField(max_length=100, allow_blank=True)
|
|
api_key = serializers.CharField(
|
|
max_length=100, allow_blank=True, trim_whitespace=True
|
|
)
|
|
base_url = serializers.URLField(
|
|
max_length=200, required=False, allow_blank=True
|
|
)
|