Initial commit - SoundWave v1.0

- Full PWA support with offline capabilities
- Comprehensive search across songs, playlists, and channels
- Offline playlist manager with download tracking
- Pre-built frontend for zero-build deployment
- Docker-based deployment with docker compose
- Material-UI dark theme interface
- YouTube audio download and management
- Multi-user authentication support
This commit is contained in:
Iulian 2025-12-16 23:43:07 +00:00
commit 51679d1943
254 changed files with 37281 additions and 0 deletions

View file

View file

@ -0,0 +1,5 @@
"""App settings admin"""
from django.contrib import admin
# No models to register for appsettings

View file

@ -0,0 +1,6 @@
"""App settings models - configuration stored in database"""
from django.db import models
# Settings can be stored in database or managed through environment variables
# For now, we'll use environment variables primarily

View file

@ -0,0 +1,12 @@
"""App settings serializers"""
from rest_framework import serializers
class AppConfigSerializer(serializers.Serializer):
"""Application configuration"""
app_name = serializers.CharField(default='SoundWave')
version = serializers.CharField(default='1.0.0')
sw_host = serializers.URLField()
audio_quality = serializers.CharField(default='best')
auto_update_ytdlp = serializers.BooleanField(default=False)

View file

@ -0,0 +1,9 @@
"""App settings URL patterns"""
from django.urls import path
from appsettings.views import AppConfigView, BackupView
urlpatterns = [
path('config/', AppConfigView.as_view(), name='app-config'),
path('backup/', BackupView.as_view(), name='backup'),
]

View file

@ -0,0 +1,37 @@
"""App settings API views"""
from django.conf import settings
from rest_framework.response import Response
from appsettings.serializers import AppConfigSerializer
from common.views import ApiBaseView, AdminOnly
class AppConfigView(ApiBaseView):
"""Application configuration endpoint"""
def get(self, request):
"""Get app configuration"""
config = {
'app_name': 'SoundWave',
'version': '1.0.0',
'sw_host': settings.SW_HOST,
'audio_quality': 'best',
'auto_update_ytdlp': settings.SW_AUTO_UPDATE_YTDLP,
}
serializer = AppConfigSerializer(config)
return Response(serializer.data)
class BackupView(ApiBaseView):
"""Backup management endpoint"""
permission_classes = [AdminOnly]
def get(self, request):
"""Get list of backups"""
# TODO: Implement backup listing
return Response({'backups': []})
def post(self, request):
"""Create backup"""
# TODO: Implement backup creation
return Response({'message': 'Backup created'})

View file

12
backend/channel/admin.py Normal file
View file

@ -0,0 +1,12 @@
"""Channel admin"""
from django.contrib import admin
from channel.models import Channel
@admin.register(Channel)
class ChannelAdmin(admin.ModelAdmin):
"""Channel admin"""
list_display = ('channel_name', 'subscribed', 'video_count', 'subscriber_count', 'last_refreshed')
list_filter = ('subscribed', 'last_refreshed')
search_fields = ('channel_name', 'channel_id')

View file

71
backend/channel/models.py Normal file
View file

@ -0,0 +1,71 @@
"""Channel models"""
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class Channel(models.Model):
"""YouTube channel model"""
# User isolation
owner = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='channels',
help_text="User who owns this channel subscription"
)
youtube_account = models.ForeignKey(
'user.UserYouTubeAccount',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='channels',
help_text="YouTube account used to subscribe to this channel"
)
channel_id = models.CharField(max_length=50, db_index=True)
channel_name = models.CharField(max_length=200)
channel_description = models.TextField(blank=True)
channel_thumbnail = models.URLField(max_length=500, blank=True)
subscribed = models.BooleanField(default=True)
subscriber_count = models.IntegerField(default=0)
video_count = models.IntegerField(default=0)
last_refreshed = models.DateTimeField(auto_now=True)
created_date = models.DateTimeField(auto_now_add=True)
# Status tracking
active = models.BooleanField(default=True, help_text="Channel is active and available")
sync_status = models.CharField(
max_length=20,
choices=[
('pending', 'Pending'),
('syncing', 'Syncing'),
('success', 'Success'),
('failed', 'Failed'),
('stale', 'Stale'),
],
default='pending',
help_text="Current sync status"
)
error_message = models.TextField(blank=True, help_text="Last error message if sync failed")
downloaded_count = models.IntegerField(default=0, help_text="Downloaded videos count")
# Download settings per channel
auto_download = models.BooleanField(default=True, help_text="Auto-download new videos from this channel")
download_quality = models.CharField(
max_length=20,
default='auto',
choices=[('auto', 'Auto'), ('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('ultra', 'Ultra')]
)
class Meta:
ordering = ['channel_name']
unique_together = ('owner', 'channel_id') # Each user can subscribe once per channel
indexes = [
models.Index(fields=['owner', 'channel_id']),
models.Index(fields=['owner', 'subscribed']),
]
def __str__(self):
return f"{self.owner.username} - {self.channel_name}"

View file

@ -0,0 +1,54 @@
"""Channel serializers"""
from rest_framework import serializers
from channel.models import Channel
import re
class ChannelSubscribeSerializer(serializers.Serializer):
"""Channel subscription from URL"""
url = serializers.URLField(required=True, help_text="YouTube channel URL")
def validate_url(self, value):
"""Extract channel ID from URL"""
# Match various YouTube channel URL patterns
patterns = [
r'youtube\.com/channel/(UC[\w-]+)',
r'youtube\.com/@([\w-]+)',
r'youtube\.com/c/([\w-]+)',
r'youtube\.com/user/([\w-]+)',
]
for pattern in patterns:
match = re.search(pattern, value)
if match:
return match.group(1)
# If it's just a channel ID
if value.startswith('UC') and len(value) == 24:
return value
raise serializers.ValidationError("Invalid YouTube channel URL")
class ChannelSerializer(serializers.ModelSerializer):
"""Channel serializer"""
status_display = serializers.CharField(source='get_sync_status_display', read_only=True)
progress_percent = serializers.SerializerMethodField()
class Meta:
model = Channel
fields = '__all__'
read_only_fields = ['created_date', 'last_refreshed']
def get_progress_percent(self, obj):
"""Calculate download progress percentage"""
if obj.video_count == 0:
return 0
return int((obj.downloaded_count / obj.video_count) * 100)
class ChannelListSerializer(serializers.Serializer):
"""Channel list response"""
data = ChannelSerializer(many=True)
paginate = serializers.BooleanField(default=True)

9
backend/channel/urls.py Normal file
View file

@ -0,0 +1,9 @@
"""Channel URL patterns"""
from django.urls import path
from channel.views import ChannelListView, ChannelDetailView
urlpatterns = [
path('', ChannelListView.as_view(), name='channel-list'),
path('<str:channel_id>/', ChannelDetailView.as_view(), name='channel-detail'),
]

65
backend/channel/views.py Normal file
View file

@ -0,0 +1,65 @@
"""Channel API views"""
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.response import Response
from channel.models import Channel
from channel.serializers import ChannelSerializer
from common.views import ApiBaseView, AdminWriteOnly
class ChannelListView(ApiBaseView):
"""Channel list endpoint"""
permission_classes = [AdminWriteOnly]
def get(self, request):
"""Get channel list"""
channels = Channel.objects.filter(owner=request.user, subscribed=True)
serializer = ChannelSerializer(channels, many=True)
return Response({'data': serializer.data, 'paginate': True})
def post(self, request):
"""Subscribe to channel - TubeArchivist pattern with Celery task"""
from channel.serializers import ChannelSubscribeSerializer
# Check channel quota
if not request.user.can_add_channel:
return Response(
{'error': f'Channel limit reached. Maximum {request.user.max_channels} channels allowed.'},
status=status.HTTP_403_FORBIDDEN
)
# Validate URL
url_serializer = ChannelSubscribeSerializer(data=request.data)
url_serializer.is_valid(raise_exception=True)
channel_url = request.data['url']
# Trigger async Celery task (TubeArchivist pattern)
from task.tasks import subscribe_to_channel
task = subscribe_to_channel.delay(request.user.id, channel_url)
return Response(
{
'message': 'Channel subscription task started',
'task_id': str(task.id)
},
status=status.HTTP_202_ACCEPTED
)
class ChannelDetailView(ApiBaseView):
"""Channel detail endpoint"""
permission_classes = [AdminWriteOnly]
def get(self, request, channel_id):
"""Get channel details"""
channel = get_object_or_404(Channel, channel_id=channel_id, owner=request.user)
serializer = ChannelSerializer(channel)
return Response(serializer.data)
def delete(self, request, channel_id):
"""Unsubscribe from channel"""
channel = get_object_or_404(Channel, channel_id=channel_id, owner=request.user)
channel.subscribed = False
channel.save()
return Response(status=status.HTTP_204_NO_CONTENT)

View file

0
backend/common/admin.py Normal file
View file

View file

5
backend/common/models.py Normal file
View file

@ -0,0 +1,5 @@
"""Common models - shared across apps"""
from django.db import models
# No models in common app - it provides shared utilities

View file

@ -0,0 +1,107 @@
"""
DRF Permissions for multi-tenant user isolation
"""
from rest_framework import permissions
class IsOwnerOrAdmin(permissions.BasePermission):
"""
Object-level permission to only allow owners or admins to access objects
"""
def has_permission(self, request, view):
"""Check if user is authenticated"""
return request.user and request.user.is_authenticated
def has_object_permission(self, request, view, obj):
"""Check if user is owner or admin"""
# Admins can access everything
if request.user.is_admin or request.user.is_superuser:
return True
# Check if object has owner field
if hasattr(obj, 'owner'):
return obj.owner == request.user
# Check if object has user field
if hasattr(obj, 'user'):
return obj.user == request.user
# Check if object is the user itself
if obj == request.user:
return True
return False
class IsAdminOrReadOnly(permissions.BasePermission):
"""
Admins can edit, regular users can only read their own data
"""
def has_permission(self, request, view):
"""Check if user is authenticated"""
if not request.user or not request.user.is_authenticated:
return False
# Read permissions are allowed for authenticated users
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions only for admins
return request.user.is_admin or request.user.is_superuser
def has_object_permission(self, request, view, obj):
"""Check object-level permissions"""
# Read permissions for owner or admin
if request.method in permissions.SAFE_METHODS:
if request.user.is_admin or request.user.is_superuser:
return True
if hasattr(obj, 'owner'):
return obj.owner == request.user
if hasattr(obj, 'user'):
return obj.user == request.user
# Write permissions only for admins
return request.user.is_admin or request.user.is_superuser
class CanManageUsers(permissions.BasePermission):
"""
Only admins can manage users
"""
def has_permission(self, request, view):
"""Check if user is admin"""
return (
request.user and
request.user.is_authenticated and
(request.user.is_admin or request.user.is_superuser)
)
class WithinQuotaLimits(permissions.BasePermission):
"""
Check if user is within their quota limits
"""
message = "You have exceeded your quota limits"
def has_permission(self, request, view):
"""Check quota limits for POST requests"""
if request.method != 'POST':
return True
user = request.user
if not user or not user.is_authenticated:
return False
# Admins bypass quota checks
if user.is_admin or user.is_superuser:
return True
# Check storage quota
if user.storage_used_gb >= user.storage_quota_gb:
self.message = f"Storage quota exceeded ({user.storage_used_gb:.1f} / {user.storage_quota_gb} GB)"
return False
return True

View file

@ -0,0 +1,16 @@
"""Common serializers"""
from rest_framework import serializers
class ErrorResponseSerializer(serializers.Serializer):
"""Error response"""
error = serializers.CharField()
details = serializers.DictField(required=False)
class AsyncTaskResponseSerializer(serializers.Serializer):
"""Async task response"""
task_id = serializers.CharField()
message = serializers.CharField()
status = serializers.CharField()

View file

@ -0,0 +1,103 @@
"""YouTube metadata extraction using yt-dlp"""
import yt_dlp
from typing import Dict, Optional
def get_playlist_metadata(playlist_id: str) -> Optional[Dict]:
"""
Fetch playlist metadata from YouTube
Args:
playlist_id: YouTube playlist ID
Returns:
Dictionary with playlist metadata or None if failed
"""
url = f"https://www.youtube.com/playlist?list={playlist_id}"
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': True,
'playlist_items': '1', # Only fetch first item to get playlist info
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if not info:
return None
# Extract thumbnail (try multiple qualities)
thumbnail = None
if info.get('thumbnails'):
# Get highest quality thumbnail
thumbnail = info['thumbnails'][-1].get('url')
return {
'title': info.get('title', f'Playlist {playlist_id[:8]}'),
'description': info.get('description', ''),
'channel_name': info.get('uploader', info.get('channel', '')),
'channel_id': info.get('uploader_id', info.get('channel_id', '')),
'thumbnail_url': thumbnail or '',
'item_count': info.get('playlist_count', 0),
}
except Exception as e:
print(f"Failed to fetch playlist metadata for {playlist_id}: {e}")
return None
def get_channel_metadata(channel_id: str) -> Optional[Dict]:
"""
Fetch channel metadata from YouTube
Args:
channel_id: YouTube channel ID or handle
Returns:
Dictionary with channel metadata or None if failed
"""
# Build URL based on channel_id format
if channel_id.startswith('UC') and len(channel_id) == 24:
url = f"https://www.youtube.com/channel/{channel_id}"
elif channel_id.startswith('@'):
url = f"https://www.youtube.com/{channel_id}"
else:
# Assume it's a username or custom URL
url = f"https://www.youtube.com/@{channel_id}"
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': True,
'playlist_items': '0', # Don't extract videos
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if not info:
return None
# Get actual channel ID if we used a handle
actual_channel_id = info.get('channel_id', channel_id)
# Extract thumbnails
thumbnail = None
if info.get('thumbnails'):
thumbnail = info['thumbnails'][-1].get('url')
return {
'channel_id': actual_channel_id,
'channel_name': info.get('channel', info.get('uploader', f'Channel {channel_id[:8]}')),
'channel_description': info.get('description', ''),
'channel_thumbnail': thumbnail or '',
'subscriber_count': info.get('channel_follower_count', 0),
'video_count': info.get('playlist_count', 0),
}
except Exception as e:
print(f"Failed to fetch channel metadata for {channel_id}: {e}")
return None

172
backend/common/streaming.py Normal file
View file

@ -0,0 +1,172 @@
"""
HTTP Range request support for media file streaming
Enables seeking in audio/video files by supporting partial content delivery
Security Features:
- Path normalization to prevent directory traversal
- User authentication (handled by Django middleware)
- File validation
- Content-Type header enforcement
- Symlink attack prevention
Note: Authentication is handled by Django's authentication middleware
before this view is reached. All media files are considered protected
and require an authenticated user session.
"""
import os
import re
import logging
from django.http import StreamingHttpResponse, HttpResponse, Http404
from django.utils.http import http_date
from pathlib import Path
from wsgiref.util import FileWrapper
logger = logging.getLogger(__name__)
def range_file_iterator(file_obj, offset=0, chunk_size=8192, length=None):
"""
Iterator for serving file in chunks with range support
Efficiently streams large files without loading entire file into memory
Args:
file_obj: Open file object
offset: Starting byte position
chunk_size: Size of each chunk to read
length: Total bytes to read (None = read to end)
"""
file_obj.seek(offset)
remaining = length
while True:
if remaining is not None:
chunk_size = min(chunk_size, remaining)
if chunk_size == 0:
break
data = file_obj.read(chunk_size)
if not data:
break
if remaining is not None:
remaining -= len(data)
yield data
def serve_media_with_range(request, path, document_root):
"""
Serve static media files with HTTP Range request support
This enables seeking in audio/video files
Security considerations:
1. Authentication: Assumes authentication is handled by Django middleware
2. Path Traversal: Prevents access to files outside document_root
3. File Validation: Only serves existing files within allowed directory
4. No Directory Listing: Returns 404 for directories
Args:
request: Django request object (user must be authenticated)
path: Relative path to file (validated for security)
document_root: Absolute path to media root directory
Returns:
StreamingHttpResponse with proper Range headers for seeking support
HTTP Status Codes:
200: Full content served
206: Partial content served (range request)
416: Range Not Satisfiable
404: File not found or access denied
"""
# Security: Normalize path and prevent directory traversal attacks
# Remove any path components that try to navigate up the directory tree
path = Path(path).as_posix()
if '..' in path or path.startswith('/') or '\\' in path:
logger.warning(f"Blocked directory traversal attempt: {path}")
raise Http404("Invalid path")
# Build full file path
full_path = Path(document_root) / path
# Security: Verify the resolved path is still within document_root
# This prevents symlink attacks and ensures files are in allowed directory
try:
full_path = full_path.resolve()
document_root = Path(document_root).resolve()
full_path.relative_to(document_root)
except (ValueError, OSError) as e:
logger.warning(f"Access denied for path: {path} - {e}")
raise Http404("Access denied")
# Check if file exists and is a file (not directory)
if not full_path.exists() or not full_path.is_file():
logger.debug(f"Media file not found: {path}")
raise Http404(f"Media file not found: {path}")
# Get file size
file_size = full_path.stat().st_size
# Get Range header
range_header = request.META.get('HTTP_RANGE', '').strip()
range_match = re.match(r'bytes=(\d+)-(\d*)', range_header)
# Determine content type
content_type = 'application/octet-stream'
ext = full_path.suffix.lower()
content_types = {
'.mp3': 'audio/mpeg',
'.mp4': 'video/mp4',
'.m4a': 'audio/mp4',
'.webm': 'video/webm',
'.ogg': 'audio/ogg',
'.wav': 'audio/wav',
'.flac': 'audio/flac',
'.aac': 'audio/aac',
'.opus': 'audio/opus',
}
content_type = content_types.get(ext, content_type)
# Open file
file_obj = open(full_path, 'rb')
# Handle Range request (for seeking)
if range_match:
start = int(range_match.group(1))
end = range_match.group(2)
end = int(end) if end else file_size - 1
# Validate range
if start >= file_size or end >= file_size or start > end:
file_obj.close()
response = HttpResponse(status=416) # Range Not Satisfiable
response['Content-Range'] = f'bytes */{file_size}'
return response
# Calculate content length for this range
length = end - start + 1
# Create streaming response with partial content
response = StreamingHttpResponse(
range_file_iterator(file_obj, offset=start, length=length),
status=206, # Partial Content
content_type=content_type,
)
response['Content-Length'] = str(length)
response['Content-Range'] = f'bytes {start}-{end}/{file_size}'
response['Accept-Ranges'] = 'bytes'
else:
# Serve entire file
response = StreamingHttpResponse(
FileWrapper(file_obj),
content_type=content_type,
)
response['Content-Length'] = str(file_size)
response['Accept-Ranges'] = 'bytes'
# Add caching headers for better performance
response['Cache-Control'] = 'public, max-age=3600'
response['Last-Modified'] = http_date(full_path.stat().st_mtime)
# Add Content-Disposition for download fallback
response['Content-Disposition'] = f'inline; filename="{full_path.name}"'
return response

7
backend/common/urls.py Normal file
View file

@ -0,0 +1,7 @@
"""Common URL patterns"""
from django.urls import path
urlpatterns = [
# Common endpoints can be added here
]

23
backend/common/views.py Normal file
View file

@ -0,0 +1,23 @@
"""Common views"""
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.views import APIView
class ApiBaseView(APIView):
"""Base API view"""
pass
class AdminOnly(IsAdminUser):
"""Admin only permission"""
pass
class AdminWriteOnly(IsAuthenticated):
"""Allow all authenticated users to read and write their own data"""
def has_permission(self, request, view):
# All authenticated users can perform any action
# Data isolation is enforced at the view/queryset level via owner field
return request.user and request.user.is_authenticated

View file

@ -0,0 +1,6 @@
# Config app
# This will make sure the Celery app is always imported when Django starts
from .celery import app as celery_app
__all__ = ('celery_app',)

11
backend/config/asgi.py Normal file
View file

@ -0,0 +1,11 @@
"""
ASGI config for SoundWave project.
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()

50
backend/config/celery.py Normal file
View file

@ -0,0 +1,50 @@
"""Celery configuration for SoundWave"""
import os
from celery import Celery
from celery.schedules import crontab
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
app = Celery('soundwave')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
# Periodic task schedule
app.conf.beat_schedule = {
# SMART SYNC: Check for new content in subscriptions every 15 minutes
'sync-subscriptions': {
'task': 'update_subscriptions',
'schedule': crontab(minute='*/15'), # Every 15 minutes for faster sync
},
# Auto-fetch lyrics every hour
'auto-fetch-lyrics': {
'task': 'audio.auto_fetch_lyrics',
'schedule': crontab(minute=0), # Every hour
'kwargs': {'limit': 50, 'max_attempts': 3},
},
# Clean up lyrics cache weekly
'cleanup-lyrics-cache': {
'task': 'audio.cleanup_lyrics_cache',
'schedule': crontab(hour=3, minute=0, day_of_week=0), # Sunday at 3 AM
'kwargs': {'days_old': 30},
},
# Retry failed lyrics weekly
'refetch-failed-lyrics': {
'task': 'audio.refetch_failed_lyrics',
'schedule': crontab(hour=4, minute=0, day_of_week=0), # Sunday at 4 AM
'kwargs': {'days_old': 7, 'limit': 20},
},
# Auto-fetch artwork every 2 hours
'auto-fetch-artwork': {
'task': 'audio.auto_fetch_artwork_batch',
'schedule': crontab(minute=0, hour='*/2'), # Every 2 hours
'kwargs': {'limit': 50},
},
# Auto-fetch artist info daily
'auto-fetch-artist-info': {
'task': 'audio.auto_fetch_artist_info_batch',
'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
'kwargs': {'limit': 20},
},
}

View file

@ -0,0 +1,41 @@
"""Middleware for user isolation and multi-tenancy"""
from django.utils.deprecation import MiddlewareMixin
from django.db.models import Q
class UserIsolationMiddleware(MiddlewareMixin):
"""
Middleware to ensure users can only access their own data
Admins can access all data
"""
def process_request(self, request):
"""Add user isolation context to request"""
if hasattr(request, 'user') and request.user.is_authenticated:
# Add helper method to filter queryset by user
def filter_by_user(queryset):
"""Filter queryset to show only user's data or all if admin"""
if request.user.is_admin or request.user.is_superuser:
# Admins can see all data
return queryset
# Regular users see only their own data
if hasattr(queryset.model, 'owner'):
return queryset.filter(owner=request.user)
elif hasattr(queryset.model, 'user'):
return queryset.filter(user=request.user)
return queryset
request.filter_by_user = filter_by_user
request.is_admin_user = request.user.is_admin or request.user.is_superuser
return None
class StorageQuotaMiddleware(MiddlewareMixin):
"""Middleware to track storage usage"""
def process_response(self, request, response):
"""Update storage usage after file operations"""
# This can be expanded to track file uploads/deletions
# For now, it's a placeholder for future implementation
return response

201
backend/config/settings.py Normal file
View file

@ -0,0 +1,201 @@
"""
Django settings for SoundWave project.
"""
import os
from pathlib import Path
# Build paths inside the project
BASE_DIR = Path(__file__).resolve().parent.parent
# Security settings
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production')
DEBUG = os.environ.get('DJANGO_DEBUG', 'False') == 'True'
ALLOWED_HOSTS = ['*']
# 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',
'corsheaders',
'drf_spectacular',
'django_celery_beat',
# SoundWave apps
'user',
'common',
'audio',
'channel',
'playlist',
'download',
'task',
'appsettings',
'stats',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'corsheaders.middleware.CorsMiddleware',
'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',
# Custom middleware for multi-tenancy
'config.middleware.UserIsolationMiddleware',
'config.middleware.StorageQuotaMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR.parent / 'frontend' / 'dist', BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# Use /app/data for persistent storage across container rebuilds
import os
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR, exist_ok=True)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(DATA_DIR, 'db.sqlite3'),
}
}
# Custom user model
AUTH_USER_MODEL = 'user.Account'
# Password validation
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.environ.get('TZ', 'UTC')
USE_I18N = True
USE_TZ = True
# Static files
STATIC_URL = '/assets/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
BASE_DIR.parent / 'frontend' / 'dist' / 'assets',
BASE_DIR.parent / 'frontend' / 'dist', # For manifest.json, service-worker.js, etc.
]
# WhiteNoise configuration
WHITENOISE_USE_FINDERS = True
WHITENOISE_AUTOREFRESH = True
WHITENOISE_INDEX_FILE = False # Don't serve index.html for directories
WHITENOISE_MIMETYPES = {
'.js': 'application/javascript',
'.css': 'text/css',
}
# Media files
MEDIA_URL = '/media/'
# Ensure MEDIA_ROOT exists and is writable
MEDIA_ROOT = os.environ.get('MEDIA_ROOT', '/app/audio')
if not os.path.exists(MEDIA_ROOT):
os.makedirs(MEDIA_ROOT, exist_ok=True)
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# REST Framework
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 50,
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
# CORS settings
CORS_ALLOWED_ORIGINS = [
"http://localhost:8889",
"http://127.0.0.1:8889",
"http://192.168.50.71:8889",
]
CORS_ALLOW_CREDENTIALS = True
# CSRF settings for development cross-origin access
CSRF_TRUSTED_ORIGINS = [
"http://localhost:8889",
"http://127.0.0.1:8889",
"http://192.168.50.71:8889",
]
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SECURE = False
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = False
# Security headers for development
SECURE_CROSS_ORIGIN_OPENER_POLICY = None # Disable COOP header for development
# Spectacular settings
SPECTACULAR_SETTINGS = {
'TITLE': 'SoundWave API',
'DESCRIPTION': 'Audio archiving and streaming platform',
'VERSION': '1.0.0',
}
# Celery settings
CELERY_BROKER_URL = f"redis://{os.environ.get('REDIS_HOST', 'localhost')}:6379/0"
CELERY_RESULT_BACKEND = f"redis://{os.environ.get('REDIS_HOST', 'localhost')}:6379/0"
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = TIME_ZONE
# ElasticSearch settings
ES_URL = os.environ.get('ES_URL', 'http://localhost:92000')
ES_USER = os.environ.get('ELASTIC_USER', 'elastic')
ES_PASSWORD = os.environ.get('ELASTIC_PASSWORD', 'soundwave')
# SoundWave settings
SW_HOST = os.environ.get('SW_HOST', 'http://localhost:123456')
SW_AUTO_UPDATE_YTDLP = os.environ.get('SW_AUTO_UPDATE_YTDLP', 'false') == 'true'
# Last.fm API settings
# Register for API keys at: https://www.last.fm/api/account/create
LASTFM_API_KEY = os.environ.get('LASTFM_API_KEY', '')
LASTFM_API_SECRET = os.environ.get('LASTFM_API_SECRET', '')
# Fanart.tv API settings
# Register for API key at: https://fanart.tv/get-an-api-key/
FANART_API_KEY = os.environ.get('FANART_API_KEY', '')

