Phase 3: J-IDs, ownership, roles, guest safety, adaptive detail, download
Backend: - User.role (user/uploader/staff) with can_upload; uploads and downloads gated to uploader+; owners and staff can edit their items - MediaItem.uploaded_by plus J-<id> identity (serializer, admin, scan_files --user, first superuser as default owner) - API resolves J-<id>, bare numeric ids and MD5s; neighbors and lookup return j_ids - Guest safety: mirror e621's anonymous default blacklist into Redis (parses comments, negations and wildcards), flag hidden_from_guests and filter lists, details and lookups for anonymous users - POST /api/online/downloads/ writes an e621 file into the watched folder and indexes it for the uploader - MariaDB + Redis via docker compose (host ports 3307/6380), PyMySQL driver shim, Redis cache replacing the file cache; SQLite data dumped and loaded into MariaDB Frontend: - Single /detail/:itemId route with an adaptive shell: J-<id> renders the library item, bare numbers render the e621 post - Legacy /view/<md5> and /online/view/<id> redirect to canonical URLs - Cards expose J-IDs; library custom-data editor is read-only for non-owners - Role gating: Upload hidden/blocked for regular users, account shows the role, guest hint on the library
This commit is contained in:
@@ -3,3 +3,18 @@ DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
# Absolute path to your media folder, or relative to the backend/ folder.
|
||||
WATCHED_FOLDER=media/library
|
||||
|
||||
# MariaDB (docker compose at the repo root)
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3307
|
||||
DB_NAME=j621
|
||||
DB_USER=j621
|
||||
DB_PASSWORD=j621
|
||||
|
||||
# Redis (docker compose at the repo root)
|
||||
REDIS_URL=redis://127.0.0.1:6380/1
|
||||
|
||||
# Guest visibility: e621's anonymous default blacklist is mirrored into
|
||||
# Redis by `manage.py refresh_guest_blacklist`.
|
||||
# GUEST_BLACKLIST_FALLBACK=young,cub,shota,loli,child,underage
|
||||
# GUEST_BLACKLIST_TTL=3600
|
||||
|
||||
@@ -6,4 +6,18 @@ from .models import User
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
pass
|
||||
list_display = ("username", "email", "role", "is_staff", "is_superuser")
|
||||
list_filter = UserAdmin.list_filter + ("role",)
|
||||
fieldsets = UserAdmin.fieldsets + (
|
||||
(
|
||||
"J621",
|
||||
{
|
||||
"fields": (
|
||||
"role",
|
||||
"e621_username",
|
||||
"e621_api_key",
|
||||
"e621_base_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-17 15:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_user_e621_api_key_user_e621_base_url_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('user', 'User'), ('uploader', 'Uploader'), ('staff', 'Staff')], default='user', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -3,8 +3,18 @@ from django.db import models
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""Project user with optional e621 API credentials."""
|
||||
"""Project user with optional e621 API credentials and an app role."""
|
||||
|
||||
ROLE_USER = "user"
|
||||
ROLE_UPLOADER = "uploader"
|
||||
ROLE_STAFF = "staff"
|
||||
ROLE_CHOICES = [
|
||||
(ROLE_USER, "User"),
|
||||
(ROLE_UPLOADER, "Uploader"),
|
||||
(ROLE_STAFF, "Staff"),
|
||||
]
|
||||
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default=ROLE_USER)
|
||||
e621_username = models.CharField(max_length=100, blank=True, default="")
|
||||
e621_api_key = models.CharField(max_length=100, blank=True, default="")
|
||||
e621_base_url = models.CharField(max_length=200, default="https://e621.net")
|
||||
@@ -12,3 +22,7 @@ class User(AbstractUser):
|
||||
@property
|
||||
def e621_configured(self):
|
||||
return bool(self.e621_username and self.e621_api_key)
|
||||
|
||||
@property
|
||||
def can_upload(self):
|
||||
return self.is_superuser or self.role in {self.ROLE_UPLOADER, self.ROLE_STAFF}
|
||||
|
||||
@@ -6,6 +6,7 @@ from .models import User
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
e621_configured = serializers.BooleanField(read_only=True)
|
||||
can_upload = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@@ -15,6 +16,8 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"email",
|
||||
"is_staff",
|
||||
"date_joined",
|
||||
"role",
|
||||
"can_upload",
|
||||
"e621_username",
|
||||
"e621_configured",
|
||||
]
|
||||
|
||||
@@ -11,11 +11,16 @@ class MediaLocationInline(admin.TabularInline):
|
||||
|
||||
@admin.register(MediaItem)
|
||||
class MediaItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("md5", "size", "rating", "created_at")
|
||||
list_filter = ("rating",)
|
||||
list_display = ("__str__", "j_id", "size", "rating", "uploaded_by", "hidden_from_guests", "created_at")
|
||||
list_filter = ("rating", "hidden_from_guests")
|
||||
search_fields = ("md5",)
|
||||
readonly_fields = ("hidden_from_guests",)
|
||||
inlines = [MediaLocationInline]
|
||||
|
||||
@admin.display(description="J-ID")
|
||||
def j_id(self, obj):
|
||||
return f"J-{obj.id}"
|
||||
|
||||
|
||||
@admin.register(MediaLocation)
|
||||
class MediaLocationAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Guest visibility: mirror e621's anonymous default blacklist.
|
||||
|
||||
Items whose tags intersect the blacklist are hidden from anonymous visitors.
|
||||
e621's blacklist entries can be comments (``# ...``), compound expressions
|
||||
(``young -rating:s``) or wildcards (``*young*``); we extract the positive
|
||||
plain patterns and match them case-insensitively (negations are ignored, so
|
||||
matching stays conservative).
|
||||
|
||||
The raw list is cached in Redis; ``manage.py refresh_guest_blacklist``
|
||||
refreshes it from e621 and recomputes the item flags.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from fnmatch import fnmatch
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_KEY = "j621.guest_blacklist"
|
||||
|
||||
|
||||
def parse_blacklist_patterns(lines):
|
||||
"""Extract lowercase tag patterns from e621 blacklist lines."""
|
||||
patterns = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
for token in stripped.split():
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
patterns.append(token.lower())
|
||||
return patterns
|
||||
|
||||
|
||||
def cached_guest_blacklist():
|
||||
"""Raw mirrored blacklist lines. Never hits the network."""
|
||||
stored = cache.get(CACHE_KEY)
|
||||
if stored is not None:
|
||||
return list(stored)
|
||||
return list(settings.GUEST_BLACKLIST_FALLBACK)
|
||||
|
||||
|
||||
def guest_blacklist_patterns():
|
||||
return parse_blacklist_patterns(cached_guest_blacklist())
|
||||
|
||||
|
||||
def item_hidden_from_guests(tags):
|
||||
if not tags:
|
||||
return False
|
||||
patterns = guest_blacklist_patterns()
|
||||
if not patterns:
|
||||
return False
|
||||
for tag in tags:
|
||||
candidate = str(tag).strip().lower()
|
||||
if not candidate:
|
||||
continue
|
||||
if any(fnmatch(candidate, pattern) for pattern in patterns):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def fetch_e621_default_blacklist():
|
||||
"""e621's anonymous default blacklist, or None when unreachable."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{settings.E621_BASE_URL}/users/me.json",
|
||||
headers={"User-Agent": settings.USER_AGENT},
|
||||
timeout=15,
|
||||
)
|
||||
response.raise_for_status()
|
||||
raw = response.json().get("blacklisted_tags", "")
|
||||
return [line.strip() for line in raw.split("\n") if line.strip()]
|
||||
except (requests.RequestException, ValueError) as exc:
|
||||
logger.warning("Could not fetch e621 default blacklist: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def refresh_guest_blacklist():
|
||||
"""Refresh the mirrored blacklist and recompute guest visibility flags."""
|
||||
from .models import MediaItem
|
||||
|
||||
lines = fetch_e621_default_blacklist()
|
||||
used_fallback = lines is None
|
||||
if used_fallback:
|
||||
lines = list(settings.GUEST_BLACKLIST_FALLBACK)
|
||||
cache.set(CACHE_KEY, lines, settings.GUEST_BLACKLIST_TTL)
|
||||
|
||||
patterns = parse_blacklist_patterns(lines)
|
||||
updates = []
|
||||
for item in MediaItem.objects.only("id", "tags", "hidden_from_guests").iterator():
|
||||
hidden = False
|
||||
for tag in item.tags or []:
|
||||
candidate = str(tag).strip().lower()
|
||||
if candidate and any(
|
||||
fnmatch(candidate, pattern) for pattern in patterns
|
||||
):
|
||||
hidden = True
|
||||
break
|
||||
if item.hidden_from_guests != hidden:
|
||||
item.hidden_from_guests = hidden
|
||||
updates.append(item)
|
||||
if updates:
|
||||
MediaItem.objects.bulk_update(updates, ["hidden_from_guests"])
|
||||
|
||||
return {
|
||||
"blacklist": lines,
|
||||
"patterns": patterns,
|
||||
"updated": len(updates),
|
||||
"fallback": used_fallback,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.library.guest_filter import refresh_guest_blacklist
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Mirror e621's anonymous default blacklist and recompute which "
|
||||
"library items are hidden from guests."
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
result = refresh_guest_blacklist()
|
||||
source = "fallback" if result["fallback"] else "e621"
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Guest blacklist from {source}: {len(result['blacklist'])} tag(s); "
|
||||
f"{result['updated']} item(s) updated."
|
||||
)
|
||||
)
|
||||
@@ -2,11 +2,14 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.library.models import MediaLocation
|
||||
from apps.library.services import ALLOWED_EXTENSIONS, index_file
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Scan the watched folder and index media files into the library."
|
||||
@@ -17,6 +20,10 @@ class Command(BaseCommand):
|
||||
action="store_true",
|
||||
help="Remove locations whose files no longer exist on disk.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user",
|
||||
help="Username that owns newly indexed files (default: first superuser).",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
folder = Path(settings.WATCHED_FOLDER)
|
||||
@@ -26,6 +33,26 @@ class Command(BaseCommand):
|
||||
)
|
||||
return
|
||||
|
||||
owner = None
|
||||
if options["user"]:
|
||||
owner = User.objects.filter(username=options["user"]).first()
|
||||
if owner is None:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(f"User not found: {options['user']}")
|
||||
)
|
||||
return
|
||||
else:
|
||||
owner = User.objects.filter(is_superuser=True).order_by("id").first()
|
||||
if owner is None:
|
||||
self.stderr.write(
|
||||
self.style.WARNING(
|
||||
"No superuser found; scanned files will have no owner. "
|
||||
"Use --user <name> to assign one."
|
||||
)
|
||||
)
|
||||
if owner is not None:
|
||||
self.stdout.write(f"New files will be owned by {owner.username}.")
|
||||
|
||||
self.stdout.write(f"Scanning {folder} ...")
|
||||
seen = set()
|
||||
new_items = 0
|
||||
@@ -40,11 +67,14 @@ class Command(BaseCommand):
|
||||
continue
|
||||
path = Path(root) / name
|
||||
try:
|
||||
_, created_item, created_location = index_file(path, folder)
|
||||
item, created_item, created_location = index_file(path, folder)
|
||||
except OSError as exc:
|
||||
skipped += 1
|
||||
self.stderr.write(f"Skipped {path}: {exc}")
|
||||
continue
|
||||
if owner is not None and item.uploaded_by_id is None:
|
||||
item.uploaded_by = owner
|
||||
item.save(update_fields=["uploaded_by", "updated_at"])
|
||||
seen.add(str(path))
|
||||
new_items += int(created_item)
|
||||
if created_location:
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-17 15:01
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('library', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='mediaitem',
|
||||
name='hidden_from_guests',
|
||||
field=models.BooleanField(db_index=True, default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='mediaitem',
|
||||
name='uploaded_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploads', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
@@ -10,6 +11,15 @@ class MediaItem(models.Model):
|
||||
rating = models.CharField(max_length=1, blank=True, default="")
|
||||
tags = models.JSONField(default=list, blank=True)
|
||||
notes = models.TextField(blank=True, default="")
|
||||
uploaded_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="uploads",
|
||||
)
|
||||
# Guests never see items whose tags hit the mirrored default blacklist.
|
||||
hidden_from_guests = models.BooleanField(default=False, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -17,7 +27,13 @@ class MediaItem(models.Model):
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.md5
|
||||
return f"J-{self.pk} ({self.md5})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
from .guest_filter import item_hidden_from_guests
|
||||
|
||||
self.hidden_from_guests = item_hidden_from_guests(self.tags)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class MediaLocation(models.Model):
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from rest_framework import permissions
|
||||
|
||||
|
||||
class CanUpload(permissions.BasePermission):
|
||||
"""Only uploader/staff/admin accounts may add items to the library."""
|
||||
|
||||
message = "Your account is not allowed to upload."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
user = request.user
|
||||
return bool(user and user.is_authenticated and user.can_upload)
|
||||
|
||||
|
||||
class IsUploaderOrStaffOrReadOnly(permissions.BasePermission):
|
||||
"""Owners and staff can change an item; everyone can read."""
|
||||
|
||||
message = "Only the uploader or staff can change this item."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
user = request.user
|
||||
if not (user and user.is_authenticated):
|
||||
return False
|
||||
if user.is_superuser or user.role == user.ROLE_STAFF:
|
||||
return True
|
||||
return obj.uploaded_by_id == user.id
|
||||
@@ -18,13 +18,18 @@ class MediaItemSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
rating = serializers.ChoiceField(choices=["", "s", "q", "e"], required=False)
|
||||
locations = MediaLocationSerializer(many=True, read_only=True)
|
||||
j_id = serializers.SerializerMethodField()
|
||||
filename = serializers.SerializerMethodField()
|
||||
extension = serializers.SerializerMethodField()
|
||||
kind = serializers.SerializerMethodField()
|
||||
uploaded_by = serializers.SerializerMethodField()
|
||||
uploaded_by_id = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = MediaItem
|
||||
fields = [
|
||||
"id",
|
||||
"j_id",
|
||||
"md5",
|
||||
"size",
|
||||
"rating",
|
||||
@@ -33,21 +38,33 @@ class MediaItemSerializer(serializers.ModelSerializer):
|
||||
"filename",
|
||||
"extension",
|
||||
"kind",
|
||||
"uploaded_by",
|
||||
"uploaded_by_id",
|
||||
"locations",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"j_id",
|
||||
"md5",
|
||||
"size",
|
||||
"filename",
|
||||
"extension",
|
||||
"kind",
|
||||
"uploaded_by",
|
||||
"uploaded_by_id",
|
||||
"locations",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def get_j_id(self, obj):
|
||||
return f"J-{obj.id}"
|
||||
|
||||
def get_uploaded_by(self, obj):
|
||||
return obj.uploaded_by.username if obj.uploaded_by else None
|
||||
|
||||
def _first_location(self, obj):
|
||||
locations = list(obj.locations.all())
|
||||
return locations[0] if locations else None
|
||||
|
||||
@@ -160,6 +160,19 @@ def serve_file(request, path, download=False):
|
||||
return response
|
||||
|
||||
|
||||
def download_file(url, destination):
|
||||
"""Stream a remote file into destination (used by Download to Library)."""
|
||||
import requests
|
||||
|
||||
headers = {"User-Agent": settings.USER_AGENT}
|
||||
with requests.get(url, headers=headers, stream=True, timeout=120) as response:
|
||||
response.raise_for_status()
|
||||
with open(destination, "wb") as handle:
|
||||
for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
|
||||
if chunk:
|
||||
handle.write(chunk)
|
||||
|
||||
|
||||
def generate_video_thumbnail(md5, path):
|
||||
"""Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs."""
|
||||
if not shutil.which("ffmpeg"):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import MediaItemViewSet, UploadView
|
||||
from .views import DownloadToLibraryView, MediaItemViewSet, UploadView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("files", MediaItemViewSet, basename="file")
|
||||
@@ -9,4 +9,9 @@ router.register("files", MediaItemViewSet, basename="file")
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
path("uploads/", UploadView.as_view(), name="upload"),
|
||||
path(
|
||||
"online/downloads/",
|
||||
DownloadToLibraryView.as_view(),
|
||||
name="download_to_library",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Min
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from . import services
|
||||
from .models import MediaItem
|
||||
from .permissions import CanUpload, IsUploaderOrStaffOrReadOnly
|
||||
from .serializers import MediaItemSerializer
|
||||
|
||||
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
|
||||
MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
|
||||
|
||||
|
||||
class MediaItemViewSet(
|
||||
@@ -23,13 +29,15 @@ class MediaItemViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
serializer_class = MediaItemSerializer
|
||||
lookup_field = "md5"
|
||||
permission_classes = [IsAuthenticatedOrReadOnly, IsUploaderOrStaffOrReadOnly]
|
||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = MediaItem.objects.prefetch_related("locations").annotate(
|
||||
name=Min("locations__rel_path")
|
||||
)
|
||||
if not self.request.user.is_authenticated:
|
||||
queryset = queryset.filter(hidden_from_guests=False)
|
||||
search = self.request.query_params.get("search", "").strip()
|
||||
if search:
|
||||
queryset = queryset.filter(locations__rel_path__icontains=search)
|
||||
@@ -46,8 +54,22 @@ class MediaItemViewSet(
|
||||
)
|
||||
return queryset.distinct()
|
||||
|
||||
def get_object(self):
|
||||
"""Resolve J-<id>, a bare numeric id, or an MD5 fingerprint."""
|
||||
value = str(self.kwargs.get("pk", "")).strip()
|
||||
queryset = self.get_queryset()
|
||||
if MD5_RE.fullmatch(value):
|
||||
obj = get_object_or_404(queryset, md5=value.lower())
|
||||
else:
|
||||
numeric = value[2:] if value.upper().startswith("J-") else value
|
||||
if not numeric.isdigit():
|
||||
raise Http404
|
||||
obj = get_object_or_404(queryset, pk=int(numeric))
|
||||
self.check_object_permissions(self.request, obj)
|
||||
return obj
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def raw(self, request, md5=None):
|
||||
def raw(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
location = item.locations.first()
|
||||
if location is None:
|
||||
@@ -60,7 +82,7 @@ class MediaItemViewSet(
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def thumbnail(self, request, md5=None):
|
||||
def thumbnail(self, request, pk=None):
|
||||
item = self.get_object()
|
||||
location = item.locations.first()
|
||||
if location is None:
|
||||
@@ -81,7 +103,7 @@ class MediaItemViewSet(
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
|
||||
def lookup(self, request):
|
||||
"""Return which of the given MD5s already exist in the library."""
|
||||
"""Report which of the given MD5s exist in the library."""
|
||||
md5s = request.data.get("md5s")
|
||||
if not isinstance(md5s, list):
|
||||
return Response(
|
||||
@@ -89,11 +111,19 @@ class MediaItemViewSet(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
wanted = {str(value).strip().lower()[:32] for value in md5s if value}
|
||||
found = MediaItem.objects.filter(md5__in=wanted).values_list("md5", flat=True)
|
||||
return Response({"found": sorted(found)})
|
||||
queryset = MediaItem.objects.filter(md5__in=wanted)
|
||||
if not request.user.is_authenticated:
|
||||
queryset = queryset.filter(hidden_from_guests=False)
|
||||
rows = list(queryset.values_list("md5", "id"))
|
||||
return Response(
|
||||
{
|
||||
"found": sorted(md5 for md5, _ in rows),
|
||||
"j_ids": {md5: f"J-{item_id}" for md5, item_id in rows},
|
||||
}
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def neighbors(self, request, md5=None):
|
||||
def neighbors(self, request, pk=None):
|
||||
"""Previous/next items in the ordered list, for keyboard navigation."""
|
||||
item = self.get_object()
|
||||
ordering = request.query_params.get("ordering", "-created_at").strip()
|
||||
@@ -120,6 +150,7 @@ class MediaItemViewSet(
|
||||
def brief(obj):
|
||||
location = obj.locations.first()
|
||||
return {
|
||||
"j_id": f"J-{obj.id}",
|
||||
"md5": obj.md5,
|
||||
"filename": Path(location.rel_path).name if location else obj.md5,
|
||||
}
|
||||
@@ -135,7 +166,7 @@ class MediaItemViewSet(
|
||||
|
||||
|
||||
class UploadView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [CanUpload]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
@@ -150,7 +181,7 @@ class UploadView(APIView):
|
||||
for chunk in upload.chunks():
|
||||
output.write(chunk)
|
||||
try:
|
||||
item, _, _ = services.index_file(destination, folder)
|
||||
item, created_item, _ = services.index_file(destination, folder)
|
||||
except Exception as exc: # noqa: BLE001 - report indexing failures
|
||||
destination.unlink(missing_ok=True)
|
||||
return Response(
|
||||
@@ -159,6 +190,9 @@ class UploadView(APIView):
|
||||
)
|
||||
|
||||
update_fields = []
|
||||
if created_item and item.uploaded_by_id is None:
|
||||
item.uploaded_by = request.user
|
||||
update_fields.append("uploaded_by")
|
||||
rating = request.data.get("rating") or ""
|
||||
if rating in {"s", "q", "e"} and item.rating != rating:
|
||||
item.rating = rating
|
||||
@@ -178,3 +212,42 @@ class UploadView(APIView):
|
||||
MediaItemSerializer(item, context={"request": request}).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class DownloadToLibraryView(APIView):
|
||||
"""Fetch an e621 file into the watched folder and index it."""
|
||||
|
||||
permission_classes = [CanUpload]
|
||||
|
||||
def post(self, request):
|
||||
url = str(request.data.get("url") or "").strip()
|
||||
post_id = request.data.get("post_id")
|
||||
filename = str(request.data.get("filename") or "").strip()
|
||||
if not url.startswith(("http://", "https://")):
|
||||
return Response(
|
||||
{"detail": "A valid file URL is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
folder = Path(settings.WATCHED_FOLDER)
|
||||
name = (
|
||||
filename
|
||||
or Path(urlparse(url).path).name
|
||||
or f"post-{post_id or 'download'}"
|
||||
)
|
||||
destination = services.unique_destination(folder, name)
|
||||
try:
|
||||
services.download_file(url, destination)
|
||||
item, _, _ = services.index_file(destination, folder)
|
||||
except Exception as exc: # noqa: BLE001 - report download failures
|
||||
destination.unlink(missing_ok=True)
|
||||
return Response(
|
||||
{"detail": f"Download failed: {exc}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if item.uploaded_by_id is None:
|
||||
item.uploaded_by = request.user
|
||||
item.save(update_fields=["uploaded_by", "updated_at"])
|
||||
return Response(
|
||||
MediaItemSerializer(item, context={"request": request}).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Django project package.
|
||||
|
||||
Use PyMySQL as the MySQL/MariaDB driver. Django checks the driver library
|
||||
version, so report a compatible one before installing the shim (same trick
|
||||
the original J621 used).
|
||||
"""
|
||||
|
||||
import pymysql
|
||||
|
||||
pymysql.version_info = (2, 2, 1, "final", 0)
|
||||
pymysql.__version__ = "2.2.1"
|
||||
|
||||
pymysql.install_as_MySQLdb()
|
||||
|
||||
@@ -85,12 +85,17 @@ TEMPLATES = [
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
# Database
|
||||
# Database (MariaDB, run via docker compose at the repo root)
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
"ENGINE": "django.db.backends.mysql",
|
||||
"NAME": os.getenv("DB_NAME", "j621"),
|
||||
"USER": os.getenv("DB_USER", "j621"),
|
||||
"PASSWORD": os.getenv("DB_PASSWORD", "j621"),
|
||||
"HOST": os.getenv("DB_HOST", "127.0.0.1"),
|
||||
"PORT": os.getenv("DB_PORT", "3307"),
|
||||
"OPTIONS": {"charset": "utf8mb4"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +140,32 @@ if not WATCHED_FOLDER.is_absolute():
|
||||
WATCHED_FOLDER = BASE_DIR / WATCHED_FOLDER
|
||||
WATCHED_FOLDER = str(WATCHED_FOLDER)
|
||||
|
||||
# e621 integration
|
||||
|
||||
E621_BASE_URL = os.getenv("E621_BASE_URL", "https://e621.net").rstrip("/")
|
||||
USER_AGENT = os.getenv("USER_AGENT", "J621/0.1 (by J621 on e621)")
|
||||
|
||||
# Guest visibility: e621's anonymous default blacklist is mirrored into the
|
||||
# cache by `manage.py refresh_guest_blacklist`. This fallback is used until
|
||||
# that command runs or when e621 is unreachable.
|
||||
GUEST_BLACKLIST_FALLBACK = [
|
||||
tag.strip()
|
||||
for tag in os.getenv(
|
||||
"GUEST_BLACKLIST_FALLBACK", "young,cub,shota,loli,child,underage"
|
||||
).split(",")
|
||||
if tag.strip()
|
||||
]
|
||||
GUEST_BLACKLIST_TTL = int(os.getenv("GUEST_BLACKLIST_TTL", "3600"))
|
||||
|
||||
# Redis cache (run via docker compose at the repo root), shared by web
|
||||
# workers and management commands (e.g. the mirrored guest blacklist).
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
||||
"LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6380/1"),
|
||||
}
|
||||
}
|
||||
|
||||
# Django REST Framework
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
|
||||
@@ -3,3 +3,6 @@ djangorestframework>=3.17
|
||||
django-filter>=25.1
|
||||
Pillow>=11.0
|
||||
python-dotenv>=1.0
|
||||
requests>=2.32
|
||||
PyMySQL>=1.1
|
||||
redis>=5.0
|
||||
|
||||
Reference in New Issue
Block a user