M0: scaffold SPA + API monorepo
Backend (Django 6.1 + DRF): - Token auth with a custom User model (register/login/logout/me) - Library models (MediaItem, MediaLocation) and REST endpoints - File list/detail with search, rating filter, sorting, pagination - Multipart upload with optional rating/tags/notes - Range-aware media serving (video seeking) and ffmpeg thumbnails - scan_files management command for the watched folder Frontend (React 19 + Vite + TypeScript): - Catppuccin Mocha design tokens from the design docs - App shell, token persistence, protected routes - Library grid with filters, file detail with custom data editor - Upload page with per-file progress via XHR - Dev proxy to the Django API
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
SECRET_KEY=change-me-to-a-long-random-string
|
||||
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
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
|
||||
from .models import User
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
pass
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
name = "apps.accounts"
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-17 12:49
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
"""Project user. Extended later with avatars and e621 credentials."""
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import User
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "email", "is_staff", "date_joined"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
from rest_framework.authtoken.views import obtain_auth_token
|
||||
|
||||
from .views import LogoutView, MeView, RegisterView
|
||||
|
||||
urlpatterns = [
|
||||
path("register/", RegisterView.as_view(), name="register"),
|
||||
path("token/", obtain_auth_token, name="login"),
|
||||
path("logout/", LogoutView.as_view(), name="logout"),
|
||||
path("me/", MeView.as_view(), name="me"),
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .serializers import RegisterSerializer, UserSerializer
|
||||
|
||||
|
||||
class RegisterView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = RegisterSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.save()
|
||||
token, _ = Token.objects.get_or_create(user=user)
|
||||
return Response(
|
||||
{"token": token.key, "user": UserSerializer(user).data},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
return Response(UserSerializer(request.user).data)
|
||||
|
||||
|
||||
class LogoutView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
if request.auth:
|
||||
request.auth.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import MediaItem, MediaLocation
|
||||
|
||||
|
||||
class MediaLocationInline(admin.TabularInline):
|
||||
model = MediaLocation
|
||||
extra = 0
|
||||
readonly_fields = ("path", "rel_path", "mtime")
|
||||
|
||||
|
||||
@admin.register(MediaItem)
|
||||
class MediaItemAdmin(admin.ModelAdmin):
|
||||
list_display = ("md5", "size", "rating", "created_at")
|
||||
list_filter = ("rating",)
|
||||
search_fields = ("md5",)
|
||||
inlines = [MediaLocationInline]
|
||||
|
||||
|
||||
@admin.register(MediaLocation)
|
||||
class MediaLocationAdmin(admin.ModelAdmin):
|
||||
list_display = ("rel_path", "item", "mtime")
|
||||
search_fields = ("rel_path", "path")
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LibraryConfig(AppConfig):
|
||||
name = "apps.library"
|
||||
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.library.models import MediaLocation
|
||||
from apps.library.services import ALLOWED_EXTENSIONS, index_file
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Scan the watched folder and index media files into the library."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--prune",
|
||||
action="store_true",
|
||||
help="Remove locations whose files no longer exist on disk.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
folder = Path(settings.WATCHED_FOLDER)
|
||||
if not folder.is_dir():
|
||||
self.stderr.write(
|
||||
self.style.ERROR(f"Watched folder does not exist: {folder}")
|
||||
)
|
||||
return
|
||||
|
||||
self.stdout.write(f"Scanning {folder} ...")
|
||||
seen = set()
|
||||
new_items = 0
|
||||
new_locations = 0
|
||||
updated_locations = 0
|
||||
skipped = 0
|
||||
|
||||
for root, dirs, files in os.walk(folder):
|
||||
dirs.sort()
|
||||
for name in sorted(files):
|
||||
if Path(name).suffix.lower() not in ALLOWED_EXTENSIONS:
|
||||
continue
|
||||
path = Path(root) / name
|
||||
try:
|
||||
_, created_item, created_location = index_file(path, folder)
|
||||
except OSError as exc:
|
||||
skipped += 1
|
||||
self.stderr.write(f"Skipped {path}: {exc}")
|
||||
continue
|
||||
seen.add(str(path))
|
||||
new_items += int(created_item)
|
||||
if created_location:
|
||||
new_locations += 1
|
||||
else:
|
||||
updated_locations += 1
|
||||
processed = len(seen)
|
||||
if processed % 200 == 0:
|
||||
self.stdout.write(f"Processed {processed} files ...")
|
||||
|
||||
if options["prune"]:
|
||||
stale = MediaLocation.objects.exclude(path__in=seen)
|
||||
pruned = stale.count()
|
||||
stale.delete()
|
||||
self.stdout.write(f"Pruned {pruned} missing location(s).")
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Done. {len(seen)} file(s) on disk, "
|
||||
f"{new_items} new item(s), "
|
||||
f"{new_locations} new location(s), "
|
||||
f"{updated_locations} refreshed, "
|
||||
f"{skipped} skipped."
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-17 12:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MediaItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('md5', models.CharField(max_length=32, unique=True)),
|
||||
('size', models.BigIntegerField(default=0)),
|
||||
('rating', models.CharField(blank=True, default='', max_length=1)),
|
||||
('tags', models.JSONField(blank=True, default=list)),
|
||||
('notes', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MediaLocation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('path', models.CharField(max_length=1024)),
|
||||
('rel_path', models.CharField(max_length=1024)),
|
||||
('mtime', models.FloatField(default=0)),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='library.mediaitem')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['rel_path'],
|
||||
'unique_together': {('item', 'path')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class MediaItem(models.Model):
|
||||
"""A logical media file, identified by its MD5 fingerprint."""
|
||||
|
||||
md5 = models.CharField(max_length=32, unique=True)
|
||||
size = models.BigIntegerField(default=0)
|
||||
# Custom rating: "s" (safe), "q" (questionable), "e" (explicit) or "" (unrated).
|
||||
rating = models.CharField(max_length=1, blank=True, default="")
|
||||
tags = models.JSONField(default=list, blank=True)
|
||||
notes = models.TextField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return self.md5
|
||||
|
||||
|
||||
class MediaLocation(models.Model):
|
||||
"""A physical copy of a media item inside the watched folder."""
|
||||
|
||||
item = models.ForeignKey(
|
||||
MediaItem, related_name="locations", on_delete=models.CASCADE
|
||||
)
|
||||
path = models.CharField(max_length=1024)
|
||||
rel_path = models.CharField(max_length=1024)
|
||||
mtime = models.FloatField(default=0)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("item", "path")
|
||||
ordering = ["rel_path"]
|
||||
|
||||
def __str__(self):
|
||||
return self.rel_path
|
||||
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import MediaItem, MediaLocation
|
||||
from .services import VIDEO_EXTENSIONS
|
||||
|
||||
|
||||
class MediaLocationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MediaLocation
|
||||
fields = ["id", "path", "rel_path", "mtime"]
|
||||
|
||||
|
||||
class MediaItemSerializer(serializers.ModelSerializer):
|
||||
tags = serializers.ListField(
|
||||
child=serializers.CharField(max_length=100), required=False
|
||||
)
|
||||
rating = serializers.ChoiceField(choices=["", "s", "q", "e"], required=False)
|
||||
locations = MediaLocationSerializer(many=True, read_only=True)
|
||||
filename = serializers.SerializerMethodField()
|
||||
extension = serializers.SerializerMethodField()
|
||||
kind = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = MediaItem
|
||||
fields = [
|
||||
"md5",
|
||||
"size",
|
||||
"rating",
|
||||
"tags",
|
||||
"notes",
|
||||
"filename",
|
||||
"extension",
|
||||
"kind",
|
||||
"locations",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"md5",
|
||||
"size",
|
||||
"filename",
|
||||
"extension",
|
||||
"kind",
|
||||
"locations",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
def _first_location(self, obj):
|
||||
locations = list(obj.locations.all())
|
||||
return locations[0] if locations else None
|
||||
|
||||
def get_filename(self, obj):
|
||||
location = self._first_location(obj)
|
||||
return os.path.basename(location.rel_path) if location else obj.md5
|
||||
|
||||
def get_extension(self, obj):
|
||||
return os.path.splitext(self.get_filename(obj))[1].lstrip(".").lower()
|
||||
|
||||
def get_kind(self, obj):
|
||||
return "video" if "." + self.get_extension(obj) in VIDEO_EXTENSIONS else "image"
|
||||
@@ -0,0 +1,191 @@
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse, Http404, HttpResponse
|
||||
from django.utils.text import get_valid_filename
|
||||
|
||||
from .models import MediaItem, MediaLocation
|
||||
|
||||
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".apng", ".mp4", ".webm"}
|
||||
VIDEO_EXTENSIONS = {".mp4", ".webm"}
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
|
||||
|
||||
|
||||
def compute_md5(path):
|
||||
digest = hashlib.md5()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(CHUNK_SIZE), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def index_file(path, folder):
|
||||
"""Index one file into MediaItem/MediaLocation.
|
||||
|
||||
Returns (item, created_item, created_location).
|
||||
"""
|
||||
path = Path(path)
|
||||
folder = Path(folder)
|
||||
stat = path.stat()
|
||||
md5 = compute_md5(path)
|
||||
item, created_item = MediaItem.objects.get_or_create(
|
||||
md5=md5, defaults={"size": stat.st_size}
|
||||
)
|
||||
if not created_item and item.size != stat.st_size:
|
||||
item.size = stat.st_size
|
||||
item.save(update_fields=["size", "updated_at"])
|
||||
location, created_location = MediaLocation.objects.update_or_create(
|
||||
item=item,
|
||||
path=str(path),
|
||||
defaults={
|
||||
"rel_path": str(path.relative_to(folder)),
|
||||
"mtime": stat.st_mtime,
|
||||
},
|
||||
)
|
||||
return item, created_item, created_location
|
||||
|
||||
|
||||
def parse_tags(raw):
|
||||
"""Normalize a comma-separated string or JSON list into a list of tags."""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, (list, tuple)):
|
||||
values = raw
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.startswith("["):
|
||||
try:
|
||||
values = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
values = text.split(",")
|
||||
else:
|
||||
values = text.split(",")
|
||||
cleaned = []
|
||||
for value in values:
|
||||
tag = str(value).strip()
|
||||
if tag and tag not in cleaned:
|
||||
cleaned.append(tag[:100])
|
||||
return cleaned
|
||||
|
||||
|
||||
def unique_destination(folder, filename):
|
||||
"""Return a non-existing path inside folder for the given filename."""
|
||||
folder = Path(folder)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
filename = get_valid_filename(Path(filename).name) or "upload"
|
||||
candidate = folder / filename
|
||||
stem, suffix = candidate.stem, candidate.suffix
|
||||
counter = 1
|
||||
while candidate.exists():
|
||||
candidate = folder / f"{stem}-{counter}{suffix}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
class RangeFileWrapper:
|
||||
"""Iterate over a limited byte range of an open file."""
|
||||
|
||||
def __init__(self, file, length, chunk_size=64 * 1024):
|
||||
self.file = file
|
||||
self.remaining = length
|
||||
self.chunk_size = chunk_size
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if self.remaining <= 0:
|
||||
raise StopIteration
|
||||
data = self.file.read(min(self.chunk_size, self.remaining))
|
||||
if not data:
|
||||
raise StopIteration
|
||||
self.remaining -= len(data)
|
||||
return data
|
||||
|
||||
def close(self):
|
||||
self.file.close()
|
||||
|
||||
|
||||
def serve_file(request, path, download=False):
|
||||
"""Serve a file with HTTP range support (needed for video seeking)."""
|
||||
path = Path(path)
|
||||
if not path.is_file():
|
||||
raise Http404
|
||||
size = path.stat().st_size
|
||||
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
||||
range_header = request.headers.get("Range", "").strip()
|
||||
if range_header:
|
||||
match = RANGE_RE.match(range_header)
|
||||
if match:
|
||||
start_raw, end_raw = match.groups()
|
||||
if start_raw == "" and end_raw:
|
||||
length = min(int(end_raw), size)
|
||||
start, end = size - length, size - 1
|
||||
else:
|
||||
start = int(start_raw or 0)
|
||||
end = min(int(end_raw) if end_raw else size - 1, size - 1)
|
||||
if start >= size or start > end:
|
||||
response = HttpResponse(status=416)
|
||||
response["Content-Range"] = f"bytes */{size}"
|
||||
return response
|
||||
length = end - start + 1
|
||||
handle = open(path, "rb")
|
||||
handle.seek(start)
|
||||
response = FileResponse(
|
||||
RangeFileWrapper(handle, length),
|
||||
status=206,
|
||||
content_type=content_type,
|
||||
)
|
||||
response["Content-Length"] = str(length)
|
||||
response["Content-Range"] = f"bytes {start}-{end}/{size}"
|
||||
response["Accept-Ranges"] = "bytes"
|
||||
return response
|
||||
response = FileResponse(
|
||||
open(path, "rb"),
|
||||
content_type=content_type,
|
||||
as_attachment=download,
|
||||
filename=path.name,
|
||||
)
|
||||
response["Accept-Ranges"] = "bytes"
|
||||
return response
|
||||
|
||||
|
||||
def generate_video_thumbnail(md5, path):
|
||||
"""Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs."""
|
||||
if not shutil.which("ffmpeg"):
|
||||
return None
|
||||
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs"
|
||||
thumbs_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = thumbs_dir / f"{md5}.jpg"
|
||||
if target.exists() and target.stat().st_mtime >= os.path.getmtime(path):
|
||||
return target
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-ss",
|
||||
"0.5",
|
||||
"-i",
|
||||
str(path),
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-vf",
|
||||
"scale=480:-2",
|
||||
"-loglevel",
|
||||
"error",
|
||||
str(target),
|
||||
]
|
||||
try:
|
||||
subprocess.run(command, check=True, capture_output=True, timeout=60)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return None
|
||||
return target if target.exists() else None
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import MediaItemViewSet, UploadView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("files", MediaItemViewSet, basename="file")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
path("uploads/", UploadView.as_view(), name="upload"),
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Min
|
||||
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 IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from . import services
|
||||
from .models import MediaItem
|
||||
from .serializers import MediaItemSerializer
|
||||
|
||||
|
||||
class MediaItemViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
serializer_class = MediaItemSerializer
|
||||
lookup_field = "md5"
|
||||
http_method_names = ["get", "patch", "head", "options"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = MediaItem.objects.prefetch_related("locations").annotate(
|
||||
name=Min("locations__rel_path")
|
||||
)
|
||||
search = self.request.query_params.get("search", "").strip()
|
||||
if search:
|
||||
queryset = queryset.filter(locations__rel_path__icontains=search)
|
||||
ratings = [
|
||||
value
|
||||
for value in self.request.query_params.get("rating", "").split(",")
|
||||
if value in {"s", "q", "e"}
|
||||
]
|
||||
if ratings:
|
||||
queryset = queryset.filter(rating__in=ratings)
|
||||
ordering = self.request.query_params.get("ordering", "").strip()
|
||||
allowed = {"name", "-name", "size", "-size", "created_at", "-created_at"}
|
||||
queryset = queryset.order_by(ordering if ordering in allowed else "-created_at")
|
||||
return queryset.distinct()
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def raw(self, request, md5=None):
|
||||
item = self.get_object()
|
||||
location = item.locations.first()
|
||||
if location is None:
|
||||
return Response(
|
||||
{"detail": "No file for this item."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return services.serve_file(
|
||||
request, location.path, download=request.query_params.get("download") == "1"
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
def thumbnail(self, request, md5=None):
|
||||
item = self.get_object()
|
||||
location = item.locations.first()
|
||||
if location is None:
|
||||
return Response(
|
||||
{"detail": "No file for this item."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
path = Path(location.path)
|
||||
if path.suffix.lower() in services.VIDEO_EXTENSIONS:
|
||||
thumbnail = services.generate_video_thumbnail(item.md5, path)
|
||||
if thumbnail is None:
|
||||
return Response(
|
||||
{"detail": "Thumbnail unavailable."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return services.serve_file(request, thumbnail)
|
||||
return services.serve_file(request, path)
|
||||
|
||||
|
||||
class UploadView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response(
|
||||
{"detail": "A file is required."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
folder = Path(settings.WATCHED_FOLDER)
|
||||
destination = services.unique_destination(folder, upload.name)
|
||||
with open(destination, "wb") as output:
|
||||
for chunk in upload.chunks():
|
||||
output.write(chunk)
|
||||
try:
|
||||
item, _, _ = services.index_file(destination, folder)
|
||||
except Exception as exc: # noqa: BLE001 - report indexing failures
|
||||
destination.unlink(missing_ok=True)
|
||||
return Response(
|
||||
{"detail": f"Could not index upload: {exc}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
update_fields = []
|
||||
rating = request.data.get("rating") or ""
|
||||
if rating in {"s", "q", "e"} and item.rating != rating:
|
||||
item.rating = rating
|
||||
update_fields.append("rating")
|
||||
tags = services.parse_tags(request.data.get("tags"))
|
||||
if tags is not None:
|
||||
item.tags = tags
|
||||
update_fields.append("tags")
|
||||
notes = request.data.get("notes")
|
||||
if notes is not None:
|
||||
item.notes = str(notes)
|
||||
update_fields.append("notes")
|
||||
if update_fields:
|
||||
item.save(update_fields=update_fields + ["updated_at"])
|
||||
|
||||
return Response(
|
||||
MediaItemSerializer(item, context={"request": request}).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for config project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.1/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
@@ -0,0 +1,8 @@
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
class StandardPagination(PageNumberPagination):
|
||||
page_size = 48
|
||||
page_size_query_param = "per_page"
|
||||
max_page_size = 330
|
||||
page_query_param = "page"
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Django settings for the J621 backend.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "django-insecure-dev-only-change-me")
|
||||
DEBUG = os.getenv("DEBUG", "True").lower() == "true"
|
||||
ALLOWED_HOSTS = [
|
||||
host.strip()
|
||||
for host in os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(",")
|
||||
if host.strip()
|
||||
]
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"django_filters",
|
||||
"apps.accounts",
|
||||
"apps.library",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "config.wsgi.application"
|
||||
|
||||
# Database
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
# Authentication
|
||||
|
||||
AUTH_USER_MODEL = "accounts.User"
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
# Internationalization
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = os.getenv("TIME_ZONE", "America/Bogota")
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static and media files
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
# Watched folder that gets indexed into the library.
|
||||
_watched = os.getenv("WATCHED_FOLDER", "").strip()
|
||||
WATCHED_FOLDER = Path(_watched) if _watched else MEDIA_ROOT / "library"
|
||||
if not WATCHED_FOLDER.is_absolute():
|
||||
WATCHED_FOLDER = BASE_DIR / WATCHED_FOLDER
|
||||
WATCHED_FOLDER = str(WATCHED_FOLDER)
|
||||
|
||||
# Django REST Framework
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
||||
],
|
||||
"DEFAULT_FILTER_BACKENDS": [
|
||||
"django_filters.rest_framework.DjangoFilterBackend",
|
||||
"rest_framework.filters.SearchFilter",
|
||||
"rest_framework.filters.OrderingFilter",
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "config.pagination.StandardPagination",
|
||||
"PAGE_SIZE": 48,
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"simple": {
|
||||
"format": "{levelname} {asctime} {name} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "simple",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/auth/", include("apps.accounts.urls")),
|
||||
path("api/", include("apps.library.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for config project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
Django>=6.0,<6.2
|
||||
djangorestframework>=3.17
|
||||
django-filter>=25.1
|
||||
Pillow>=11.0
|
||||
python-dotenv>=1.0
|
||||
Reference in New Issue
Block a user