59
backend/config/urls.py Normal file
View file

@ -0,0 +1,59 @@
"""URL Configuration for SoundWave"""
from django.contrib import admin
from django.urls import include, path, re_path
from django.conf import settings
from django.conf.urls.static import static
from django.views.generic import TemplateView
from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from common.streaming import serve_media_with_range
import os
urlpatterns = [
path("api/", include("common.urls")),
path("api/audio/", include("audio.urls")),
path("api/channel/", include("channel.urls")),
path("api/playlist/", include("playlist.urls")),
path("api/download/", include("download.urls")),
path("api/task/", include("task.urls")),
path("api/appsettings/", include("appsettings.urls")),
path("api/stats/", include("stats.urls")),
path("api/user/", include("user.urls")),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/docs/",
SpectacularSwaggerView.as_view(url_name="schema"),
name="swagger-ui",
),
path("admin/", admin.site.urls),
]
# Serve static files
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# Serve media files (audio files) with Range request support for seeking
if settings.MEDIA_URL and settings.MEDIA_ROOT:
urlpatterns += [
re_path(
r'^media/(?P<path>.*)$',
serve_media_with_range,
{'document_root': settings.MEDIA_ROOT},
),
]
# Serve PWA files from frontend/dist
frontend_dist = settings.BASE_DIR.parent / 'frontend' / 'dist'
urlpatterns += [
path('manifest.json', serve, {'path': 'manifest.json', 'document_root': frontend_dist}),
path('service-worker.js', serve, {'path': 'service-worker.js', 'document_root': frontend_dist}),
re_path(r'^img/(?P<path>.*)$', serve, {'document_root': frontend_dist / 'img'}),
re_path(r'^avatars/(?P<path>.*)$', serve, {'document_root': frontend_dist / 'avatars'}),
]
# Serve React frontend - catch all routes (must be LAST)
urlpatterns += [
re_path(r'^(?!api/|admin/|static/|media/|assets/).*$',
TemplateView.as_view(template_name='index.html'),
name='frontend'),
]

View file

@ -0,0 +1,19 @@
"""Settings for user registration and authentication"""
# Public registration disabled - only admins can create users
ALLOW_PUBLIC_REGISTRATION = False
# Require admin approval for new users (future feature)
REQUIRE_ADMIN_APPROVAL = False
# Minimum password requirements
PASSWORD_MIN_LENGTH = 8
PASSWORD_REQUIRE_UPPERCASE = True
PASSWORD_REQUIRE_LOWERCASE = True
PASSWORD_REQUIRE_NUMBERS = True
PASSWORD_REQUIRE_SPECIAL = False
# Account security
ENABLE_2FA = True
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15

11
backend/config/wsgi.py Normal file
View file

