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:
commit
51679d1943
254 changed files with 37281 additions and 0 deletions
0
backend/appsettings/__init__.py
Normal file
0
backend/appsettings/__init__.py
Normal file
5
backend/appsettings/admin.py
Normal file
5
backend/appsettings/admin.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""App settings admin"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
# No models to register for appsettings
|
||||
0
backend/appsettings/migrations/__init__.py
Normal file
0
backend/appsettings/migrations/__init__.py
Normal file
6
backend/appsettings/models.py
Normal file
6
backend/appsettings/models.py
Normal 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
|
||||
12
backend/appsettings/serializers.py
Normal file
12
backend/appsettings/serializers.py
Normal 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)
|
||||
9
backend/appsettings/urls.py
Normal file
9
backend/appsettings/urls.py
Normal 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'),
|
||||
]
|
||||
37
backend/appsettings/views.py
Normal file
37
backend/appsettings/views.py
Normal 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'})
|
||||
0
backend/channel/__init__.py
Normal file
0
backend/channel/__init__.py
Normal file
12
backend/channel/admin.py
Normal file
12
backend/channel/admin.py
Normal 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')
|
||||
0
backend/channel/migrations/__init__.py
Normal file
0
backend/channel/migrations/__init__.py
Normal file
71
backend/channel/models.py
Normal file
71
backend/channel/models.py
Normal 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}"
|
||||
54
backend/channel/serializers.py
Normal file
54
backend/channel/serializers.py
Normal 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
9
backend/channel/urls.py
Normal 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
65
backend/channel/views.py
Normal 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)
|
||||
0
backend/common/__init__.py
Normal file
0
backend/common/__init__.py
Normal file
0
backend/common/admin.py
Normal file
0
backend/common/admin.py
Normal file
0
backend/common/migrations/__init__.py
Normal file
0
backend/common/migrations/__init__.py
Normal file
5
backend/common/models.py
Normal file
5
backend/common/models.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Common models - shared across apps"""
|
||||
|
||||
from django.db import models
|
||||
|
||||
# No models in common app - it provides shared utilities
|
||||
107
backend/common/permissions.py
Normal file
107
backend/common/permissions.py
Normal 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
|
||||
16
backend/common/serializers.py
Normal file
16
backend/common/serializers.py
Normal 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()
|
||||
103
backend/common/src/youtube_metadata.py
Normal file
103
backend/common/src/youtube_metadata.py
Normal 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
172
backend/common/streaming.py
Normal 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
7
backend/common/urls.py
Normal 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
23
backend/common/views.py
Normal 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
|
||||
6
backend/config/__init__.py
Normal file
6
backend/config/__init__.py
Normal 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
11
backend/config/asgi.py
Normal 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
50
backend/config/celery.py
Normal 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},
|
||||
},
|
||||
}
|
||||
41
backend/config/middleware.py
Normal file
41
backend/config/middleware.py
Normal 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
201
backend/config/settings.py
Normal 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
59
backend/config/urls.py
Normal 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'),
|
||||
]
|
||||
19
backend/config/user_settings.py
Normal file
19
backend/config/user_settings.py
Normal 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
11
backend/config/wsgi.py
Normal 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()
|
||||
0
backend/download/__init__.py
Normal file
0
backend/download/__init__.py
Normal file
12
backend/download/admin.py
Normal file
12
backend/download/admin.py
Normal 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')
|
||||
0
backend/download/migrations/__init__.py
Normal file
0
backend/download/migrations/__init__.py
Normal file
40
backend/download/models.py
Normal file
40
backend/download/models.py
Normal 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}"
|
||||
22
backend/download/serializers.py
Normal file
22
backend/download/serializers.py
Normal 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
8
backend/download/urls.py
Normal 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
42
backend/download/views.py
Normal 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
22
backend/manage.py
Normal 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()
|
||||
0
backend/playlist/__init__.py
Normal file
0
backend/playlist/__init__.py
Normal file
19
backend/playlist/admin.py
Normal file
19
backend/playlist/admin.py
Normal 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')
|
||||
0
backend/playlist/migrations/__init__.py
Normal file
0
backend/playlist/migrations/__init__.py
Normal file
82
backend/playlist/models.py
Normal file
82
backend/playlist/models.py
Normal 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}"
|
||||
139
backend/playlist/models_download.py
Normal file
139
backend/playlist/models_download.py
Normal 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
|
||||
59
backend/playlist/serializers.py
Normal file
59
backend/playlist/serializers.py
Normal 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']
|
||||
110
backend/playlist/serializers_download.py
Normal file
110
backend/playlist/serializers_download.py
Normal 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)
|
||||
249
backend/playlist/tasks_download.py
Normal file
249
backend/playlist/tasks_download.py
Normal 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
12
backend/playlist/urls.py
Normal 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'),
|
||||
]
|
||||
12
backend/playlist/urls_download.py
Normal file
12
backend/playlist/urls_download.py
Normal 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
110
backend/playlist/views.py
Normal 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)
|
||||
207
backend/playlist/views_download.py
Normal file
207
backend/playlist/views_download.py
Normal 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
20
backend/requirements.txt
Normal 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
|
||||
0
backend/stats/__init__.py
Normal file
0
backend/stats/__init__.py
Normal file
5
backend/stats/admin.py
Normal file
5
backend/stats/admin.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Stats admin"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
# No models to register for stats
|
||||
0
backend/stats/migrations/__init__.py
Normal file
0
backend/stats/migrations/__init__.py
Normal file
5
backend/stats/models.py
Normal file
5
backend/stats/models.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Stats models"""
|
||||
|
||||
from django.db import models
|
||||
|
||||
# Stats are calculated from aggregations, no models needed
|
||||
24
backend/stats/serializers.py
Normal file
24
backend/stats/serializers.py
Normal 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
10
backend/stats/urls.py
Normal 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
61
backend/stats/views.py
Normal 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
0
backend/task/__init__.py
Normal file
5
backend/task/admin.py
Normal file
5
backend/task/admin.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Task admin - tasks are managed through Celery"""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
# No models to register for task app
|
||||
0
backend/task/migrations/__init__.py
Normal file
0
backend/task/migrations/__init__.py
Normal file
7
backend/task/models.py
Normal file
7
backend/task/models.py
Normal 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
|
||||
18
backend/task/serializers.py
Normal file
18
backend/task/serializers.py
Normal 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
507
backend/task/tasks.py
Normal 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
10
backend/task/urls.py
Normal 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
53
backend/task/views.py
Normal 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
|
||||
})
|
||||
464
backend/user/README_MULTI_TENANT.md
Normal file
464
backend/user/README_MULTI_TENANT.md
Normal 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
|
||||
239
backend/user/REGISTRATION_POLICY.md
Normal file
239
backend/user/REGISTRATION_POLICY.md
Normal 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
0
backend/user/__init__.py
Normal file
5
backend/user/admin.py
Normal file
5
backend/user/admin.py
Normal 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
243
backend/user/admin_users.py
Normal 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)
|
||||
0
backend/user/migrations/__init__.py
Normal file
0
backend/user/migrations/__init__.py
Normal file
152
backend/user/models.py
Normal file
152
backend/user/models.py
Normal 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}"
|
||||
71
backend/user/serializers.py
Normal file
71
backend/user/serializers.py
Normal 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()
|
||||
181
backend/user/serializers_admin.py
Normal file
181
backend/user/serializers_admin.py
Normal 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
158
backend/user/two_factor.py
Normal 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
43
backend/user/urls.py
Normal 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')),
|
||||
]
|
||||
12
backend/user/urls_admin.py
Normal file
12
backend/user/urls_admin.py
Normal 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
591
backend/user/views.py
Normal 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
215
backend/user/views_admin.py
Normal 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
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue