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