@ -0,0 +1,11 @@
"""
WSGI config for SoundWave project.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()

View file

12
backend/download/admin.py Normal file
View file

@ -0,0 +1,12 @@
"""Download admin"""
from django.contrib import admin
from download.models import DownloadQueue
@admin.register(DownloadQueue)
class DownloadQueueAdmin(admin.ModelAdmin):
"""Download queue admin"""
list_display = ('title', 'channel_name', 'status', 'added_date', 'auto_start')
list_filter = ('status', 'auto_start', 'added_date')
search_fields = ('title', 'url', 'youtube_id')

View file

View file

@ -0,0 +1,40 @@
"""Download queue models"""
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class DownloadQueue(models.Model):
"""Download queue model"""
STATUS_CHOICES = [
('pending', 'Pending'),
('downloading', 'Downloading'),
('completed', 'Completed'),
('failed', 'Failed'),
('ignored', 'Ignored'),
]
owner = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='download_queue',
help_text="User who owns this download"
)
url = models.URLField(max_length=500)
youtube_id = models.CharField(max_length=50, blank=True)
title = models.CharField(max_length=500, blank=True)
channel_name = models.CharField(max_length=200, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
error_message = models.TextField(blank=True)
added_date = models.DateTimeField(auto_now_add=True)
started_date = models.DateTimeField(null=True, blank=True)
completed_date = models.DateTimeField(null=True, blank=True)
auto_start = models.BooleanField(default=False)
class Meta:
ordering = ['-auto_start', 'added_date']
def __str__(self):
return f"{self.title or self.url} - {self.status}"

View file

@ -0,0 +1,22 @@
"""Download serializers"""
from rest_framework import serializers
from download.models import DownloadQueue
class DownloadQueueSerializer(serializers.ModelSerializer):
"""Download queue serializer"""
class Meta:
model = DownloadQueue
fields = '__all__'
read_only_fields = ['added_date', 'started_date', 'completed_date']
class AddToDownloadSerializer(serializers.Serializer):
"""Add to download queue"""
urls = serializers.ListField(
child=serializers.URLField(),
allow_empty=False
)
auto_start = serializers.BooleanField(default=False)

8
backend/download/urls.py Normal file
View file

@ -0,0 +1,8 @@
"""Download URL patterns"""
from django.urls import path
from download.views import DownloadListView
urlpatterns = [
path('', DownloadListView.as_view(), name='download-list'),
]

42
backend/download/views.py Normal file
View file

@ -0,0 +1,42 @@
"""Download API views"""
from rest_framework import status
from rest_framework.response import Response
from download.models import DownloadQueue
from download.serializers import DownloadQueueSerializer, AddToDownloadSerializer
from common.views import ApiBaseView, AdminWriteOnly
class DownloadListView(ApiBaseView):
"""Download queue list endpoint"""
permission_classes = [AdminWriteOnly]
def get(self, request):
"""Get download queue"""
status_filter = request.query_params.get('filter', 'pending')
queryset = DownloadQueue.objects.filter(owner=request.user, status=status_filter)
serializer = DownloadQueueSerializer(queryset, many=True)
return Response({'data': serializer.data})
def post(self, request):
"""Add to download queue"""
serializer = AddToDownloadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
created_items = []
for url in serializer.validated_data['urls']:
item, created = DownloadQueue.objects.get_or_create(
owner=request.user,
url=url,
defaults={'auto_start': serializer.validated_data['auto_start']}
)
created_items.append(item)
response_serializer = DownloadQueueSerializer(created_items, many=True)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
def delete(self, request):
"""Clear download queue"""
status_filter = request.query_params.get('filter', 'pending')
DownloadQueue.objects.filter(owner=request.user, status=status_filter).delete()
return Response(status=status.HTTP_204_NO_CONTENT)

22
backend/manage.py Normal file
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()

View file

19
backend/playlist/admin.py Normal file
View file

@ -0,0 +1,19 @@
"""Playlist admin"""
from django.contrib import admin
from playlist.models import Playlist, PlaylistItem
@admin.register(Playlist)
class PlaylistAdmin(admin.ModelAdmin):
"""Playlist admin"""
list_display = ('title', 'playlist_type', 'subscribed', 'created_date')
list_filter = ('playlist_type', 'subscribed')
search_fields = ('title', 'playlist_id')
@admin.register(PlaylistItem)
class PlaylistItemAdmin(admin.ModelAdmin):
"""Playlist item admin"""
list_display = ('playlist', 'audio', 'position', 'added_date')
list_filter = ('playlist', 'added_date')

View file

View file

@ -0,0 +1,82 @@
"""Playlist models"""
from django.db import models
from django.contrib.auth import get_user_model
from audio.models import Audio
User = get_user_model()
class Playlist(models.Model):
"""Playlist model"""
PLAYLIST_TYPE_CHOICES = [
('youtube', 'YouTube Playlist'),
('custom', 'Custom Playlist'),
]
# User isolation
owner = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='playlists',
help_text="User who owns this playlist"
)
playlist_id = models.CharField(max_length=100, db_index=True)
title = models.CharField(max_length=500)
description = models.TextField(blank=True)
playlist_type = models.CharField(max_length=20, choices=PLAYLIST_TYPE_CHOICES, default='youtube')
channel_id = models.CharField(max_length=50, blank=True)
channel_name = models.CharField(max_length=200, blank=True)
subscribed = models.BooleanField(default=False)
thumbnail_url = models.URLField(max_length=500, blank=True)
created_date = models.DateTimeField(auto_now_add=True)
last_updated = models.DateTimeField(auto_now=True)
# Status tracking (inspired by TubeArchivist)
active = models.BooleanField(default=True, help_text="Playlist is active and available")
last_refresh = models.DateTimeField(null=True, blank=True, help_text="Last time playlist metadata was refreshed")
sync_status = models.CharField(
max_length=20,
choices=[
('pending', 'Pending'),
('syncing', 'Syncing'),
('success', 'Success'),
('failed', 'Failed'),
('stale', 'Stale'),
],
default='pending',
help_text="Current sync status"
)
error_message = models.TextField(blank=True, help_text="Last error message if sync failed")
item_count = models.IntegerField(default=0, help_text="Total items in playlist")
downloaded_count = models.IntegerField(default=0, help_text="Downloaded items count")
# Download settings
auto_download = models.BooleanField(default=False, help_text="Auto-download new items in this playlist")
class Meta:
ordering = ['-created_date']
unique_together = ('owner', 'playlist_id') # Each user can subscribe once per playlist
indexes = [
models.Index(fields=['owner', 'playlist_id']),
models.Index(fields=['owner', 'subscribed']),
]
def __str__(self):
return f"{self.owner.username} - {self.title}"
class PlaylistItem(models.Model):
"""Playlist item (audio file in playlist)"""
playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE, related_name='items')
audio = models.ForeignKey(Audio, on_delete=models.CASCADE, related_name='playlist_items')
position = models.IntegerField(default=0)
added_date = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('playlist', 'audio')
ordering = ['position']
def __str__(self):
return f"{self.playlist.title} - {self.audio.title}"

View file

@ -0,0 +1,139 @@
"""Models for playlist download management"""
from django.db import models
from django.contrib.auth import get_user_model
from playlist.models import Playlist
User = get_user_model()
class PlaylistDownload(models.Model):
"""Track playlist download for offline playback"""
STATUS_CHOICES = [
('pending', 'Pending'),
('downloading', 'Downloading'),
('completed', 'Completed'),
('failed', 'Failed'),
('paused', 'Paused'),
]
playlist = models.ForeignKey(
Playlist,
on_delete=models.CASCADE,
related_name='downloads'
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='playlist_downloads'
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
# Progress tracking
total_items = models.IntegerField(default=0)
downloaded_items = models.IntegerField(default=0)
failed_items = models.IntegerField(default=0)
# Size tracking
total_size_bytes = models.BigIntegerField(default=0, help_text="Total size in bytes")
downloaded_size_bytes = models.BigIntegerField(default=0, help_text="Downloaded size in bytes")
# Download settings
quality = models.CharField(
max_length=20,
default='medium',
choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('ultra', 'Ultra')]
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
# Error tracking
error_message = models.TextField(blank=True)
# Download location
download_path = models.CharField(max_length=500, blank=True, help_text="Path to downloaded files")
class Meta:
ordering = ['-created_at']
unique_together = ('playlist', 'user')
indexes = [
models.Index(fields=['user', 'status']),
models.Index(fields=['playlist', 'status']),
]
def __str__(self):
return f"{self.user.username} - {self.playlist.title} ({self.status})"
@property
def progress_percent(self):
"""Calculate download progress percentage"""
if self.total_items == 0:
return 0
return (self.downloaded_items / self.total_items) * 100
@property
def is_complete(self):
"""Check if download is complete"""
return self.status == 'completed'
@property
def can_resume(self):
"""Check if download can be resumed"""
return self.status in ['paused', 'failed']
class PlaylistDownloadItem(models.Model):
"""Track individual audio items in playlist download"""
STATUS_CHOICES = [
('pending', 'Pending'),
('downloading', 'Downloading'),
('completed', 'Completed'),
('failed', 'Failed'),
('skipped', 'Skipped'),
]
download = models.ForeignKey(
PlaylistDownload,
on_delete=models.CASCADE,
related_name='items'
)
audio = models.ForeignKey(
'audio.Audio',
on_delete=models.CASCADE,
related_name='playlist_download_items'
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
position = models.IntegerField(default=0)
# Progress tracking
file_size_bytes = models.BigIntegerField(default=0)
downloaded_bytes = models.BigIntegerField(default=0)
# Timestamps
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
# Error tracking
error_message = models.TextField(blank=True)
retry_count = models.IntegerField(default=0)
class Meta:
ordering = ['position']
unique_together = ('download', 'audio')
def __str__(self):
return f"{self.download.playlist.title} - {self.audio.title} ({self.status})"
@property
def progress_percent(self):
"""Calculate item download progress"""
if self.file_size_bytes == 0:
return 0
return (self.downloaded_bytes / self.file_size_bytes) * 100

View file

@ -0,0 +1,59 @@
"""Playlist serializers"""
from rest_framework import serializers
from playlist.models import Playlist, PlaylistItem
import re
class PlaylistSubscribeSerializer(serializers.Serializer):
"""Playlist subscription from URL"""
url = serializers.URLField(required=True, help_text="YouTube playlist URL")
def validate_url(self, value):
"""Extract playlist ID from URL"""
# Match YouTube playlist URL patterns
patterns = [
r'[?&]list=([a-zA-Z0-9_-]+)',
r'playlist\?list=([a-zA-Z0-9_-]+)',
]
for pattern in patterns:
match = re.search(pattern, value)
if match:
return match.group(1)
# If it's just a playlist ID
if len(value) >= 13 and value.startswith(('PL', 'UU', 'LL', 'RD')):
return value
raise serializers.ValidationError("Invalid YouTube playlist URL")
class PlaylistSerializer(serializers.ModelSerializer):
"""Playlist serializer"""
item_count = serializers.SerializerMethodField()
progress_percent = serializers.SerializerMethodField()
status_display = serializers.CharField(source='get_sync_status_display', read_only=True)
class Meta:
model = Playlist
fields = '__all__'
read_only_fields = ['owner', 'created_date', 'last_updated', 'last_refresh']
def get_item_count(self, obj):
return obj.items.count()
def get_progress_percent(self, obj):
"""Calculate download progress percentage"""
if obj.item_count == 0:
return 0
return int((obj.downloaded_count / obj.item_count) * 100)
class PlaylistItemSerializer(serializers.ModelSerializer):
"""Playlist item serializer"""
class Meta:
model = PlaylistItem
fields = '__all__'
read_only_fields = ['added_date']

View file

@ -0,0 +1,110 @@
"""Serializers for playlist download"""
from rest_framework import serializers
from playlist.models_download import PlaylistDownload, PlaylistDownloadItem
from playlist.serializers import PlaylistSerializer
class PlaylistDownloadItemSerializer(serializers.ModelSerializer):
"""Serializer for playlist download items"""
audio_title = serializers.CharField(source='audio.title', read_only=True)
audio_duration = serializers.IntegerField(source='audio.duration', read_only=True)
progress_percent = serializers.FloatField(read_only=True)
class Meta:
model = PlaylistDownloadItem
fields = [
'id',
'audio',
'audio_title',
'audio_duration',
'status',
'position',
'file_size_bytes',
'downloaded_bytes',
'progress_percent',
'started_at',
'completed_at',
'error_message',
'retry_count',
]
read_only_fields = [
'id',
'status',
'file_size_bytes',
'downloaded_bytes',
'started_at',
'completed_at',
'error_message',
'retry_count',
]
class PlaylistDownloadSerializer(serializers.ModelSerializer):
"""Serializer for playlist downloads"""
playlist_data = PlaylistSerializer(source='playlist', read_only=True)
progress_percent = serializers.FloatField(read_only=True)
is_complete = serializers.BooleanField(read_only=True)
can_resume = serializers.BooleanField(read_only=True)
items = PlaylistDownloadItemSerializer(many=True, read_only=True)
class Meta:
model = PlaylistDownload
fields = [
'id',
'playlist',
'playlist_data',
'status',
'total_items',
'downloaded_items',
'failed_items',
'progress_percent',
'total_size_bytes',
'downloaded_size_bytes',
'quality',
'created_at',
'started_at',
'completed_at',
'error_message',
'download_path',
'is_complete',
'can_resume',
'items',
]
read_only_fields = [
'id',
'status',
'total_items',
'downloaded_items',
'failed_items',
'total_size_bytes',
'downloaded_size_bytes',
'created_at',
'started_at',
'completed_at',
'error_message',
'download_path',
]
class PlaylistDownloadCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating playlist download"""
class Meta:
model = PlaylistDownload
fields = ['playlist', 'quality']
def validate_playlist(self, value):
"""Validate user owns the playlist"""
request = self.context.get('request')
if request and hasattr(value, 'owner'):
if value.owner != request.user:
raise serializers.ValidationError("You can only download your own playlists")
return value
def create(self, validated_data):
"""Set user from request"""
request = self.context.get('request')
if request and request.user.is_authenticated:
validated_data['user'] = request.user
return super().create(validated_data)

View file

@ -0,0 +1,249 @@
"""Celery tasks for playlist downloading"""
from celery import shared_task
from django.utils import timezone
from django.db import transaction
import logging
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3)
def download_playlist_task(self, download_id):
"""
Download all items in a playlist
Args:
download_id: PlaylistDownload ID
"""
from playlist.models_download import PlaylistDownload, PlaylistDownloadItem
from playlist.models import PlaylistItem
from audio.models import Audio
try:
download = PlaylistDownload.objects.select_related('playlist', 'user').get(id=download_id)
# Update status to downloading
download.status = 'downloading'
download.started_at = timezone.now()
download.save()
# Get all playlist items
playlist_items = PlaylistItem.objects.filter(
playlist=download.playlist
).select_related('audio').order_by('position')
# Create download items
download_items = []
for idx, item in enumerate(playlist_items):
download_item, created = PlaylistDownloadItem.objects.get_or_create(
download=download,
audio=item.audio,
defaults={
'position': idx,
'status': 'pending',
}
)
download_items.append(download_item)
# Update total items count
download.total_items = len(download_items)
download.save()
# Download each item
for download_item in download_items:
try:
# Check if already downloaded
if download_item.audio.downloaded:
download_item.status = 'skipped'
download_item.completed_at = timezone.now()
download_item.save()
download.downloaded_items += 1
download.save()
continue
# Trigger download for this audio
download_item.status = 'downloading'
download_item.started_at = timezone.now()
download_item.save()
# Call the audio download task
from download.tasks import download_audio_task
result = download_audio_task.apply(args=[download_item.audio.id])
if result.successful():
download_item.status = 'completed'
download_item.completed_at = timezone.now()
download_item.save()
download.downloaded_items += 1
download.downloaded_size_bytes += download_item.audio.file_size
download.save()
else:
raise Exception("Download task failed")
except Exception as e:
logger.error(f"Error downloading item {download_item.id}: {e}")
download_item.status = 'failed'
download_item.error_message = str(e)
download_item.retry_count += 1
download_item.save()
download.failed_items += 1
download.save()
# Mark as completed
download.status = 'completed'
download.completed_at = timezone.now()
download.save()
logger.info(f"Playlist download {download_id} completed: {download.downloaded_items}/{download.total_items} items")
return {
'download_id': download_id,
'status': 'completed',
'downloaded_items': download.downloaded_items,
'failed_items': download.failed_items,
'total_items': download.total_items,
}
except PlaylistDownload.DoesNotExist:
logger.error(f"PlaylistDownload {download_id} not found")
raise
except Exception as e:
logger.error(f"Error in playlist download task {download_id}: {e}")
# Update download status
try:
download = PlaylistDownload.objects.get(id=download_id)
download.status = 'failed'
download.error_message = str(e)
download.save()
except:
pass
# Retry task
raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
@shared_task
def pause_playlist_download(download_id):
"""Pause a playlist download"""
from playlist.models_download import PlaylistDownload
try:
download = PlaylistDownload.objects.get(id=download_id)
download.status = 'paused'
download.save()
logger.info(f"Playlist download {download_id} paused")
return {'download_id': download_id, 'status': 'paused'}
except PlaylistDownload.DoesNotExist:
logger.error(f"PlaylistDownload {download_id} not found")
return {'error': 'Download not found'}
@shared_task
def resume_playlist_download(download_id):
"""Resume a paused or failed playlist download"""
from playlist.models_download import PlaylistDownload
try:
download = PlaylistDownload.objects.get(id=download_id)
if not download.can_resume:
return {'error': 'Download cannot be resumed'}
# Trigger the download task again
download_playlist_task.apply_async(args=[download_id])
logger.info(f"Playlist download {download_id} resumed")
return {'download_id': download_id, 'status': 'resumed'}
except PlaylistDownload.DoesNotExist:
logger.error(f"PlaylistDownload {download_id} not found")
return {'error': 'Download not found'}
@shared_task
def cancel_playlist_download(download_id):
"""Cancel a playlist download"""
from playlist.models_download import PlaylistDownload
try:
download = PlaylistDownload.objects.get(id=download_id)
download.status = 'failed'
download.error_message = 'Cancelled by user'
download.completed_at = timezone.now()
download.save()
logger.info(f"Playlist download {download_id} cancelled")
return {'download_id': download_id, 'status': 'cancelled'}
except PlaylistDownload.DoesNotExist:
logger.error(f"PlaylistDownload {download_id} not found")
return {'error': 'Download not found'}
@shared_task
def cleanup_old_downloads():
"""Clean up old completed downloads (older than 30 days)"""
from playlist.models_download import PlaylistDownload
from django.utils import timezone
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=30)
old_downloads = PlaylistDownload.objects.filter(
status='completed',
completed_at__lt=cutoff_date
)
count = old_downloads.count()
old_downloads.delete()
logger.info(f"Cleaned up {count} old playlist downloads")
return {'cleaned_up': count}
@shared_task
def retry_failed_items(download_id):
"""Retry failed items in a playlist download"""
from playlist.models_download import PlaylistDownload, PlaylistDownloadItem
try:
download = PlaylistDownload.objects.get(id=download_id)
# Get failed items
failed_items = PlaylistDownloadItem.objects.filter(
download=download,
status='failed',
retry_count__lt=3 # Max 3 retries
)
if not failed_items.exists():
return {'message': 'No failed items to retry'}
# Reset failed items to pending
failed_items.update(
status='pending',
error_message='',
retry_count=models.F('retry_count') + 1
)
# Update download status
download.status = 'downloading'
download.failed_items = 0
download.save()
# Trigger download task
download_playlist_task.apply_async(args=[download_id])
logger.info(f"Retrying {failed_items.count()} failed items for download {download_id}")
return {'download_id': download_id, 'retried_items': failed_items.count()}
except PlaylistDownload.DoesNotExist:
logger.error(f"PlaylistDownload {download_id} not found")
return {'error': 'Download not found'}

12
backend/playlist/urls.py Normal file
View file

@ -0,0 +1,12 @@
"""Playlist URL patterns"""
from django.urls import path, include
from playlist.views import PlaylistListView, PlaylistDetailView
urlpatterns = [
# Playlist download management - must come BEFORE catch-all patterns
path('downloads/', include('playlist.urls_download')),
# Main playlist endpoints
path('', PlaylistListView.as_view(), name='playlist-list'),
path('<str:playlist_id>/', PlaylistDetailView.as_view(), name='playlist-detail'),
]

View file

@ -0,0 +1,12 @@
"""URL configuration for playlist downloads"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from playlist.views_download import PlaylistDownloadViewSet
router = DefaultRouter()
router.register(r'downloads', PlaylistDownloadViewSet, basename='playlist-downloads')
urlpatterns = [
path('', include(router.urls)),
]

110
backend/playlist/views.py Normal file
View file

@ -0,0 +1,110 @@
"""Playlist API views"""
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.response import Response
from playlist.models import Playlist, PlaylistItem
from playlist.serializers import PlaylistSerializer, PlaylistItemSerializer
from common.views import ApiBaseView, AdminWriteOnly
class PlaylistListView(ApiBaseView):
"""Playlist list endpoint"""
permission_classes = [AdminWriteOnly]
def get(self, request):
"""Get playlist list"""
playlists = Playlist.objects.filter(owner=request.user)
serializer = PlaylistSerializer(playlists, many=True)
return Response({'data': serializer.data})
def post(self, request):
"""Subscribe to playlist - TubeArchivist pattern with Celery task"""
from playlist.serializers import PlaylistSubscribeSerializer
import uuid
# Check playlist quota
if not request.user.can_add_playlist:
return Response(
{'error': f'Playlist limit reached. Maximum {request.user.max_playlists} playlists allowed.'},
status=status.HTTP_403_FORBIDDEN
)
# Check if it's a URL subscription
if 'url' in request.data:
url_serializer = PlaylistSubscribeSerializer(data=request.data)
url_serializer.is_valid(raise_exception=True)
playlist_url = request.data['url']
# Trigger async Celery task (TubeArchivist pattern)
from task.tasks import subscribe_to_playlist
task = subscribe_to_playlist.delay(request.user.id, playlist_url)
return Response(
{
'message': 'Playlist subscription task started',
'task_id': str(task.id)
},
status=status.HTTP_202_ACCEPTED
)
# Otherwise create custom playlist
# Auto-generate required fields for custom playlists
data = request.data.copy()
if 'playlist_id' not in data:
data['playlist_id'] = f'custom-{uuid.uuid4().hex[:12]}'
if 'title' not in data and 'name' in data:
data['title'] = data['name']
if 'playlist_type' not in data:
data['playlist_type'] = 'custom'
serializer = PlaylistSerializer(data=data)
serializer.is_valid(raise_exception=True)
serializer.save(owner=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class PlaylistDetailView(ApiBaseView):
"""Playlist detail endpoint"""
permission_classes = [AdminWriteOnly]
def get(self, request, playlist_id):
"""Get playlist details with items"""
playlist = get_object_or_404(Playlist, playlist_id=playlist_id, owner=request.user)
# Check if items are requested
include_items = request.query_params.get('include_items', 'false').lower() == 'true'
serializer = PlaylistSerializer(playlist)
response_data = serializer.data
if include_items:
# Get all playlist items with audio details
items = PlaylistItem.objects.filter(playlist=playlist).select_related('audio').order_by('position')
from audio.serializers import AudioSerializer
response_data['items'] = [{
'id': item.id,
'position': item.position,
'added_date': item.added_date,
'audio': AudioSerializer(item.audio).data
} for item in items]
return Response(response_data)
def post(self, request, playlist_id):
"""Trigger actions on playlist (e.g., download)"""
playlist = get_object_or_404(Playlist, playlist_id=playlist_id, owner=request.user)
action = request.data.get('action')
if action == 'download':
from task.tasks import download_playlist_task
download_playlist_task.delay(playlist.id)
return Response({'detail': 'Download task started'}, status=status.HTTP_202_ACCEPTED)
return Response({'detail': 'Invalid action'}, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, playlist_id):
"""Delete playlist"""
playlist = get_object_or_404(Playlist, playlist_id=playlist_id, owner=request.user)
playlist.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View file

@ -0,0 +1,207 @@
"""Views for playlist downloads"""
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404
from playlist.models import Playlist
from playlist.models_download import PlaylistDownload, PlaylistDownloadItem
from playlist.serializers_download import (
PlaylistDownloadSerializer,
PlaylistDownloadCreateSerializer,
PlaylistDownloadItemSerializer,
)
from playlist.tasks_download import (
download_playlist_task,
pause_playlist_download,
resume_playlist_download,
cancel_playlist_download,
retry_failed_items,
)
from common.permissions import IsOwnerOrAdmin
class PlaylistDownloadViewSet(viewsets.ModelViewSet):
"""ViewSet for managing playlist downloads"""
permission_classes = [IsAuthenticated, IsOwnerOrAdmin]
def get_serializer_class(self):
if self.action == 'create':
return PlaylistDownloadCreateSerializer
return PlaylistDownloadSerializer
def get_queryset(self):
"""Filter by user"""
queryset = PlaylistDownload.objects.select_related(
'playlist', 'user'
).prefetch_related('items')
# Regular users see only their downloads
if not (self.request.user.is_admin or self.request.user.is_superuser):
queryset = queryset.filter(user=self.request.user)
# Filter by status
status_filter = self.request.query_params.get('status')
if status_filter:
queryset = queryset.filter(status=status_filter)
# Filter by playlist
playlist_id = self.request.query_params.get('playlist_id')
if playlist_id:
queryset = queryset.filter(playlist_id=playlist_id)
return queryset.order_by('-created_at')
def perform_create(self, serializer):
"""Create download and trigger task"""
download = serializer.save(user=self.request.user)
# Trigger download task
download_playlist_task.apply_async(args=[download.id])
return download
@action(detail=True, methods=['post'])
def pause(self, request, pk=None):
"""Pause playlist download"""
download = self.get_object()
if download.status != 'downloading':
return Response(
{'error': 'Can only pause downloading playlists'},
status=status.HTTP_400_BAD_REQUEST
)
result = pause_playlist_download.apply_async(args=[download.id])
return Response({
'message': 'Playlist download paused',
'task_id': result.id
})
@action(detail=True, methods=['post'])
def resume(self, request, pk=None):
"""Resume paused playlist download"""
download = self.get_object()
if not download.can_resume:
return Response(
{'error': 'Download cannot be resumed'},
status=status.HTTP_400_BAD_REQUEST
)
result = resume_playlist_download.apply_async(args=[download.id])
return Response({
'message': 'Playlist download resumed',
'task_id': result.id
})
@action(detail=True, methods=['post'])
def cancel(self, request, pk=None):
"""Cancel playlist download"""
download = self.get_object()
if download.status in ['completed', 'failed']:
return Response(
{'error': 'Cannot cancel completed or failed download'},
status=status.HTTP_400_BAD_REQUEST
)
result = cancel_playlist_download.apply_async(args=[download.id])
return Response({
'message': 'Playlist download cancelled',
'task_id': result.id
})
@action(detail=True, methods=['post'])
def retry_failed(self, request, pk=None):
"""Retry failed items"""
download = self.get_object()
if download.failed_items == 0:
return Response(
{'error': 'No failed items to retry'},
status=status.HTTP_400_BAD_REQUEST
)
result = retry_failed_items.apply_async(args=[download.id])
return Response({
'message': f'Retrying {download.failed_items} failed items',
'task_id': result.id
})
@action(detail=True, methods=['get'])
def items(self, request, pk=None):
"""Get download items with status"""
download = self.get_object()
items = download.items.select_related('audio').order_by('position')
serializer = PlaylistDownloadItemSerializer(items, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def active(self, request):
"""Get active downloads (pending or downloading)"""
downloads = self.get_queryset().filter(
status__in=['pending', 'downloading']
)
serializer = self.get_serializer(downloads, many=True)
return Response(serializer.data)
@action(detail=False, methods=['get'])
def completed(self, request):
"""Get completed downloads"""
downloads = self.get_queryset().filter(status='completed')
serializer = self.get_serializer(downloads, many=True)
return Response(serializer.data)
@action(detail=False, methods=['post'])
def download_playlist(self, request):
"""Quick action to download a playlist"""
playlist_id = request.data.get('playlist_id')
quality = request.data.get('quality', 'medium')
if not playlist_id:
return Response(
{'error': 'playlist_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Get playlist
playlist = get_object_or_404(Playlist, id=playlist_id, owner=request.user)
# Check if already downloading
existing = PlaylistDownload.objects.filter(
playlist=playlist,
user=request.user,
status__in=['pending', 'downloading']
).first()
if existing:
return Response(
{
'error': 'Playlist is already being downloaded',
'download': PlaylistDownloadSerializer(existing).data
},
status=status.HTTP_400_BAD_REQUEST
)
# Create download
download = PlaylistDownload.objects.create(
playlist=playlist,
user=request.user,
quality=quality
)
# Trigger task
download_playlist_task.apply_async(args=[download.id])
serializer = PlaylistDownloadSerializer(download)
return Response(serializer.data, status=status.HTTP_201_CREATED)

20
backend/requirements.txt Normal file
View file

@ -0,0 +1,20 @@
Django>=4.2,<5.0
djangorestframework>=3.14.0
django-cors-headers>=4.0.0
celery>=5.3.0
redis>=4.5.0
elasticsearch>=8.8.0
yt-dlp>=2023.11.0
Pillow>=10.0.0
python-dateutil>=2.8.2
pytz>=2023.3
drf-spectacular>=0.26.0
django-celery-beat>=2.5.0
requests>=2.31.0
pyotp>=2.9.0
qrcode>=7.4.0
reportlab>=4.0.0
mutagen>=1.47.0
pylast>=5.2.0
psutil>=5.9.0
whitenoise>=6.5.0

View file

5
backend/stats/admin.py Normal file
View file

@ -0,0 +1,5 @@
"""Stats admin"""
from django.contrib import admin
# No models to register for stats

View file

5
backend/stats/models.py Normal file
View file

@ -0,0 +1,5 @@
"""Stats models"""
from django.db import models
# Stats are calculated from aggregations, no models needed

View file

@ -0,0 +1,24 @@
"""Stats serializers"""
from rest_framework import serializers
class AudioStatsSerializer(serializers.Serializer):
"""Audio statistics"""
total_count = serializers.IntegerField()
total_duration = serializers.IntegerField(help_text="Total duration in seconds")
total_size = serializers.IntegerField(help_text="Total size in bytes")
total_plays = serializers.IntegerField()
class ChannelStatsSerializer(serializers.Serializer):
"""Channel statistics"""
total_channels = serializers.IntegerField()
subscribed_channels = serializers.IntegerField()
class DownloadStatsSerializer(serializers.Serializer):
"""Download statistics"""
pending = serializers.IntegerField()
completed = serializers.IntegerField()
failed = serializers.IntegerField()

10
backend/stats/urls.py Normal file
View file

@ -0,0 +1,10 @@
"""Stats URL patterns"""
from django.urls import path
from stats.views import AudioStatsView, ChannelStatsView, DownloadStatsView
urlpatterns = [
path('audio/', AudioStatsView.as_view(), name='audio-stats'),
path('channel/', ChannelStatsView.as_view(), name='channel-stats'),
path('download/', DownloadStatsView.as_view(), name='download-stats'),
]

61
backend/stats/views.py Normal file
View file

@ -0,0 +1,61 @@
"""Stats API views"""
from django.db.models import Sum, Count
from rest_framework.response import Response
from audio.models import Audio
from channel.models import Channel
from download.models import DownloadQueue
from stats.serializers import (
AudioStatsSerializer,
ChannelStatsSerializer,
DownloadStatsSerializer,
)
from common.views import ApiBaseView
class AudioStatsView(ApiBaseView):
"""Audio statistics endpoint"""
def get(self, request):
"""Get audio statistics"""
stats = Audio.objects.aggregate(
total_count=Count('id'),
total_duration=Sum('duration'),
total_size=Sum('file_size'),
total_plays=Sum('play_count'),
)
# Handle None values
stats = {k: v or 0 for k, v in stats.items()}
serializer = AudioStatsSerializer(stats)
return Response(serializer.data)
class ChannelStatsView(ApiBaseView):
"""Channel statistics endpoint"""
def get(self, request):
"""Get channel statistics"""
stats = {
'total_channels': Channel.objects.count(),
'subscribed_channels': Channel.objects.filter(subscribed=True).count(),
}
serializer = ChannelStatsSerializer(stats)
return Response(serializer.data)
class DownloadStatsView(ApiBaseView):
"""Download statistics endpoint"""
def get(self, request):
"""Get download statistics"""
stats = {
'pending': DownloadQueue.objects.filter(status='pending').count(),
'completed': DownloadQueue.objects.filter(status='completed').count(),
'failed': DownloadQueue.objects.filter(status='failed').count(),
}
serializer = DownloadStatsSerializer(stats)
return Response(serializer.data)

0
backend/task/__init__.py Normal file
View file

5
backend/task/admin.py Normal file
View file

@ -0,0 +1,5 @@
"""Task admin - tasks are managed through Celery"""
from django.contrib import admin
# No models to register for task app

View file

7
backend/task/models.py Normal file
View file

@ -0,0 +1,7 @@
"""Task models"""
from django.db import models
# Task models can use Celery's built-in result backend
# No custom models needed for basic task tracking

View file

@ -0,0 +1,18 @@
"""Task serializers"""
from rest_framework import serializers
class TaskSerializer(serializers.Serializer):
"""Task status serializer"""
task_id = serializers.CharField()
task_name = serializers.CharField()
status = serializers.CharField()
result = serializers.JSONField(required=False)
date_done = serializers.DateTimeField(required=False)
class TaskCreateSerializer(serializers.Serializer):
"""Create task serializer"""
task_name = serializers.CharField()
params = serializers.DictField(required=False, default=dict)

507
backend/task/tasks.py Normal file
View file

@ -0,0 +1,507 @@
"""Celery tasks for background processing"""
from celery import shared_task
import yt_dlp
from audio.models import Audio
from channel.models import Channel
from download.models import DownloadQueue
from datetime import datetime
from django.utils import timezone
import os
@shared_task
def download_audio_task(queue_id):
"""Download audio from YouTube - AUDIO ONLY, no video"""
try:
queue_item = DownloadQueue.objects.get(id=queue_id)
queue_item.status = 'downloading'
queue_item.started_date = timezone.now()
queue_item.save()
# yt-dlp options for AUDIO ONLY (no video)
ydl_opts = {
'format': 'bestaudio/best', # Best audio quality, no video
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'm4a',
'preferredquality': '192',
}],
'outtmpl': '/app/audio/%(channel)s/%(title)s-%(id)s.%(ext)s',
'quiet': True,
'no_warnings': True,
'extract_audio': True, # Ensure audio extraction
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(queue_item.url, download=True)
# Get the actual downloaded filename from yt-dlp
# After post-processing with FFmpegExtractAudio, the extension will be .m4a
# We need to use prepare_filename and replace the extension
actual_filename = ydl.prepare_filename(info)
# Replace extension with .m4a since we're extracting audio
import os as os_module
base_filename = os_module.path.splitext(actual_filename)[0]
actual_filename = base_filename + '.m4a'
# Remove /app/audio/ prefix to get relative path
if actual_filename.startswith('/app/audio/'):
file_path = actual_filename[11:] # Remove '/app/audio/' prefix
else:
# Fallback to constructed path if prepare_filename doesn't work as expected
file_path = f"{info.get('channel', 'unknown')}/{info.get('title', 'unknown')}-{info['id']}.m4a"
# Create Audio object
audio, created = Audio.objects.get_or_create(
owner=queue_item.owner,
youtube_id=info['id'],
defaults={
'title': info.get('title', 'Unknown'),
'description': info.get('description', ''),
'channel_id': info.get('channel_id', ''),
'channel_name': info.get('channel', 'Unknown'),
'duration': info.get('duration', 0),
'file_path': file_path,
'file_size': info.get('filesize', 0) or 0,
'thumbnail_url': info.get('thumbnail', ''),
'published_date': datetime.strptime(info.get('upload_date', '20230101'), '%Y%m%d'),
'view_count': info.get('view_count', 0) or 0,
'like_count': info.get('like_count', 0) or 0,
}
)
# Queue a task to link this audio to playlists (optimized - runs after download)
# This prevents blocking the download task with expensive playlist lookups
link_audio_to_playlists.delay(audio.id, queue_item.owner.id)
queue_item.status = 'completed'
queue_item.completed_date = timezone.now()
queue_item.youtube_id = info['id']
queue_item.title = info.get('title', '')
queue_item.save()
return f"Downloaded: {info.get('title', 'Unknown')}"
except Exception as e:
queue_item.status = 'failed'
queue_item.error_message = str(e)
queue_item.save()
raise
@shared_task
def download_channel_task(channel_id):
"""Smart sync: Download only NEW audio from channel (not already downloaded)"""
try:
channel = Channel.objects.get(id=channel_id)
channel.sync_status = 'syncing'
channel.error_message = ''
channel.save()
url = f"https://www.youtube.com/channel/{channel.channel_id}/videos"
# Extract flat to get list quickly
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': True,
'playlistend': 50, # Limit to last 50 videos per sync
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if not info or 'entries' not in info:
channel.sync_status = 'failed'
channel.error_message = 'Failed to fetch channel videos'
channel.save()
return f"Failed to fetch channel videos"
# Get list of already downloaded video IDs
existing_ids = set(Audio.objects.filter(
owner=channel.owner
).values_list('youtube_id', flat=True))
# Queue only NEW videos
new_videos = 0
skipped = 0
for entry in info['entries']:
if not entry:
continue
video_id = entry.get('id')
if not video_id:
continue
# SMART SYNC: Skip if already downloaded
if video_id in existing_ids:
skipped += 1
continue
# This is NEW content
queue_item, created = DownloadQueue.objects.get_or_create(
owner=channel.owner,
url=f"https://www.youtube.com/watch?v={video_id}",
defaults={
'youtube_id': video_id,
'title': entry.get('title', 'Unknown'),
'status': 'pending',
'auto_start': True
}
)
if created:
new_videos += 1
download_audio_task.delay(queue_item.id)
# Update channel status
channel.sync_status = 'success'
channel.downloaded_count = len(existing_ids)
channel.save()
if new_videos == 0:
return f"Channel '{channel.channel_name}' up to date ({skipped} already downloaded)"
return f"Channel '{channel.channel_name}': {new_videos} new audio(s) queued, {skipped} already downloaded"
except Exception as e:
channel.sync_status = 'failed'
channel.error_message = str(e)
channel.save()
raise
@shared_task(bind=True, name="subscribe_to_playlist")
def subscribe_to_playlist(self, user_id, playlist_url):
"""
TubeArchivist pattern: Subscribe to playlist and trigger audio download
Called from API Creates subscription Downloads audio (not video)
"""
from django.contrib.auth import get_user_model
from playlist.models import Playlist
from common.src.youtube_metadata import get_playlist_metadata
import re
User = get_user_model()
user = User.objects.get(id=user_id)
# Extract playlist ID from URL
patterns = [
r'[?&]list=([a-zA-Z0-9_-]+)',
r'playlist\?list=([a-zA-Z0-9_-]+)',
]
playlist_id = None
for pattern in patterns:
match = re.search(pattern, playlist_url)
if match:
playlist_id = match.group(1)
break
if not playlist_id and len(playlist_url) >= 13 and playlist_url.startswith(('PL', 'UU', 'LL', 'RD')):
playlist_id = playlist_url
if not playlist_id:
raise ValueError("Invalid playlist URL")
# Check if already subscribed
if Playlist.objects.filter(owner=user, playlist_id=playlist_id).exists():
return f"Already subscribed to playlist {playlist_id}"
# Fetch metadata
metadata = get_playlist_metadata(playlist_id)
if not metadata:
raise ValueError("Failed to fetch playlist metadata")
# Create subscription
playlist = Playlist.objects.create(
owner=user,
playlist_id=playlist_id,
title=metadata['title'],
description=metadata['description'],
channel_name=metadata['channel_name'],
channel_id=metadata['channel_id'],
thumbnail_url=metadata['thumbnail_url'],
item_count=metadata['item_count'],
playlist_type='youtube',
subscribed=True,
auto_download=True,
sync_status='pending',
)
# Trigger audio download task
download_playlist_task.delay(playlist.id)
return f"Subscribed to playlist: {metadata['title']}"
@shared_task(bind=True, name="subscribe_to_channel")
def subscribe_to_channel(self, user_id, channel_url):
"""
TubeArchivist pattern: Subscribe to channel and trigger audio download
Called from API Creates subscription Downloads audio (not video)
"""
from django.contrib.auth import get_user_model
from channel.models import Channel
from common.src.youtube_metadata import get_channel_metadata
import re
User = get_user_model()
user = User.objects.get(id=user_id)
# Extract channel ID from URL
patterns = [
r'youtube\.com/channel/(UC[\w-]+)',
r'youtube\.com/@([\w-]+)',
r'youtube\.com/c/([\w-]+)',
r'youtube\.com/user/([\w-]+)',
]
channel_id = None
for pattern in patterns:
match = re.search(pattern, channel_url)
if match:
channel_id = match.group(1)
break
if not channel_id and channel_url.startswith('UC') and len(channel_url) == 24:
channel_id = channel_url
if not channel_id:
channel_id = channel_url # Try as-is
# Fetch metadata (this resolves handles to actual channel IDs)
metadata = get_channel_metadata(channel_id)
if not metadata:
raise ValueError("Failed to fetch channel metadata")
actual_channel_id = metadata['channel_id']
# Check if already subscribed
if Channel.objects.filter(owner=user, channel_id=actual_channel_id).exists():
return f"Already subscribed to channel {actual_channel_id}"
# Create subscription
channel = Channel.objects.create(
owner=user,
channel_id=actual_channel_id,
channel_name=metadata['channel_name'],
channel_description=metadata['channel_description'],
channel_thumbnail=metadata['channel_thumbnail'],
subscriber_count=metadata['subscriber_count'],
video_count=metadata['video_count'],
subscribed=True,
auto_download=True,
sync_status='pending',
)
# Trigger audio download task
download_channel_task.delay(channel.id)
return f"Subscribed to channel: {metadata['channel_name']}"
@shared_task(name="update_subscriptions")
def update_subscriptions_task():
"""
TubeArchivist pattern: Periodic task to check ALL subscriptions for NEW audio
Runs every 2 hours via Celery Beat
"""
from playlist.models import Playlist
# Sync all subscribed playlists
playlists = Playlist.objects.filter(subscribed=True, auto_download=True)
for playlist in playlists:
download_playlist_task.delay(playlist.id)
# Sync all subscribed channels
channels = Channel.objects.filter(subscribed=True, auto_download=True)
for channel in channels:
download_channel_task.delay(channel.id)
return f"Syncing {playlists.count()} playlists and {channels.count()} channels"
@shared_task
def download_playlist_task(playlist_id):
"""Smart sync: Download only NEW audio from playlist (not already downloaded)"""
from playlist.models import Playlist, PlaylistItem
try:
playlist = Playlist.objects.get(id=playlist_id)
playlist.sync_status = 'syncing'
playlist.error_message = ''
playlist.save()
url = f"https://www.youtube.com/playlist?list={playlist.playlist_id}"
# Extract flat to get list quickly without downloading
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=False)
if not info or 'entries' not in info:
playlist.sync_status = 'failed'
playlist.error_message = 'Failed to fetch playlist items'
playlist.save()
return f"Failed to fetch playlist items"
# Update item count
total_items = len([e for e in info['entries'] if e])
playlist.item_count = total_items
# Get list of already downloaded video IDs
existing_ids = set(Audio.objects.filter(
owner=playlist.owner
).values_list('youtube_id', flat=True))
# Queue only NEW videos (not already downloaded)
new_videos = 0
skipped = 0
for idx, entry in enumerate(info['entries']):
if not entry:
continue
video_id = entry.get('id')
if not video_id:
continue
# Check if audio already exists
audio_obj = Audio.objects.filter(
owner=playlist.owner,
youtube_id=video_id
).first()
# Create PlaylistItem if audio exists but not in playlist yet
if audio_obj:
PlaylistItem.objects.get_or_create(
playlist=playlist,
audio=audio_obj,
defaults={'position': idx}
)
skipped += 1
continue
# This is NEW content - add to download queue
queue_item, created = DownloadQueue.objects.get_or_create(
owner=playlist.owner,
url=f"https://www.youtube.com/watch?v={video_id}",
defaults={
'youtube_id': video_id,
'title': entry.get('title', 'Unknown'),
'status': 'pending',
'auto_start': True
}
)
if created:
new_videos += 1
# Trigger download task for NEW video
download_audio_task.delay(queue_item.id)
# Create PlaylistItem for the downloaded audio (will be created after download completes)
# Note: Audio object might not exist yet, so we'll add a post-download hook
# Update playlist status
playlist.sync_status = 'success'
playlist.last_refresh = timezone.now()
# Count only audios from THIS playlist (match by checking all video IDs in playlist)
all_playlist_video_ids = [e.get('id') for e in info['entries'] if e and e.get('id')]
playlist.downloaded_count = Audio.objects.filter(
owner=playlist.owner,
youtube_id__in=all_playlist_video_ids
).count()
playlist.save()
if new_videos == 0:
return f"Playlist '{playlist.title}' up to date ({skipped} already downloaded)"
return f"Playlist '{playlist.title}': {new_videos} new audio(s) queued, {skipped} already downloaded"
except Exception as e:
playlist.sync_status = 'failed'
playlist.error_message = str(e)
playlist.save()
raise
@shared_task
def link_audio_to_playlists(audio_id, user_id):
"""Link newly downloaded audio to playlists that contain it (optimized)"""
from playlist.models import Playlist, PlaylistItem
from django.contrib.auth import get_user_model
try:
User = get_user_model()
user = User.objects.get(id=user_id)
audio = Audio.objects.get(id=audio_id)
# Get all playlists for this user
playlists = Playlist.objects.filter(owner=user, playlist_type='youtube')
# For each playlist, check if this video is in it
for playlist in playlists:
# Check if already linked
if PlaylistItem.objects.filter(playlist=playlist, audio=audio).exists():
continue
try:
ydl_opts = {
'quiet': True,
'no_warnings': True,
'extract_flat': True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
playlist_info = ydl.extract_info(
f"https://www.youtube.com/playlist?list={playlist.playlist_id}",
download=False
)
if playlist_info and 'entries' in playlist_info:
for idx, entry in enumerate(playlist_info['entries']):
if entry and entry.get('id') == audio.youtube_id:
# Found it! Create the link
PlaylistItem.objects.get_or_create(
playlist=playlist,
audio=audio,
defaults={'position': idx}
)
# Update playlist downloaded count
all_video_ids = [e.get('id') for e in playlist_info['entries'] if e and e.get('id')]
playlist.downloaded_count = Audio.objects.filter(
owner=user,
youtube_id__in=all_video_ids
).count()
playlist.save(update_fields=['downloaded_count'])
break
except Exception as e:
# Don't fail if playlist linking fails
pass
return f"Linked audio {audio.youtube_id} to playlists"
except Exception as e:
# Don't fail - this is a best-effort operation
return f"Failed to link audio: {str(e)}"
@shared_task
def cleanup_task():
"""Cleanup old download queue items"""
# Remove completed items older than 7 days
from datetime import timedelta
cutoff_date = timezone.now() - timedelta(days=7)
deleted = DownloadQueue.objects.filter(
status='completed',
completed_date__lt=cutoff_date
).delete()
return f"Cleaned up {deleted[0]} items"

10
backend/task/urls.py Normal file
View file

@ -0,0 +1,10 @@
"""Task URL patterns"""
from django.urls import path
from task.views import TaskListView, TaskCreateView, TaskDetailView
urlpatterns = [
path('', TaskListView.as_view(), name='task-list'),
path('create/', TaskCreateView.as_view(), name='task-create'),
path('<str:task_id>/', TaskDetailView.as_view(), name='task-detail'),
]

53
backend/task/views.py Normal file
View file

@ -0,0 +1,53 @@
"""Task API views"""
from celery.result import AsyncResult
from rest_framework import status
from rest_framework.response import Response
from task.serializers import TaskSerializer, TaskCreateSerializer
from common.views import ApiBaseView, AdminOnly
class TaskListView(ApiBaseView):
"""Task list endpoint"""
permission_classes = [AdminOnly]
def get(self, request):
"""Get list of tasks"""
# TODO: Implement task listing from Celery
return Response({'data': []})
class TaskCreateView(ApiBaseView):
"""Task creation endpoint"""
permission_classes = [AdminOnly]
def post(self, request):
"""Create and run a task"""
serializer = TaskCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
task_name = serializer.validated_data['task_name']
params = serializer.validated_data.get('params', {})
# Map task names to Celery tasks
# TODO: Implement task dispatch
return Response({
'message': 'Task created',
'task_name': task_name
}, status=status.HTTP_202_ACCEPTED)
class TaskDetailView(ApiBaseView):
"""Task detail endpoint"""
permission_classes = [AdminOnly]
def get(self, request, task_id):
"""Get task status"""
result = AsyncResult(task_id)
return Response({
'task_id': task_id,
'status': result.status,
'result': result.result if result.ready() else None
})

View file

@ -0,0 +1,464 @@
# Multi-Tenant Admin System - Implementation Guide
## Overview
This system transforms SoundWave into a multi-tenant platform where:
- **Admins** can manage all users and their content
- **Users** have isolated YouTube accounts, channels, playlists, and audio files
- Each user operates as if they have their own Docker container
- Resource limits (storage, channels, playlists) are enforced per user
## Architecture
### User Isolation Model
```
Admin User (is_admin=True)
├── Can create/manage all users
├── Access all content across users
└── Set resource quotas
Regular User
├── Own YouTube accounts
├── Own channels (subscriptions)
├── Own playlists
├── Own audio files
└── Cannot see other users' data
```
### Database Schema Changes
**Account Model** (`user/models.py`):
```python
- storage_quota_gb: int (default 50 GB)
- storage_used_gb: float (tracked automatically)
- max_channels: int (default 50)
- max_playlists: int (default 100)
- user_notes: text (admin notes)
- created_by: ForeignKey to admin who created user
```
**UserYouTubeAccount Model** (NEW):
```python
- user: ForeignKey to Account
- account_name: str (friendly name)
- youtube_channel_id: str
- youtube_channel_name: str
- cookies_file: text (for authentication)
- auto_download: bool
- download_quality: choices
```
**Channel Model** (UPDATED):
```python
+ owner: ForeignKey to Account
+ youtube_account: ForeignKey to UserYouTubeAccount
+ auto_download: bool per channel
+ download_quality: choices per channel
```
**Audio Model** (UPDATED):
```python
+ owner: ForeignKey to Account
```
**Playlist Model** (UPDATED):
```python
+ owner: ForeignKey to Account
+ auto_download: bool per playlist
```
### Unique Constraints
- **Channel**: `(owner, channel_id)` - Each user can subscribe once per channel
- **Audio**: `(owner, youtube_id)` - Each user can have one copy of each video
- **Playlist**: `(owner, playlist_id)` - Each user can subscribe once per playlist
## Backend Implementation
### Middleware (`config/middleware.py`)
**UserIsolationMiddleware**:
- Adds `request.filter_by_user()` helper
- Automatically filters querysets by owner
- Admins bypass filtering
**StorageQuotaMiddleware**:
- Tracks storage usage
- Prevents uploads when quota exceeded
### Permissions (`common/permissions.py`)
**IsOwnerOrAdmin**:
- Users can only access their own objects
- Admins can access everything
**CanManageUsers**:
- Only admins can manage users
**WithinQuotaLimits**:
- Checks storage/channel/playlist quotas
- Admins bypass quota checks
### Admin API (`user/views_admin.py`)
**UserManagementViewSet**:
```python
GET /api/user/admin/users/ # List users
POST /api/user/admin/users/ # Create user
GET /api/user/admin/users/{id}/ # User details
PATCH /api/user/admin/users/{id}/ # Update user
GET /api/user/admin/users/{id}/stats/ # User statistics
POST /api/user/admin/users/{id}/reset_storage/
POST /api/user/admin/users/{id}/reset_2fa/
POST /api/user/admin/users/{id}/toggle_active/
GET /api/user/admin/users/{id}/channels/
GET /api/user/admin/users/{id}/playlists/
GET /api/user/admin/users/system_stats/ # System-wide stats
```
**UserYouTubeAccountViewSet**:
```python
GET /api/user/admin/youtube-accounts/ # List accounts
POST /api/user/admin/youtube-accounts/ # Add account
GET /api/user/admin/youtube-accounts/{id}/ # Account details
PATCH /api/user/admin/youtube-accounts/{id}/ # Update account
DELETE /api/user/admin/youtube-accounts/{id}/ # Delete account
POST /api/user/admin/youtube-accounts/{id}/verify/ # Verify credentials
POST /api/user/admin/youtube-accounts/{id}/toggle_active/
```
### Django Admin (`user/admin_users.py`)
Enhanced admin interface with:
- User list with storage/channel/playlist counts
- Visual storage progress bars
- Bulk actions (reset storage, disable users, reset 2FA)
- YouTube account management
- Per-user notes
## Frontend Implementation
### AdminUsersPage Component
**Features**:
- System statistics dashboard (users, content, storage)
- Users table with status, storage, content counts
- Create user dialog with full settings
- Edit user dialog with quota management
- User details modal with comprehensive info
- Quick actions (activate/deactivate, reset storage, reset 2FA)
**UI Components**:
```tsx
- System stats cards (users, content, storage)
- Users table (sortable, filterable)
- Create user form (username, email, password, quotas)
- Edit user form (quotas, status, permissions)
- User details modal (all stats and metadata)
- Actions menu (edit, toggle, reset)
```
## Migration Strategy
### Step 1: Run Migrations
```bash
# Create migrations
python manage.py makemigrations user channel audio playlist
# Apply migrations
python manage.py migrate
# Create superuser
python manage.py createsuperuser
```
### Step 2: Data Migration
For existing data, create a data migration to set owner fields:
```python
# Create empty migration
python manage.py makemigrations --empty user --name set_default_owner
# Edit migration file
def set_default_owner(apps, schema_editor):
Account = apps.get_model('user', 'Account')
Channel = apps.get_model('channel', 'Channel')
Audio = apps.get_model('audio', 'Audio')
Playlist = apps.get_model('playlist', 'Playlist')
# Get or create default admin user
admin = Account.objects.filter(is_superuser=True).first()
if not admin:
admin = Account.objects.create_superuser(
username='admin',
email='admin@example.com',
password='changeme'
)
# Assign owner to existing records
Channel.objects.filter(owner__isnull=True).update(owner=admin)
Audio.objects.filter(owner__isnull=True).update(owner=admin)
Playlist.objects.filter(owner__isnull=True).update(owner=admin)
```
### Step 3: Update Views
Update existing views to use owner filtering:
```python
# Before
Audio.objects.all()
# After
Audio.objects.filter(owner=request.user)
# or use middleware
request.filter_by_user(Audio.objects.all())
```
### Step 4: Update Serializers
Ensure owner is set on create:
```python
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
```
## Usage Examples
### Admin Creating User
```bash
POST /api/user/admin/users/
{
"username": "john_doe",
"email": "john@example.com",
"password": "secure123",
"password_confirm": "secure123",
"storage_quota_gb": 100,
"max_channels": 75,
"max_playlists": 150,
"is_admin": false,
"is_active": true,
"user_notes": "Premium user - increased quotas"
}
```
### User Adding YouTube Account
```bash
POST /api/user/admin/youtube-accounts/
{
"account_name": "Personal YouTube",
"youtube_channel_id": "UCxxxxxxxx",
"youtube_channel_name": "John's Channel",
"cookies_file": "# Netscape HTTP Cookie File...",
"auto_download": true,
"download_quality": "high"
}
```
### User Subscribing to Channel
```bash
POST /api/channels/
{
"channel_id": "UCxxxxxxxx",
"channel_name": "Tech Channel",
"youtube_account": 1, # User's YouTube account ID
"subscribed": true,
"auto_download": true,
"download_quality": "auto"
}
```
## Resource Quota Enforcement
### Storage Quota
```python
# Checked before download
if user.storage_used_gb >= user.storage_quota_gb:
raise PermissionDenied("Storage quota exceeded")
# Updated after download
file_size_gb = file_size_bytes / (1024**3)
user.storage_used_gb += file_size_gb
user.save()
# Updated after deletion
user.storage_used_gb -= file_size_gb
user.save()
```
### Channel Limit
```python
# Checked before subscribing
if not user.can_add_channel:
raise PermissionDenied(f"Channel limit reached ({user.max_channels})")
# Property in Account model
@property
def can_add_channel(self):
current_count = self.channels.count()
return current_count < self.max_channels
```
### Playlist Limit
```python
# Checked before creating
if not user.can_add_playlist:
raise PermissionDenied(f"Playlist limit reached ({user.max_playlists})")
# Property in Account model
@property
def can_add_playlist(self):
current_count = self.playlists.count()
return current_count < self.max_playlists
```
## Security Considerations
### Data Isolation
1. **Queryset Filtering**: All queries automatically filtered by owner
2. **Middleware**: UserIsolationMiddleware enforces filtering
3. **Permissions**: IsOwnerOrAdmin checks object-level permissions
4. **Admin Bypass**: Admins can access all data for management
### Authentication
1. **User Authentication**: Standard Django auth with 2FA support
2. **YouTube Authentication**: Cookie-based (stored per user)
3. **API Authentication**: Token-based with user context
### File Storage
User files should be stored in isolated directories:
```python
# File path structure
/media/
└── users/
├── user_1/
│ ├── audio/
│ ├── thumbnails/
│ └── cookies/
├── user_2/
│ ├── audio/
│ ├── thumbnails/
│ └── cookies/
└── ...
```
## Celery Tasks
Update tasks to respect user isolation:
```python
@shared_task
def download_audio(audio_id, user_id):
audio = Audio.objects.get(id=audio_id, owner_id=user_id)
user = audio.owner
# Use user's YouTube account
youtube_account = audio.channel.youtube_account
cookies_file = youtube_account.cookies_file if youtube_account else None
# Download to user's directory
output_path = f'/media/users/user_{user_id}/audio/'
# Check quota before download
if user.storage_used_gb >= user.storage_quota_gb:
raise Exception("Storage quota exceeded")
# Download...
# Update storage
user.storage_used_gb += file_size_gb
user.save()
```
## Testing
### Test User Isolation
```python
def test_user_cannot_access_other_user_data():
user1 = Account.objects.create_user('user1', 'user1@test.com', 'pass')
user2 = Account.objects.create_user('user2', 'user2@test.com', 'pass')
audio1 = Audio.objects.create(owner=user1, youtube_id='xxx')
audio2 = Audio.objects.create(owner=user2, youtube_id='yyy')
# User1 should only see their audio
assert Audio.objects.filter(owner=user1).count() == 1
assert Audio.objects.filter(owner=user2).count() == 1
```
### Test Quota Enforcement
```python
def test_storage_quota_enforced():
user = Account.objects.create_user(
'user', 'user@test.com', 'pass',
storage_quota_gb=10,
storage_used_gb=10
)
# Should fail when quota exceeded
with pytest.raises(PermissionDenied):
download_audio(audio_id, user.id)
```
## Performance Optimization
### Database Indexes
```python
class Meta:
indexes = [
models.Index(fields=['owner', 'youtube_id']),
models.Index(fields=['owner', 'channel_id']),
models.Index(fields=['owner', '-published_date']),
]
```
### Query Optimization
```python
# Use select_related for foreign keys
Audio.objects.filter(owner=user).select_related('owner')
# Use prefetch_related for reverse relations
User.objects.prefetch_related('channels', 'playlists', 'audio_files')
```
### Caching
```python
# Cache user stats
cache_key = f'user_stats_{user.id}'
stats = cache.get(cache_key)
if not stats:
stats = calculate_user_stats(user)
cache.set(cache_key, stats, 300) # 5 minutes
```
## Future Enhancements
- [ ] User groups and team accounts
- [ ] Shared playlists between users
- [ ] Storage pooling for organizations
- [ ] Usage analytics per user
- [ ] API rate limiting per user
- [ ] Custom branding per user
- [ ] Billing and subscription management
- [ ] OAuth integration for YouTube
- [ ] Automated quota adjustment based on usage
- [ ] User data export/import

View file

@ -0,0 +1,239 @@
# User Registration Policy
## Public Registration Status: DISABLED ❌
Public user registration is **disabled** in SoundWave. This is a security feature for multi-tenant deployments.
## User Creation
### Admin-Only User Creation
Only administrators can create new user accounts through:
1. **Django Admin Panel**:
```
http://localhost:8888/admin/user/account/add/
```
2. **REST API** (Admin only):
```bash
POST /api/user/admin/users/
{
"username": "newuser",
"email": "user@example.com",
"password": "SecurePass123",
"password_confirm": "SecurePass123",
"storage_quota_gb": 50,
"max_channels": 50,
"max_playlists": 100,
"is_admin": false,
"is_active": true
}
```
3. **Frontend Admin Panel**:
- Navigate to Admin Users page
- Click "Create User" button
- Fill in user details and resource quotas
### Django Management Command
Admins can also use Django management commands:
```bash
# Create regular user
python manage.py createsuperuser
# Or use shell
python manage.py shell
>>> from user.models import Account
>>> user = Account.objects.create_user(
... username='john_doe',
... email='john@example.com',
... password='SecurePass123'
... )
>>> user.storage_quota_gb = 100
>>> user.max_channels = 75
>>> user.save()
```
## Attempted Public Registration
If someone attempts to access the registration endpoint:
**Request**:
```bash
POST /api/user/register/
{
"username": "newuser",
"email": "user@example.com",
"password": "password123"
}
```
**Response** (403 Forbidden):
```json
{
"error": "Public registration is disabled",
"message": "New users can only be created by administrators. Please contact your system administrator for account creation."
}
```
## Configuration
Registration policy is controlled in `config/user_settings.py`:
```python
# Public registration disabled - only admins can create users
ALLOW_PUBLIC_REGISTRATION = False
```
### To Enable Public Registration (Not Recommended)
If you need to enable public registration for testing or specific use cases:
1. Edit `config/user_settings.py`:
```python
ALLOW_PUBLIC_REGISTRATION = True
```
2. Implement registration logic in `user/views.py` RegisterView
3. Add frontend registration form (not included by default)
**⚠️ Warning**: Enabling public registration bypasses the multi-tenant security model and allows anyone to create accounts.
## Security Benefits
### Why Registration is Disabled
1. **Resource Control**: Admins control who gets accounts and resource quotas
2. **Quality Control**: Prevents spam accounts and abuse
3. **Multi-Tenancy**: Each user is a "tenant" with isolated data
4. **Storage Management**: Admins allocate storage based on needs
5. **Compliance**: Controlled user base for compliance requirements
6. **Billing**: Users can be tied to billing/subscription models
### Admin Capabilities
Admins have full control over:
- User creation and deletion
- Resource quotas (storage, channels, playlists)
- Account activation/deactivation
- 2FA reset
- Storage usage monitoring
- User permissions (admin/regular)
## User Onboarding Flow
### Recommended Process
1. **Request**: User requests account via email/form
2. **Admin Review**: Admin reviews request
3. **Account Creation**: Admin creates account with appropriate quotas
4. **Credentials**: Admin sends credentials to user securely
5. **First Login**: User logs in and changes password
6. **2FA Setup**: User sets up 2FA (recommended)
### Example Onboarding Email
```
Welcome to SoundWave!
Your account has been created:
- Username: john_doe
- Temporary Password: [generated_password]
Storage Quota: 50 GB
Max Channels: 50
Max Playlists: 100
Please login and change your password immediately:
http://soundwave.example.com/
For security, we recommend enabling 2FA in Settings.
Questions? Contact: admin@example.com
```
## API Endpoints
### Public Endpoints (No Auth Required)
- `POST /api/user/login/` - User login
- `POST /api/user/register/` - Returns 403 (disabled)
### Authenticated Endpoints
- `GET /api/user/account/` - Get current user
- `POST /api/user/logout/` - Logout
- `GET /api/user/config/` - User settings
### Admin-Only Endpoints
- `GET /api/user/admin/users/` - List all users
- `POST /api/user/admin/users/` - Create new user
- `PATCH /api/user/admin/users/{id}/` - Update user
- `POST /api/user/admin/users/{id}/reset_storage/` - Reset storage
- `POST /api/user/admin/users/{id}/toggle_active/` - Activate/deactivate
## Password Requirements
When creating users, passwords must meet these requirements:
```python
PASSWORD_MIN_LENGTH = 8
PASSWORD_REQUIRE_UPPERCASE = True
PASSWORD_REQUIRE_LOWERCASE = True
PASSWORD_REQUIRE_NUMBERS = True
PASSWORD_REQUIRE_SPECIAL = False # Optional
```
Example valid passwords:
- `SecurePass123`
- `MyPassword1`
- `Admin2024Test`
## Future Enhancements
Potential features for user management:
- [ ] Invitation system (admin sends invite links)
- [ ] Approval workflow (users request, admin approves)
- [ ] Self-service password reset
- [ ] Email verification
- [ ] Account expiration dates
- [ ] Welcome email templates
- [ ] User onboarding wizard
- [ ] Bulk user import from CSV
- [ ] SSO/LDAP integration
- [ ] OAuth providers (Google, GitHub)
## Troubleshooting
### "Registration is disabled" error
**Cause**: Public registration is intentionally disabled.
**Solution**: Contact system administrator to create an account.
### Cannot create users
**Cause**: User is not an admin.
**Solution**: Only admin users (`is_admin=True` or `is_superuser=True`) can create users.
### How to create first admin?
```bash
python manage.py createsuperuser
```
This creates the first admin who can then create other users.
## Best Practices
1. **Strong Passwords**: Enforce strong password requirements
2. **Enable 2FA**: Require 2FA for admin accounts
3. **Audit Logs**: Track user creation and modifications
4. **Resource Planning**: Allocate quotas based on user needs
5. **Regular Review**: Periodically review active users
6. **Offboarding**: Deactivate accounts for departed users
7. **Backup**: Regular database backups including user data
8. **Documentation**: Keep user list and quotas documented

0
backend/user/__init__.py Normal file
View file

5
backend/user/admin.py Normal file
View file

@ -0,0 +1,5 @@
"""User admin - Import enhanced admin from admin_users"""
from user.admin_users import AccountAdmin, UserYouTubeAccountAdmin
# Admin classes are registered in admin_users.py

243
backend/user/admin_users.py Normal file
View file

@ -0,0 +1,243 @@
"""Admin interface for user management"""
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.html import format_html
from django.urls import reverse
from django.utils.safestring import mark_safe
from user.models import Account, UserYouTubeAccount
@admin.register(Account)
class AccountAdmin(BaseUserAdmin):
"""Enhanced admin for Account model with user management"""
list_display = [
'username',
'email',
'is_admin',
'is_active',
'storage_usage',
'channel_count',
'playlist_count',
'date_joined',
'last_login',
]
list_filter = [
'is_admin',
'is_active',
'is_staff',
'is_superuser',
'two_factor_enabled',
'date_joined',
]
search_fields = ['username', 'email']
fieldsets = (
('Account Info', {
'fields': ('username', 'email', 'password')
}),
('Permissions', {
'fields': (
'is_active',
'is_staff',
'is_admin',
'is_superuser',
'groups',
'user_permissions',
)
}),
('Resource Limits', {
'fields': (
'storage_quota_gb',
'storage_used_gb',
'max_channels',
'max_playlists',
)
}),
('Security', {
'fields': (
'two_factor_enabled',
'two_factor_secret',
)
}),
('Metadata', {
'fields': (
'user_notes',
'created_by',
'date_joined',
'last_login',
)
}),
)
add_fieldsets = (
('Create New User', {
'classes': ('wide',),
'fields': (
'username',
'email',
'password1',
'password2',
'is_admin',
'is_active',
'storage_quota_gb',
'max_channels',
'max_playlists',
'user_notes',
),
}),
)
readonly_fields = ['date_joined', 'last_login', 'storage_used_gb']
ordering = ['-date_joined']
def storage_usage(self, obj):
"""Display storage usage with progress bar"""
percent = obj.storage_percent_used
if percent > 90:
color = 'red'
elif percent > 75:
color = 'orange'
else:
color = 'green'
return format_html(
'<div style="width:100px; background-color:#f0f0f0; border:1px solid #ccc;">'
'<div style="width:{}%; background-color:{}; height:20px; text-align:center; color:white;">'
'{:.1f}%'
'</div></div>',
min(percent, 100),
color,
percent
)
storage_usage.short_description = 'Storage'
def channel_count(self, obj):
"""Display channel count with limit"""
from channel.models import Channel
count = Channel.objects.filter(owner=obj).count()
return format_html(
'<span style="color: {};">{} / {}</span>',
'red' if count >= obj.max_channels else 'green',
count,
obj.max_channels
)
channel_count.short_description = 'Channels'
def playlist_count(self, obj):
"""Display playlist count with limit"""
from playlist.models import Playlist
count = Playlist.objects.filter(owner=obj).count()
return format_html(
'<span style="color: {};">{} / {}</span>',
'red' if count >= obj.max_playlists else 'green',
count,
obj.max_playlists
)
playlist_count.short_description = 'Playlists'
def save_model(self, request, obj, form, change):
"""Set created_by for new users"""
if not change and request.user.is_authenticated:
obj.created_by = request.user
super().save_model(request, obj, form, change)
actions = [
'reset_storage_quota',
'disable_users',
'enable_users',
'reset_2fa',
]
def reset_storage_quota(self, request, queryset):
"""Reset storage usage to 0"""
count = queryset.update(storage_used_gb=0.0)
self.message_user(request, f'Reset storage for {count} users')
reset_storage_quota.short_description = 'Reset storage usage'
def disable_users(self, request, queryset):
"""Disable selected users"""
count = queryset.update(is_active=False)
self.message_user(request, f'Disabled {count} users')
disable_users.short_description = 'Disable selected users'
def enable_users(self, request, queryset):
"""Enable selected users"""
count = queryset.update(is_active=True)
self.message_user(request, f'Enabled {count} users')
enable_users.short_description = 'Enable selected users'
def reset_2fa(self, request, queryset):
"""Reset 2FA for selected users"""
count = queryset.update(
two_factor_enabled=False,
two_factor_secret='',
backup_codes=[]
)
self.message_user(request, f'Reset 2FA for {count} users')
reset_2fa.short_description = 'Reset 2FA'
@admin.register(UserYouTubeAccount)
class UserYouTubeAccountAdmin(admin.ModelAdmin):
"""Admin for YouTube accounts"""
list_display = [
'user',
'account_name',
'youtube_channel_name',
'is_active',
'auto_download',
'download_quality',
'created_date',
]
list_filter = [
'is_active',
'auto_download',
'download_quality',
'created_date',
]
search_fields = [
'user__username',
'account_name',
'youtube_channel_name',
'youtube_channel_id',
]
fieldsets = (
('Account Info', {
'fields': (
'user',
'account_name',
'youtube_channel_id',
'youtube_channel_name',
)
}),
('Authentication', {
'fields': (
'cookies_file',
'is_active',
'last_verified',
)
}),
('Download Settings', {
'fields': (
'auto_download',
'download_quality',
)
}),
)
readonly_fields = ['created_date', 'last_verified']
def get_queryset(self, request):
"""Filter by user if not admin"""
qs = super().get_queryset(request)
if request.user.is_superuser or request.user.is_admin:
return qs
return qs.filter(user=request.user)

View file

152
backend/user/models.py Normal file
View file

@ -0,0 +1,152 @@
"""User models"""
from django.contrib.auth.models import AbstractUser, BaseUserManager
from django.db import models
class AccountManager(BaseUserManager):
"""Custom user manager"""
def create_user(self, username, email, password=None):
"""Create regular user"""
if not email:
raise ValueError('Users must have an email address')
if not username:
raise ValueError('Users must have a username')
user = self.model(
email=self.normalize_email(email),
username=username,
)
user.set_password(password)
user.save(using=self._db)
return user
def create_superuser(self, username, email, password):
"""Create superuser"""
user = self.create_user(
email=self.normalize_email(email),
password=password,
username=username,
)
user.is_admin = True
user.is_staff = True
user.is_superuser = True
user.save(using=self._db)
return user
class Account(AbstractUser):
"""Custom user model"""
email = models.EmailField(verbose_name="email", max_length=60, unique=True)
username = models.CharField(max_length=30, unique=True)
date_joined = models.DateTimeField(verbose_name='date joined', auto_now_add=True)
last_login = models.DateTimeField(verbose_name='last login', auto_now=True)
is_admin = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False)
# 2FA fields
two_factor_enabled = models.BooleanField(default=False)
two_factor_secret = models.CharField(max_length=32, blank=True, null=True)
backup_codes = models.JSONField(default=list, blank=True)
# User isolation and resource limits
storage_quota_gb = models.IntegerField(default=50, help_text="Storage quota in GB")
storage_used_gb = models.FloatField(default=0.0, help_text="Storage used in GB")
max_channels = models.IntegerField(default=50, help_text="Maximum channels allowed")
max_playlists = models.IntegerField(default=100, help_text="Maximum playlists allowed")
# User metadata
user_notes = models.TextField(blank=True, help_text="Admin notes about this user")
created_by = models.ForeignKey(
'self',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='created_users',
help_text="Admin who created this user"
)
avatar = models.CharField(
max_length=500,
blank=True,
null=True,
help_text="Path to user avatar image or preset avatar number (1-5)"
)
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = ['email']
objects = AccountManager()
def __str__(self):
return self.username
@property
def storage_percent_used(self):
"""Calculate storage usage percentage"""
if self.storage_quota_gb == 0:
return 0
return (self.storage_used_gb / self.storage_quota_gb) * 100
@property
def can_add_channel(self):
"""Check if user can add more channels"""
from channel.models import Channel
current_count = Channel.objects.filter(owner=self).count()
return current_count < self.max_channels
@property
def can_add_playlist(self):
"""Check if user can add more playlists"""
from playlist.models import Playlist
current_count = Playlist.objects.filter(owner=self).count()
return current_count < self.max_playlists
def calculate_storage_usage(self):
"""Calculate and update actual storage usage from audio files"""
from audio.models import Audio
from django.db.models import Sum
total_bytes = Audio.objects.filter(owner=self).aggregate(
total=Sum('file_size')
)['total'] or 0
# Convert bytes to GB
self.storage_used_gb = round(total_bytes / (1024 ** 3), 2)
self.save(update_fields=['storage_used_gb'])
return self.storage_used_gb
class UserYouTubeAccount(models.Model):
"""User's YouTube account credentials and settings"""
user = models.ForeignKey(Account, on_delete=models.CASCADE, related_name='youtube_accounts')
account_name = models.CharField(max_length=200, help_text="Friendly name for this YouTube account")
# YouTube authentication (for future OAuth integration)
youtube_channel_id = models.CharField(max_length=50, blank=True)
youtube_channel_name = models.CharField(max_length=200, blank=True)
# Cookie-based authentication (current method)
cookies_file = models.TextField(blank=True, help_text="YouTube cookies for authenticated downloads")
# Account status
is_active = models.BooleanField(default=True)
last_verified = models.DateTimeField(null=True, blank=True)
created_date = models.DateTimeField(auto_now_add=True)
# Download preferences
auto_download = models.BooleanField(default=True, help_text="Automatically download new videos")
download_quality = models.CharField(
max_length=20,
default='medium',
choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('ultra', 'Ultra')]
)
class Meta:
ordering = ['-created_date']
unique_together = ('user', 'account_name')
def __str__(self):
return f"{self.user.username} - {self.account_name}"

