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

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

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

View file

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

View file

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