"""Celery tasks for artwork and metadata management""" from celery import shared_task from celery.utils.log import get_task_logger from django.core.files.base import ContentFile from django.db.models import Q import os import requests from pathlib import Path from audio.models import Audio, Channel from audio.models_artwork import Artwork, MusicMetadata, ArtistInfo from audio.lastfm_client import LastFMClient from audio.fanart_client import FanartClient from audio.id3_service import ID3TagService logger = get_task_logger(__name__) @shared_task(bind=True, max_retries=3) def fetch_metadata_for_audio(self, audio_id: int): """ Fetch metadata for audio from Last.fm Args: audio_id: Audio ID """ try: audio = Audio.objects.get(id=audio_id) client = LastFMClient() # Extract artist and title from audio artist = audio.channel.channel_name if audio.channel else 'Unknown Artist' title = audio.audio_title # Search Last.fm track_info = client.search_track(artist, title) if not track_info: logger.warning(f"No track info found on Last.fm for: {artist} - {title}") return # Create or update metadata metadata, created = MusicMetadata.objects.get_or_create(audio=audio) # Update metadata fields if 'album' in track_info: metadata.album_name = track_info['album'] if 'tags' in track_info and track_info['tags']: metadata.genre = track_info['tags'][0] if track_info['tags'] else None metadata.tags = track_info['tags'] metadata.lastfm_url = track_info.get('url', '') metadata.lastfm_mbid = track_info.get('mbid', '') metadata.play_count = track_info.get('playcount', 0) metadata.listeners = track_info.get('listeners', 0) metadata.save() logger.info(f"Updated metadata for audio {audio_id}") # Also fetch artwork fetch_artwork_for_audio.delay(audio_id) except Audio.DoesNotExist: logger.error(f"Audio {audio_id} not found") except Exception as e: logger.error(f"Error fetching metadata for audio {audio_id}: {e}") raise self.retry(exc=e, countdown=300) @shared_task(bind=True, max_retries=3) def fetch_artwork_for_audio(self, audio_id: int): """ Fetch artwork for audio from Last.fm and Fanart.tv Args: audio_id: Audio ID """ try: audio = Audio.objects.get(id=audio_id) # Try Last.fm first lastfm_client = LastFMClient() artist = audio.channel.channel_name if audio.channel else 'Unknown Artist' title = audio.audio_title track_info = lastfm_client.search_track(artist, title) if track_info and 'images' in track_info: # Save album cover from Last.fm for img in track_info['images']: if img['size'] in ['large', 'extralarge', 'mega']: # Check if artwork already exists if not Artwork.objects.filter( audio=audio, source='lastfm', artwork_type='audio_cover' ).exists(): artwork = Artwork.objects.create( audio=audio, artwork_type='audio_cover', source='lastfm', url=img['url'], priority=20 ) # Download and save locally download_artwork.delay(artwork.id) logger.info(f"Created Last.fm artwork for audio {audio_id}") break # Try Fanart.tv if we have MusicBrainz ID try: metadata = MusicMetadata.objects.get(audio=audio) if metadata.lastfm_mbid: fanart_client = FanartClient() artist_images = fanart_client.get_artist_images(metadata.lastfm_mbid) if artist_images: # Save artist thumbnail if artist_images['thumbnails']: img = artist_images['thumbnails'][0] if not Artwork.objects.filter( audio=audio, source='fanart', artwork_type='audio_cover' ).exists(): artwork = Artwork.objects.create( audio=audio, artwork_type='audio_cover', source='fanart', url=img['url'], priority=30 ) download_artwork.delay(artwork.id) logger.info(f"Created Fanart.tv artwork for audio {audio_id}") except MusicMetadata.DoesNotExist: pass # Use YouTube thumbnail as fallback if audio.thumb_url and not Artwork.objects.filter(audio=audio, source='youtube').exists(): artwork = Artwork.objects.create( audio=audio, artwork_type='audio_thumbnail', source='youtube', url=audio.thumb_url, priority=10 ) download_artwork.delay(artwork.id) logger.info(f"Created YouTube thumbnail artwork for audio {audio_id}") except Audio.DoesNotExist: logger.error(f"Audio {audio_id} not found") except Exception as e: logger.error(f"Error fetching artwork for audio {audio_id}: {e}") raise self.retry(exc=e, countdown=300) @shared_task(bind=True, max_retries=3) def fetch_artist_info(self, channel_id: int): """ Fetch artist information from Last.fm Args: channel_id: Channel ID """ try: channel = Channel.objects.get(id=channel_id) client = LastFMClient() artist_name = channel.channel_name artist_info = client.get_artist_info(artist_name) if not artist_info: logger.warning(f"No artist info found on Last.fm for: {artist_name}") return # Create or update artist info info, created = ArtistInfo.objects.get_or_create(channel=channel) info.bio = artist_info.get('bio', '') info.bio_summary = artist_info.get('bio_summary', '') info.lastfm_url = artist_info.get('url', '') info.lastfm_mbid = artist_info.get('mbid', '') info.lastfm_listeners = artist_info.get('listeners', 0) info.lastfm_playcount = artist_info.get('playcount', 0) info.tags = artist_info.get('tags', []) info.similar_artists = artist_info.get('similar_artists', []) info.save() logger.info(f"Updated artist info for channel {channel_id}") # Also fetch artist artwork fetch_artist_artwork.delay(channel_id) except Channel.DoesNotExist: logger.error(f"Channel {channel_id} not found") except Exception as e: logger.error(f"Error fetching artist info for channel {channel_id}: {e}") raise self.retry(exc=e, countdown=300) @shared_task(bind=True, max_retries=3) def fetch_artist_artwork(self, channel_id: int): """ Fetch artist artwork from Last.fm and Fanart.tv Args: channel_id: Channel ID """ try: channel = Channel.objects.get(id=channel_id) # Try Last.fm first lastfm_client = LastFMClient() artist_name = channel.channel_name artist_info = lastfm_client.get_artist_info(artist_name) if artist_info and 'images' in artist_info: # Save artist image from Last.fm for img in artist_info['images']: if img['size'] in ['large', 'extralarge', 'mega']: if not Artwork.objects.filter( channel=channel, source='lastfm', artwork_type='artist_image' ).exists(): artwork = Artwork.objects.create( channel=channel, artwork_type='artist_image', source='lastfm', url=img['url'], priority=20 ) download_artwork.delay(artwork.id) logger.info(f"Created Last.fm artist image for channel {channel_id}") break # Try Fanart.tv if we have MusicBrainz ID try: info = ArtistInfo.objects.get(channel=channel) if info.lastfm_mbid: fanart_client = FanartClient() artist_images = fanart_client.get_artist_images(info.lastfm_mbid) if artist_images: # Save artist thumbnail if artist_images['thumbnails'] and not Artwork.objects.filter( channel=channel, source='fanart', artwork_type='artist_image' ).exists(): img = artist_images['thumbnails'][0] artwork = Artwork.objects.create( channel=channel, artwork_type='artist_image', source='fanart', url=img['url'], priority=30 ) download_artwork.delay(artwork.id) # Save artist banner if artist_images['banners'] and not Artwork.objects.filter( channel=channel, source='fanart', artwork_type='artist_banner' ).exists(): img = artist_images['banners'][0] artwork = Artwork.objects.create( channel=channel, artwork_type='artist_banner', source='fanart', url=img['url'], priority=30 ) download_artwork.delay(artwork.id) # Save artist logo if (artist_images['logos_hd'] or artist_images['logos']) and not Artwork.objects.filter( channel=channel, source='fanart', artwork_type='artist_logo' ).exists(): img = artist_images['logos_hd'][0] if artist_images['logos_hd'] else artist_images['logos'][0] artwork = Artwork.objects.create( channel=channel, artwork_type='artist_logo', source='fanart', url=img['url'], priority=30 ) download_artwork.delay(artwork.id) logger.info(f"Created Fanart.tv artwork for channel {channel_id}") except ArtistInfo.DoesNotExist: pass # Use YouTube thumbnail as fallback if channel.channel_thumb_url and not Artwork.objects.filter(channel=channel, source='youtube').exists(): artwork = Artwork.objects.create( channel=channel, artwork_type='artist_image', source='youtube', url=channel.channel_thumb_url, priority=10 ) download_artwork.delay(artwork.id) logger.info(f"Created YouTube thumbnail for channel {channel_id}") except Channel.DoesNotExist: logger.error(f"Channel {channel_id} not found") except Exception as e: logger.error(f"Error fetching artist artwork for channel {channel_id}: {e}") raise self.retry(exc=e, countdown=300) @shared_task(bind=True, max_retries=3) def download_artwork(self, artwork_id: int): """ Download artwork from URL and save locally Args: artwork_id: Artwork ID """ try: artwork = Artwork.objects.get(id=artwork_id) if not artwork.url: logger.warning(f"No URL for artwork {artwork_id}") return # Download image response = requests.get(artwork.url, timeout=30, stream=True) response.raise_for_status() # Get file extension from content type content_type = response.headers.get('content-type', '') if 'jpeg' in content_type or 'jpg' in content_type: ext = 'jpg' elif 'png' in content_type: ext = 'png' elif 'webp' in content_type: ext = 'webp' else: ext = 'jpg' # Default # Generate filename if artwork.audio: filename = f"audio_{artwork.audio.id}_{artwork.artwork_type}_{artwork.source}.{ext}" elif artwork.channel: filename = f"channel_{artwork.channel.id}_{artwork.artwork_type}_{artwork.source}.{ext}" else: filename = f"artwork_{artwork.id}.{ext}" # Save to media directory from django.conf import settings artwork_dir = Path(settings.MEDIA_ROOT) / 'artwork' artwork_dir.mkdir(parents=True, exist_ok=True) filepath = artwork_dir / filename with open(filepath, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) # Update artwork record artwork.local_path = str(filepath.relative_to(settings.MEDIA_ROOT)) artwork.save() logger.info(f"Downloaded artwork {artwork_id} to {filepath}") # If it's audio cover, embed in file if artwork.audio and artwork.artwork_type in ['audio_cover', 'audio_thumbnail']: embed_artwork_in_audio.delay(artwork.audio.id, artwork_id) except Artwork.DoesNotExist: logger.error(f"Artwork {artwork_id} not found") except Exception as e: logger.error(f"Error downloading artwork {artwork_id}: {e}") raise self.retry(exc=e, countdown=300) @shared_task(bind=True) def embed_artwork_in_audio(self, audio_id: int, artwork_id: int = None): """ Embed artwork in audio file using ID3 tags Args: audio_id: Audio ID artwork_id: Optional specific artwork ID to embed (uses best if not provided) """ try: audio = Audio.objects.get(id=audio_id) if not audio.media_url: logger.warning(f"No media file for audio {audio_id}") return # Get artwork if artwork_id: artwork = Artwork.objects.get(id=artwork_id) else: # Get best artwork (highest priority) artwork = Artwork.objects.filter( audio=audio, local_path__isnull=False ).order_by('-priority', '-id').first() if not artwork or not artwork.local_path: logger.warning(f"No local artwork found for audio {audio_id}") return # Read image data from django.conf import settings image_path = Path(settings.MEDIA_ROOT) / artwork.local_path if not image_path.exists(): logger.error(f"Artwork file not found: {image_path}") return with open(image_path, 'rb') as f: image_data = f.read() # Determine MIME type if image_path.suffix.lower() in ['.jpg', '.jpeg']: mime_type = 'image/jpeg' elif image_path.suffix.lower() == '.png': mime_type = 'image/png' else: mime_type = 'image/jpeg' # Embed in audio file service = ID3TagService() audio_path = Path(settings.MEDIA_ROOT) / audio.media_url if audio_path.exists(): success = service.embed_cover_art(str(audio_path), image_data, mime_type) if success: logger.info(f"Embedded artwork in audio {audio_id}") else: logger.error(f"Failed to embed artwork in audio {audio_id}") else: logger.error(f"Audio file not found: {audio_path}") except Audio.DoesNotExist: logger.error(f"Audio {audio_id} not found") except Artwork.DoesNotExist: logger.error(f"Artwork {artwork_id} not found") except Exception as e: logger.error(f"Error embedding artwork for audio {audio_id}: {e}") @shared_task def auto_fetch_artwork_batch(limit: int = 50): """ Auto-fetch artwork for audio without artwork Args: limit: Maximum number of audio to process """ # Find audio without artwork audio_without_artwork = Audio.objects.filter( ~Q(artwork__isnull=False) )[:limit] count = 0 for audio in audio_without_artwork: fetch_metadata_for_audio.delay(audio.id) count += 1 logger.info(f"Queued artwork fetch for {count} audio tracks") @shared_task def auto_fetch_artist_info_batch(limit: int = 20): """ Auto-fetch artist info for channels without info Args: limit: Maximum number of channels to process """ # Find channels without artist info channels_without_info = Channel.objects.filter( ~Q(artistinfo__isnull=False) )[:limit] count = 0 for channel in channels_without_info: fetch_artist_info.delay(channel.id) count += 1 logger.info(f"Queued artist info fetch for {count} channels") @shared_task def update_id3_tags_from_metadata(audio_id: int): """ Update ID3 tags in audio file from metadata Args: audio_id: Audio ID """ try: audio = Audio.objects.get(id=audio_id) if not audio.media_url: logger.warning(f"No media file for audio {audio_id}") return from django.conf import settings audio_path = Path(settings.MEDIA_ROOT) / audio.media_url if not audio_path.exists(): logger.error(f"Audio file not found: {audio_path}") return # Prepare tags tags = { 'title': audio.audio_title, 'artist': audio.channel.channel_name if audio.channel else 'Unknown Artist', } # Add metadata if available try: metadata = MusicMetadata.objects.get(audio=audio) if metadata.album_name: tags['album'] = metadata.album_name if metadata.album_artist: tags['album_artist'] = metadata.album_artist if metadata.release_year: tags['year'] = str(metadata.release_year) if metadata.genre: tags['genre'] = metadata.genre if metadata.track_number: tags['track_number'] = metadata.track_number if metadata.disc_number: tags['disc_number'] = metadata.disc_number except MusicMetadata.DoesNotExist: pass # Write tags service = ID3TagService() success = service.write_tags(str(audio_path), tags) if success: logger.info(f"Updated ID3 tags for audio {audio_id}") else: logger.error(f"Failed to update ID3 tags for audio {audio_id}") except Audio.DoesNotExist: logger.error(f"Audio {audio_id} not found") except Exception as e: logger.error(f"Error updating ID3 tags for audio {audio_id}: {e}")