View file

@ -0,0 +1,71 @@
"""User serializers"""
from rest_framework import serializers
from user.models import Account
class AccountSerializer(serializers.ModelSerializer):
"""Account serializer"""
avatar_url = serializers.SerializerMethodField()
class Meta:
model = Account
fields = [
'id', 'username', 'email', 'date_joined', 'last_login',
'two_factor_enabled', 'avatar', 'avatar_url',
'is_admin', 'is_superuser', 'is_staff',
'storage_quota_gb', 'storage_used_gb',
'max_channels', 'max_playlists'
]
read_only_fields = [
'id', 'date_joined', 'last_login', 'two_factor_enabled', 'avatar_url',
'is_admin', 'is_superuser', 'is_staff',
'storage_used_gb'
]
def get_avatar_url(self, obj):
"""Get avatar URL"""
if not obj.avatar:
return None
# Preset avatars (served from frontend public folder)
if obj.avatar.startswith('preset_'):
return f"/avatars/{obj.avatar}.svg"
# Custom avatars (served from backend)
return f"/api/user/avatar/file/{obj.avatar.split('/')[-1]}/"
class LoginSerializer(serializers.Serializer):
"""Login serializer"""
username = serializers.CharField()
password = serializers.CharField(write_only=True)
two_factor_code = serializers.CharField(required=False, allow_blank=True)
class UserConfigSerializer(serializers.Serializer):
"""User configuration serializer"""
theme = serializers.CharField(default='dark')
items_per_page = serializers.IntegerField(default=50)
audio_quality = serializers.ChoiceField(
choices=['low', 'medium', 'high', 'best'],
default='best'
)
class TwoFactorSetupSerializer(serializers.Serializer):
"""2FA setup response"""
secret = serializers.CharField()
qr_code = serializers.CharField()
backup_codes = serializers.ListField(child=serializers.CharField())
class TwoFactorVerifySerializer(serializers.Serializer):
"""2FA verification"""
code = serializers.CharField(min_length=6, max_length=6)
class TwoFactorStatusSerializer(serializers.Serializer):
"""2FA status"""
enabled = serializers.BooleanField()
backup_codes_count = serializers.IntegerField()

View file

@ -0,0 +1,181 @@
"""Serializers for admin user management"""
from rest_framework import serializers
from user.models import Account, UserYouTubeAccount
from channel.models import Channel
from playlist.models import Playlist
class UserStatsSerializer(serializers.Serializer):
"""User statistics"""
total_channels = serializers.IntegerField()
total_playlists = serializers.IntegerField()
total_audio_files = serializers.IntegerField()
storage_used_gb = serializers.FloatField()
storage_quota_gb = serializers.IntegerField()
storage_percent = serializers.FloatField()
class UserDetailSerializer(serializers.ModelSerializer):
"""Detailed user information for admin"""
storage_percent_used = serializers.FloatField(read_only=True)
can_add_channel = serializers.BooleanField(read_only=True)
can_add_playlist = serializers.BooleanField(read_only=True)
stats = serializers.SerializerMethodField()
created_by_username = serializers.CharField(source='created_by.username', read_only=True)
class Meta:
model = Account
fields = [
'id',
'username',
'email',
'is_admin',
'is_active',
'is_staff',
'is_superuser',
'two_factor_enabled',
'storage_quota_gb',
'storage_used_gb',
'storage_percent_used',
'max_channels',
'max_playlists',
'can_add_channel',
'can_add_playlist',
'user_notes',
'created_by',
'created_by_username',
'date_joined',
'last_login',
'stats',
]
read_only_fields = [
'id',
'date_joined',
'last_login',
'storage_used_gb',
'two_factor_enabled',
]
def get_stats(self, obj):
"""Get user statistics"""
from audio.models import Audio
channels_count = Channel.objects.filter(owner=obj).count()
playlists_count = Playlist.objects.filter(owner=obj).count()
audio_count = Audio.objects.filter(owner=obj).count()
return {
'total_channels': channels_count,
'total_playlists': playlists_count,
'total_audio_files': audio_count,
'storage_used_gb': obj.storage_used_gb,
'storage_quota_gb': obj.storage_quota_gb,
'storage_percent': obj.storage_percent_used,
}
class UserCreateSerializer(serializers.ModelSerializer):
"""Create new user (admin only)"""
password = serializers.CharField(write_only=True, required=True, style={'input_type': 'password'})
password_confirm = serializers.CharField(write_only=True, required=True, style={'input_type': 'password'})
class Meta:
model = Account
fields = [
'username',
'email',
'password',
'password_confirm',
'is_admin',
'is_active',
'storage_quota_gb',
'max_channels',
'max_playlists',
'user_notes',
]
def validate(self, data):
"""Validate password match"""
if data['password'] != data['password_confirm']:
raise serializers.ValidationError({"password": "Passwords do not match"})
return data
def create(self, validated_data):
"""Create user with hashed password"""
validated_data.pop('password_confirm')
password = validated_data.pop('password')
user = Account.objects.create_user(
username=validated_data['username'],
email=validated_data['email'],
password=password,
)
# Update additional fields
for key, value in validated_data.items():
setattr(user, key, value)
# Set created_by from request context
request = self.context.get('request')
if request and request.user.is_authenticated:
user.created_by = request.user
user.save()
return user
class UserUpdateSerializer(serializers.ModelSerializer):
"""Update user (admin only)"""
class Meta:
model = Account
fields = [
'is_admin',
'is_active',
'is_staff',
'storage_quota_gb',
'max_channels',
'max_playlists',
'user_notes',
]
class UserYouTubeAccountSerializer(serializers.ModelSerializer):
"""YouTube account serializer"""
class Meta:
model = UserYouTubeAccount
fields = [
'id',
'account_name',
'youtube_channel_id',
'youtube_channel_name',
'is_active',
'auto_download',
'download_quality',
'created_date',
'last_verified',
]
read_only_fields = ['id', 'created_date', 'last_verified']
class UserYouTubeAccountCreateSerializer(serializers.ModelSerializer):
"""Create YouTube account"""
class Meta:
model = UserYouTubeAccount
fields = [
'account_name',
'youtube_channel_id',
'youtube_channel_name',
'cookies_file',
'auto_download',
'download_quality',
]
def create(self, validated_data):
"""Set user from request context"""
request = self.context.get('request')
if request and request.user.is_authenticated:
validated_data['user'] = request.user
return super().create(validated_data)

