Initial commit - SoundWave v1.0

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

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

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

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

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

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