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:
2026-09-17 07:56:47 -05:00
commit 555c25d77b
65 changed files with 5237 additions and 0 deletions
+5
View File
@@ -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
View File
View File
+9
View File
@@ -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
+5
View File
@@ -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()),
],
),
]
+5
View File
@@ -0,0 +1,5 @@
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
"""Project user. Extended later with avatars and e621 credentials."""
+22
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+11
View File
@@ -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"),
]
+37
View File
@@ -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)
View File
+23
View File
@@ -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")
+5
View File
@@ -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')},
},
),
]
+38
View File
@@ -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
+63
View File
@@ -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"
+191
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+12
View File
@@ -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"),
]
+123
View File
@@ -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,
)
View File
+16
View File
@@ -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()
+8
View File
@@ -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"
+156
View File
@@ -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",
},
}
+13
View File
@@ -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)
+16
View File
@@ -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()
+22
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
Django>=6.0,<6.2
djangorestframework>=3.17
django-filter>=25.1
Pillow>=11.0
python-dotenv>=1.0