158
backend/user/two_factor.py Normal file
View file

@ -0,0 +1,158 @@
"""2FA utility functions"""
import pyotp
import qrcode
import io
import base64
import secrets
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib import colors
from datetime import datetime
def generate_totp_secret():
"""Generate a new TOTP secret"""
return pyotp.random_base32()
def get_totp_uri(secret, username, issuer='SoundWave'):
"""Generate TOTP URI for QR code"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name=issuer)
def generate_qr_code(uri):
"""Generate QR code image as base64 string"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# Convert to base64
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
img_base64 = base64.b64encode(buffer.getvalue()).decode()
return f"data:image/png;base64,{img_base64}"
def verify_totp(secret, token):
"""Verify a TOTP token"""
totp = pyotp.TOTP(secret)
return totp.verify(token, valid_window=1)
def generate_backup_codes(count=10):
"""Generate backup codes"""
codes = []
for _ in range(count):
code = '-'.join([
secrets.token_hex(2).upper(),
secrets.token_hex(2).upper(),
secrets.token_hex(2).upper()
])
codes.append(code)
return codes
def generate_backup_codes_pdf(username, codes):
"""Generate PDF with backup codes"""
buffer = io.BytesIO()
# Create PDF
doc = SimpleDocTemplate(buffer, pagesize=letter)
story = []
styles = getSampleStyleSheet()
# Custom styles
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
textColor=colors.HexColor('#1D3557'),
spaceAfter=30,
)
subtitle_style = ParagraphStyle(
'CustomSubtitle',
parent=styles['Normal'],
fontSize=12,
textColor=colors.HexColor('#718096'),
spaceAfter=20,
)
# Title
story.append(Paragraph('SoundWave Backup Codes', title_style))
story.append(Paragraph(f'User: {username}', subtitle_style))
story.append(Paragraph(f'Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}', subtitle_style))
story.append(Spacer(1, 0.3 * inch))
# Warning
warning_style = ParagraphStyle(
'Warning',
parent=styles['Normal'],
fontSize=10,
textColor=colors.HexColor('#E53E3E'),
spaceAfter=20,
leftIndent=20,
rightIndent=20,
)
story.append(Paragraph(
'<b>⚠️ IMPORTANT:</b> Store these codes securely. Each code can only be used once. '
'If you lose access to your 2FA device, you can use these codes to log in.',
warning_style
))
story.append(Spacer(1, 0.3 * inch))
# Codes table
data = [['#', 'Backup Code']]
for i, code in enumerate(codes, 1):
data.append([str(i), code])
table = Table(data, colWidths=[0.5 * inch, 3 * inch])
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4ECDC4')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.HexColor('#1D3557')),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 12),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.white),
('TEXTCOLOR', (0, 1), (-1, -1), colors.HexColor('#2D3748')),
('FONTNAME', (0, 1), (-1, -1), 'Courier'),
('FONTSIZE', (0, 1), (-1, -1), 11),
('GRID', (0, 0), (-1, -1), 1, colors.HexColor('#E2E8F0')),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 10),
('RIGHTPADDING', (0, 0), (-1, -1), 10),
('TOPPADDING', (0, 1), (-1, -1), 8),
('BOTTOMPADDING', (0, 1), (-1, -1), 8),
]))
story.append(table)
# Footer
story.append(Spacer(1, 0.5 * inch))
footer_style = ParagraphStyle(
'Footer',
parent=styles['Normal'],
fontSize=9,
textColor=colors.HexColor('#A0AEC0'),
alignment=1, # Center
)
story.append(Paragraph('Keep this document in a safe place', footer_style))
# Build PDF
doc.build(story)
buffer.seek(0)
return buffer

43
backend/user/urls.py Normal file
View file

@ -0,0 +1,43 @@
"""User URL patterns"""
from django.urls import path, include
from user.views import (
LoginView,
LogoutView,
RegisterView,
UserAccountView,
UserProfileView,
ChangePasswordView,
UserConfigView,
TwoFactorStatusView,
TwoFactorSetupView,
TwoFactorVerifyView,
TwoFactorDisableView,
TwoFactorRegenerateCodesView,
TwoFactorDownloadCodesView,
AvatarUploadView,
AvatarPresetView,
AvatarFileView,
)
urlpatterns = [
path('account/', UserAccountView.as_view(), name='user-account'),
path('profile/', UserProfileView.as_view(), name='user-profile'),
path('change-password/', ChangePasswordView.as_view(), name='change-password'),
path('login/', LoginView.as_view(), name='user-login'),
path('logout/', LogoutView.as_view(), name='user-logout'),
path('register/', RegisterView.as_view(), name='user-register'), # Returns 403 - disabled
path('config/', UserConfigView.as_view(), name='user-config'),
path('2fa/status/', TwoFactorStatusView.as_view(), name='2fa-status'),
path('2fa/setup/', TwoFactorSetupView.as_view(), name='2fa-setup'),
path('2fa/verify/', TwoFactorVerifyView.as_view(), name='2fa-verify'),
path('2fa/disable/', TwoFactorDisableView.as_view(), name='2fa-disable'),
path('2fa/regenerate-codes/', TwoFactorRegenerateCodesView.as_view(), name='2fa-regenerate'),
path('2fa/download-codes/', TwoFactorDownloadCodesView.as_view(), name='2fa-download'),
# Avatar management
path('avatar/upload/', AvatarUploadView.as_view(), name='avatar-upload'),
path('avatar/preset/', AvatarPresetView.as_view(), name='avatar-preset'),
path('avatar/file/<str:filename>/', AvatarFileView.as_view(), name='avatar-file'),
# Admin user management
path('', include('user.urls_admin')),
]

View file

@ -0,0 +1,12 @@
"""URL configuration for admin user management"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from user.views_admin import UserManagementViewSet, UserYouTubeAccountViewSet
router = DefaultRouter()
router.register(r'users', UserManagementViewSet, basename='admin-users')
router.register(r'youtube-accounts', UserYouTubeAccountViewSet, basename='youtube-accounts')
urlpatterns = [
path('admin/', include(router.urls)),
]

591
backend/user/views.py Normal file
View file

@ -0,0 +1,591 @@
"""User API views"""
import os
import mimetypes
from pathlib import Path
from django.contrib.auth import authenticate, login, logout
from django.http import HttpResponse, FileResponse
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.parsers import MultiPartParser, FormParser
from user.models import Account
from user.serializers import (
AccountSerializer,
LoginSerializer,
UserConfigSerializer,
TwoFactorSetupSerializer,
TwoFactorVerifySerializer,
TwoFactorStatusSerializer,
)
from user.two_factor import (
generate_totp_secret,
get_totp_uri,
generate_qr_code,
verify_totp,
generate_backup_codes,
generate_backup_codes_pdf,
)
from datetime import datetime
class UserAccountView(APIView):
"""User account endpoint"""
def get(self, request):
"""Get current user account"""
user = request.user
# Calculate current storage usage
user.calculate_storage_usage()
serializer = AccountSerializer(user)
return Response(serializer.data)
class UserProfileView(APIView):
"""User profile management"""
def patch(self, request):
"""Update user profile (username, email, first_name, last_name)"""
user = request.user
username = request.data.get('username')
email = request.data.get('email')
first_name = request.data.get('first_name')
last_name = request.data.get('last_name')
current_password = request.data.get('current_password', '').strip()
# At least one field must be provided
if not username and not email and first_name is None and last_name is None:
return Response(
{'error': 'At least one field (username, email, first_name, last_name) must be provided'},
status=status.HTTP_400_BAD_REQUEST
)
# Password is required to change username or email (security critical fields)
if (username or email) and not current_password:
return Response(
{'error': 'Current password is required to change username or email'},
status=status.HTTP_400_BAD_REQUEST
)
# Verify current password only if it's provided (for username/email changes)
if current_password and not user.check_password(current_password):
return Response(
{'error': 'Current password is incorrect'},
status=status.HTTP_401_UNAUTHORIZED
)
# Validate username
if username:
username = username.strip()
if len(username) < 3:
return Response(
{'error': 'Username must be at least 3 characters long'},
status=status.HTTP_400_BAD_REQUEST
)
if not username.isalnum() and '_' not in username:
return Response(
{'error': 'Username can only contain letters, numbers, and underscores'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if username is already taken
if Account.objects.exclude(id=user.id).filter(username=username).exists():
return Response(
{'error': 'Username already taken'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if email is already taken
if email and Account.objects.exclude(id=user.id).filter(email=email).exists():
return Response(
{'error': 'Email already in use'},
status=status.HTTP_400_BAD_REQUEST
)
# Update fields
updated_fields = []
if username:
user.username = username
updated_fields.append('username')
if email:
user.email = email
updated_fields.append('email')
if first_name is not None:
user.first_name = first_name
updated_fields.append('name')
if last_name is not None:
user.last_name = last_name
if 'name' not in updated_fields:
updated_fields.append('name')
user.save()
return Response({
'message': f'{" and ".join(updated_fields).capitalize()} updated successfully',
'user': AccountSerializer(user).data
})
class ChangePasswordView(APIView):
"""Change user password"""
def post(self, request):
"""Change password"""
user = request.user
current_password = request.data.get('current_password')
new_password = request.data.get('new_password')
if not current_password or not new_password:
return Response(
{'error': 'Current and new password are required'},
status=status.HTTP_400_BAD_REQUEST
)
# Verify current password
if not user.check_password(current_password):
return Response(
{'error': 'Current password is incorrect'},
status=status.HTTP_401_UNAUTHORIZED
)
# Validate new password length
if len(new_password) < 8:
return Response(
{'error': 'Password must be at least 8 characters long'},
status=status.HTTP_400_BAD_REQUEST
)
# Set new password
user.set_password(new_password)
user.save()
# Delete old token and create new one for security
Token.objects.filter(user=user).delete()
new_token = Token.objects.create(user=user)
return Response({
'message': 'Password changed successfully',
'token': new_token.key # Return new token so user stays logged in
})
class LoginView(APIView):
"""Login endpoint"""
permission_classes = [AllowAny]
def post(self, request):
"""Authenticate user"""
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
username=serializer.validated_data['username'],
password=serializer.validated_data['password']
)
if not user:
return Response(
{'error': 'Invalid credentials'},
status=status.HTTP_401_UNAUTHORIZED
)
# Check if 2FA is enabled
if user.two_factor_enabled:
two_factor_code = serializer.validated_data.get('two_factor_code')
if not two_factor_code:
return Response({
'requires_2fa': True,
'message': 'Two-factor authentication required'
}, status=status.HTTP_200_OK)
# Verify TOTP code
if user.two_factor_secret and verify_totp(user.two_factor_secret, two_factor_code):
pass # Code is valid, continue login
# Check backup codes
elif two_factor_code in user.backup_codes:
# Remove used backup code
user.backup_codes.remove(two_factor_code)
user.save()
else:
return Response(
{'error': 'Invalid two-factor code'},
status=status.HTTP_401_UNAUTHORIZED
)
login(request, user)
token, _ = Token.objects.get_or_create(user=user)
return Response({
'token': token.key,
'user': AccountSerializer(user).data
})
class LogoutView(APIView):
"""Logout endpoint"""
def post(self, request):
"""Logout user and delete token"""
# Delete the user's token for security
if request.user.is_authenticated:
try:
Token.objects.filter(user=request.user).delete()
except Token.DoesNotExist:
pass
logout(request)
return Response({'message': 'Logged out successfully'})
class RegisterView(APIView):
"""
Registration endpoint - DISABLED
Public registration is not allowed. Only admins can create new users.
"""
permission_classes = [AllowAny]
def post(self, request):
"""Block public registration"""
from config.user_settings import ALLOW_PUBLIC_REGISTRATION
if not ALLOW_PUBLIC_REGISTRATION:
return Response(
{
'error': 'Public registration is disabled',
'message': 'New users can only be created by administrators. Please contact your system administrator for account creation.'
},
status=status.HTTP_403_FORBIDDEN
)
# If registration is enabled in settings, this would handle it
# This code is kept for potential future use
return Response(
{'error': 'Registration endpoint not implemented'},
status=status.HTTP_501_NOT_IMPLEMENTED
)
class UserConfigView(APIView):
"""User configuration endpoint"""
def get(self, request):
"""Get user configuration"""
# TODO: Implement user config storage
config = {
'theme': 'dark',
'items_per_page': 50,
'audio_quality': 'best'
}
serializer = UserConfigSerializer(config)
return Response(serializer.data)
def post(self, request):
"""Update user configuration"""
serializer = UserConfigSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# TODO: Store user config
return Response(serializer.data)
class TwoFactorStatusView(APIView):
"""Get 2FA status"""
def get(self, request):
"""Get 2FA status for current user"""
user = request.user
serializer = TwoFactorStatusSerializer({
'enabled': user.two_factor_enabled,
'backup_codes_count': len(user.backup_codes) if user.backup_codes else 0
})
return Response(serializer.data)
class TwoFactorSetupView(APIView):
"""Setup 2FA"""
def post(self, request):
"""Generate 2FA secret and QR code"""
user = request.user
# Generate new secret
secret = generate_totp_secret()
uri = get_totp_uri(secret, user.username)
qr_code = generate_qr_code(uri)
# Generate backup codes
backup_codes = generate_backup_codes()
# Store secret temporarily (not enabled yet)
user.two_factor_secret = secret
user.backup_codes = backup_codes
user.save()
serializer = TwoFactorSetupSerializer({
'secret': secret,
'qr_code': qr_code,
'backup_codes': backup_codes
})
return Response(serializer.data)
class TwoFactorVerifyView(APIView):
"""Verify and enable 2FA"""
def post(self, request):
"""Verify 2FA code and enable"""
serializer = TwoFactorVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = request.user
code = serializer.validated_data['code']
if not user.two_factor_secret:
return Response(
{'error': 'No 2FA setup found. Please setup 2FA first.'},
status=status.HTTP_400_BAD_REQUEST
)
if verify_totp(user.two_factor_secret, code):
user.two_factor_enabled = True
user.save()
return Response({
'message': 'Two-factor authentication enabled successfully',
'enabled': True
})
return Response(
{'error': 'Invalid verification code'},
status=status.HTTP_400_BAD_REQUEST
)
class TwoFactorDisableView(APIView):
"""Disable 2FA"""
def post(self, request):
"""Disable 2FA for user"""
serializer = TwoFactorVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = request.user
code = serializer.validated_data['code']
if not user.two_factor_enabled:
return Response(
{'error': 'Two-factor authentication is not enabled'},
status=status.HTTP_400_BAD_REQUEST
)
# Verify code before disabling
if verify_totp(user.two_factor_secret, code) or code in user.backup_codes:
user.two_factor_enabled = False
user.two_factor_secret = None
user.backup_codes = []
user.save()
return Response({
'message': 'Two-factor authentication disabled successfully',
'enabled': False
})
return Response(
{'error': 'Invalid verification code'},
status=status.HTTP_400_BAD_REQUEST
)
class TwoFactorRegenerateCodesView(APIView):
"""Regenerate backup codes"""
def post(self, request):
"""Generate new backup codes"""
user = request.user
if not user.two_factor_enabled:
return Response(
{'error': 'Two-factor authentication is not enabled'},
status=status.HTTP_400_BAD_REQUEST
)
# Generate new backup codes
backup_codes = generate_backup_codes()
user.backup_codes = backup_codes
user.save()
return Response({
'backup_codes': backup_codes,
'message': 'Backup codes regenerated successfully'
})
class TwoFactorDownloadCodesView(APIView):
"""Download backup codes as PDF"""
def get(self, request):
"""Generate and download backup codes PDF"""
user = request.user
if not user.two_factor_enabled or not user.backup_codes:
return Response(
{'error': 'No backup codes available'},
status=status.HTTP_400_BAD_REQUEST
)
# Generate PDF
pdf_buffer = generate_backup_codes_pdf(user.username, user.backup_codes)
# Create filename: username_SoundWave_BackupCodes_YYYY-MM-DD.pdf
filename = f"{user.username}_SoundWave_BackupCodes_{datetime.now().strftime('%Y-%m-%d')}.pdf"
response = HttpResponse(pdf_buffer, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
class AvatarUploadView(APIView):
"""Upload user avatar"""
parser_classes = [MultiPartParser, FormParser]
# Avatar directory - persistent storage
AVATAR_DIR = Path('/app/data/avatars')
MAX_SIZE = 20 * 1024 * 1024 # 20MB
ALLOWED_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
def post(self, request):
"""Upload custom avatar image"""
if 'avatar' not in request.FILES:
return Response(
{'error': 'No avatar file provided'},
status=status.HTTP_400_BAD_REQUEST
)
avatar_file = request.FILES['avatar']
# Validate file size
if avatar_file.size > self.MAX_SIZE:
return Response(
{'error': f'File too large. Maximum size is {self.MAX_SIZE // (1024*1024)}MB'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate content type
content_type = avatar_file.content_type
if content_type not in self.ALLOWED_TYPES:
return Response(
{'error': f'Invalid file type. Allowed types: {", ".join(self.ALLOWED_TYPES)}'},
status=status.HTTP_400_BAD_REQUEST
)
# Create avatars directory if it doesn't exist
self.AVATAR_DIR.mkdir(parents=True, exist_ok=True)
# Generate safe filename: username_timestamp.ext
ext = Path(avatar_file.name).suffix or '.jpg'
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"{request.user.username}_{timestamp}{ext}"
filepath = self.AVATAR_DIR / filename
# Remove old avatar file if it exists and is not a preset
if request.user.avatar and not request.user.avatar.startswith('preset_'):
old_path = self.AVATAR_DIR / request.user.avatar.split('/')[-1]
if old_path.exists():
old_path.unlink()
# Save file
with open(filepath, 'wb+') as destination:
for chunk in avatar_file.chunks():
destination.write(chunk)
# Update user model
request.user.avatar = f"avatars/{filename}"
request.user.save()
return Response({
'message': 'Avatar uploaded successfully',
'avatar': request.user.avatar
})
def delete(self, request):
"""Remove custom avatar and reset to default"""
user = request.user
# Remove file if it exists and is not a preset
if user.avatar and not user.avatar.startswith('preset_'):
filepath = self.AVATAR_DIR / user.avatar.split('/')[-1]
if filepath.exists():
filepath.unlink()
user.avatar = None
user.save()
return Response({
'message': 'Avatar removed successfully'
})
class AvatarPresetView(APIView):
"""Set preset avatar"""
def post(self, request):
"""Set preset avatar (1-5)"""
preset = request.data.get('preset')
if not preset or not str(preset).isdigit():
return Response(
{'error': 'Invalid preset number'},
status=status.HTTP_400_BAD_REQUEST
)
preset_num = int(preset)
if preset_num < 1 or preset_num > 5:
return Response(
{'error': 'Preset must be between 1 and 5'},
status=status.HTTP_400_BAD_REQUEST
)
# Remove old custom avatar file if exists
user = request.user
if user.avatar and not user.avatar.startswith('preset_'):
avatar_dir = Path('/app/data/avatars')
filepath = avatar_dir / user.avatar.split('/')[-1]
if filepath.exists():
filepath.unlink()
# Set preset
user.avatar = f"preset_{preset_num}"
user.save()
return Response({
'message': 'Preset avatar set successfully',
'avatar': user.avatar
})
class AvatarFileView(APIView):
"""Serve avatar files"""
def get(self, request, filename):
"""Serve avatar file"""
avatar_dir = Path('/app/data/avatars')
filepath = avatar_dir / filename
# Security: validate path
if not filepath.resolve().is_relative_to(avatar_dir.resolve()):
return Response(
{'error': 'Invalid path'},
status=status.HTTP_403_FORBIDDEN
)
if not filepath.exists():
return Response(
{'error': 'Avatar not found'},
status=status.HTTP_404_NOT_FOUND
)
# Determine content type
content_type, _ = mimetypes.guess_type(str(filepath))
if not content_type:
content_type = 'application/octet-stream'
return FileResponse(open(filepath, 'rb'), content_type=content_type)

215
backend/user/views_admin.py Normal file
View file

@ -0,0 +1,215 @@
"""Admin views for user management"""
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from django.db.models import Count, Sum, Q
from django.contrib.auth import get_user_model
from user.models import UserYouTubeAccount
from user.serializers_admin import (
UserDetailSerializer,
UserCreateSerializer,
UserUpdateSerializer,
UserStatsSerializer,
UserYouTubeAccountSerializer,
UserYouTubeAccountCreateSerializer,
)
from channel.models import Channel
from playlist.models import Playlist
from audio.models import Audio
User = get_user_model()
class IsAdminOrSelf(IsAuthenticated):
"""Permission: Admin can access all, users can access only their own data"""
def has_object_permission(self, request, view, obj):
if request.user.is_admin or request.user.is_superuser:
return True
if hasattr(obj, 'owner'):
return obj.owner == request.user
if hasattr(obj, 'user'):
return obj.user == request.user
return obj == request.user
class UserManagementViewSet(viewsets.ModelViewSet):
"""Admin viewset for managing users"""
queryset = User.objects.all()
permission_classes = [IsAdminUser]
def get_serializer_class(self):
if self.action == 'create':
return UserCreateSerializer
elif self.action in ['update', 'partial_update']:
return UserUpdateSerializer
return UserDetailSerializer
def get_queryset(self):
"""Filter users based on permissions"""
queryset = User.objects.all()
# Admin sees all, regular users see only themselves
if not (self.request.user.is_admin or self.request.user.is_superuser):
queryset = queryset.filter(id=self.request.user.id)
# Add annotations
queryset = queryset.annotate(
channels_count=Count('channels', distinct=True),
playlists_count=Count('playlists', distinct=True),
audio_count=Count('audio_files', distinct=True),
)
return queryset.order_by('-date_joined')
@action(detail=True, methods=['get'])
def stats(self, request, pk=None):
"""Get detailed statistics for a user"""
user = self.get_object()
stats = {
'total_channels': Channel.objects.filter(owner=user).count(),
'active_channels': Channel.objects.filter(owner=user, subscribed=True).count(),
'total_playlists': Playlist.objects.filter(owner=user).count(),
'subscribed_playlists': Playlist.objects.filter(owner=user, subscribed=True).count(),
'total_audio_files': Audio.objects.filter(owner=user).count(),
'storage_used_gb': user.storage_used_gb,
'storage_quota_gb': user.storage_quota_gb,
'storage_percent': user.storage_percent_used,
'youtube_accounts': UserYouTubeAccount.objects.filter(user=user).count(),
}
serializer = UserStatsSerializer(stats)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def reset_storage(self, request, pk=None):
"""Reset user storage usage"""
user = self.get_object()
user.storage_used_gb = 0.0
user.save()
return Response({'message': 'Storage reset successfully'})
@action(detail=True, methods=['post'])
def reset_2fa(self, request, pk=None):
"""Reset user 2FA"""
user = self.get_object()
user.two_factor_enabled = False
user.two_factor_secret = ''
user.backup_codes = []
user.save()
return Response({'message': '2FA reset successfully'})
@action(detail=True, methods=['post'])
def toggle_active(self, request, pk=None):
"""Toggle user active status"""
user = self.get_object()
user.is_active = not user.is_active
user.save()
return Response({
'message': f'User {"activated" if user.is_active else "deactivated"}',
'is_active': user.is_active
})
@action(detail=True, methods=['get'])
def channels(self, request, pk=None):
"""Get user's channels"""
user = self.get_object()
channels = Channel.objects.filter(owner=user).values(
'id', 'channel_name', 'channel_id', 'subscribed', 'video_count'
)
return Response(channels)
@action(detail=True, methods=['get'])
def playlists(self, request, pk=None):
"""Get user's playlists"""
user = self.get_object()
playlists = Playlist.objects.filter(owner=user).values(
'id', 'title', 'playlist_id', 'subscribed', 'playlist_type'
)
return Response(playlists)
@action(detail=False, methods=['get'])
def system_stats(self, request):
"""Get system-wide statistics"""
total_users = User.objects.count()
active_users = User.objects.filter(is_active=True).count()
admin_users = User.objects.filter(Q(is_admin=True) | Q(is_superuser=True)).count()
total_channels = Channel.objects.count()
total_playlists = Playlist.objects.count()
total_audio = Audio.objects.count()
total_storage = User.objects.aggregate(
used=Sum('storage_used_gb'),
quota=Sum('storage_quota_gb')
)
return Response({
'users': {
'total': total_users,
'active': active_users,
'admin': admin_users,
},
'content': {
'channels': total_channels,
'playlists': total_playlists,
'audio_files': total_audio,
},
'storage': {
'used_gb': total_storage['used'] or 0,
'quota_gb': total_storage['quota'] or 0,
}
})
class UserYouTubeAccountViewSet(viewsets.ModelViewSet):
"""ViewSet for managing user YouTube accounts"""
permission_classes = [IsAdminOrSelf]
def get_serializer_class(self):
if self.action == 'create':
return UserYouTubeAccountCreateSerializer
return UserYouTubeAccountSerializer
def get_queryset(self):
"""Filter by user"""
queryset = UserYouTubeAccount.objects.all()
# Regular users see only their accounts
if not (self.request.user.is_admin or self.request.user.is_superuser):
queryset = queryset.filter(user=self.request.user)
# Filter by user_id if provided
user_id = self.request.query_params.get('user_id')
if user_id:
queryset = queryset.filter(user_id=user_id)
return queryset.order_by('-created_date')
def perform_create(self, serializer):
"""Set user from request"""
serializer.save(user=self.request.user)
@action(detail=True, methods=['post'])
def verify(self, request, pk=None):
"""Verify YouTube account credentials"""
account = self.get_object()
# TODO: Implement actual verification logic
from django.utils import timezone
account.last_verified = timezone.now()
account.save()
return Response({'message': 'Account verified successfully'})
@action(detail=True, methods=['post'])
def toggle_active(self, request, pk=None):
"""Toggle account active status"""
account = self.get_object()
account.is_active = not account.is_active
account.save()
return Response({
'message': f'Account {"activated" if account.is_active else "deactivated"}',
'is_active': account.is_active
})