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/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
|
||||
Loading…
Add table
Add a link
Reference in a new issue