Fix: Include backend/audio Django app in repository
This commit is contained in:
parent
d04e726373
commit
644cfab298
37 changed files with 6632 additions and 4 deletions
502
backend/audio/README_ARTWORK.md
Normal file
502
backend/audio/README_ARTWORK.md
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
# ID3 Tags and Artwork Management
|
||||
|
||||
This document describes the ID3 tagging and artwork management features in SoundWave.
|
||||
|
||||
## Features
|
||||
|
||||
### ID3 Tag Support
|
||||
- **Broad codec support** with read/write tags for multiple audio formats
|
||||
- **Automatic tag extraction** from downloaded audio files
|
||||
- **Manual tag editing** through API
|
||||
- **Bulk tag updates** from metadata
|
||||
|
||||
**Supported Audio Formats:**
|
||||
|
||||
Lossy formats:
|
||||
- MP3 (ID3v2)
|
||||
- MP4/M4A/M4B (iTunes tags)
|
||||
- OGG Vorbis
|
||||
- Opus
|
||||
- Musepack (MPC)
|
||||
|
||||
Lossless formats:
|
||||
- FLAC
|
||||
- WavPack (.wv)
|
||||
- Monkey's Audio (.ape)
|
||||
- AIFF/AIF
|
||||
- WAV
|
||||
|
||||
High-resolution DSD formats:
|
||||
- DSF (DSD Stream File)
|
||||
- DFF (DSDIFF - Direct Stream Digital Interchange File Format)
|
||||
|
||||
Supported tags:
|
||||
- Title
|
||||
- Artist
|
||||
- Album
|
||||
- Album Artist
|
||||
- Year
|
||||
- Genre
|
||||
- Track Number
|
||||
- Disc Number
|
||||
- Duration (read-only)
|
||||
- Bitrate (read-only)
|
||||
- Sample Rate (DSD formats)
|
||||
- Channels (DSD formats)
|
||||
- Bits per Sample (DSD formats)
|
||||
|
||||
### Artwork Management
|
||||
- **Multiple artwork types**:
|
||||
- Audio thumbnail (YouTube)
|
||||
- Audio cover art
|
||||
- Album cover
|
||||
- Artist image
|
||||
- Artist banner
|
||||
- Artist logo
|
||||
|
||||
- **Multiple artwork sources**:
|
||||
- YouTube thumbnails (automatic)
|
||||
- Last.fm API
|
||||
- Fanart.tv API
|
||||
- Manual uploads
|
||||
|
||||
- **Automatic artwork fetching** from Last.fm and Fanart.tv
|
||||
- **Artwork embedding** in audio files
|
||||
- **Priority-based artwork selection**
|
||||
- **Local artwork caching**
|
||||
|
||||
### Music Metadata
|
||||
- **Extended metadata** for audio tracks:
|
||||
- Album information
|
||||
- Track and disc numbers
|
||||
- Genre and tags
|
||||
- Last.fm statistics (play count, listeners)
|
||||
- MusicBrainz IDs
|
||||
- Fanart.tv IDs
|
||||
|
||||
- **Artist information** for channels:
|
||||
- Biography
|
||||
- Last.fm statistics
|
||||
- Tags and genres
|
||||
- Similar artists
|
||||
- MusicBrainz ID
|
||||
- Fanart.tv ID
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
The required packages are already in `requirements.txt`:
|
||||
- `mutagen>=1.47.0` - ID3 tag reading/writing
|
||||
- `pylast>=5.2.0` - Last.fm API client
|
||||
|
||||
Install with:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Configure API Keys
|
||||
|
||||
#### Last.fm API
|
||||
1. Register at https://www.last.fm/api/account/create
|
||||
2. Add to `.env`:
|
||||
```bash
|
||||
LASTFM_API_KEY=your_api_key_here
|
||||
LASTFM_API_SECRET=your_api_secret_here
|
||||
```
|
||||
|
||||
#### Fanart.tv API
|
||||
1. Register at https://fanart.tv/get-an-api-key/
|
||||
2. Add to `.env`:
|
||||
```bash
|
||||
FANART_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
### 3. Run Migrations
|
||||
|
||||
```bash
|
||||
python manage.py makemigrations audio
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### 4. Configure Celery Tasks
|
||||
|
||||
The following periodic tasks are configured in `config/celery.py`:
|
||||
- **Auto-fetch artwork**: Every 2 hours (50 tracks per batch)
|
||||
- **Auto-fetch artist info**: Daily at 2 AM (20 channels per batch)
|
||||
|
||||
## Usage
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Artwork Management
|
||||
```
|
||||
GET /api/audio/api/artwork/ # List all artwork
|
||||
GET /api/audio/api/artwork/{id}/ # Get artwork details
|
||||
POST /api/audio/api/artwork/ # Create artwork
|
||||
PUT /api/audio/api/artwork/{id}/ # Update artwork
|
||||
DELETE /api/audio/api/artwork/{id}/ # Delete artwork
|
||||
POST /api/audio/api/artwork/{id}/download/ # Download artwork from URL
|
||||
POST /api/audio/api/artwork/{id}/set_primary/ # Set as primary artwork
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
- `audio_id` - Filter by audio ID
|
||||
- `channel_id` - Filter by channel ID
|
||||
- `type` - Filter by artwork type
|
||||
- `source` - Filter by source
|
||||
|
||||
#### Music Metadata
|
||||
```
|
||||
GET /api/audio/api/metadata/ # List metadata
|
||||
GET /api/audio/api/metadata/{id}/ # Get metadata
|
||||
POST /api/audio/api/metadata/ # Create metadata
|
||||
PUT /api/audio/api/metadata/{id}/ # Update metadata
|
||||
DELETE /api/audio/api/metadata/{id}/ # Delete metadata
|
||||
POST /api/audio/api/metadata/{id}/fetch_from_lastfm/ # Fetch from Last.fm
|
||||
POST /api/audio/api/metadata/{id}/update_id3_tags/ # Update file ID3 tags
|
||||
```
|
||||
|
||||
#### Artist Information
|
||||
```
|
||||
GET /api/audio/api/artist-info/ # List artist info
|
||||
GET /api/audio/api/artist-info/{id}/ # Get artist info
|
||||
POST /api/audio/api/artist-info/ # Create artist info
|
||||
PUT /api/audio/api/artist-info/{id}/ # Update artist info
|
||||
DELETE /api/audio/api/artist-info/{id}/ # Delete artist info
|
||||
POST /api/audio/api/artist-info/{id}/fetch_from_lastfm/ # Fetch from Last.fm
|
||||
```
|
||||
|
||||
#### Audio Artwork Operations
|
||||
```
|
||||
GET /api/audio/api/audio-artwork/{audio_id}/ # Get all artwork for audio
|
||||
POST /api/audio/api/audio-artwork/{audio_id}/fetch_artwork/ # Fetch artwork
|
||||
POST /api/audio/api/audio-artwork/{audio_id}/fetch_metadata/ # Fetch metadata
|
||||
POST /api/audio/api/audio-artwork/{audio_id}/embed_artwork/ # Embed artwork in file
|
||||
```
|
||||
|
||||
Request body for embed_artwork (optional):
|
||||
```json
|
||||
{
|
||||
"artwork_id": 123 // Specific artwork to embed, or omit for best artwork
|
||||
}
|
||||
```
|
||||
|
||||
#### Channel Artwork Operations
|
||||
```
|
||||
GET /api/audio/api/channel-artwork/{channel_id}/ # Get all artwork for channel
|
||||
POST /api/audio/api/channel-artwork/{channel_id}/fetch_artwork/ # Fetch artwork
|
||||
POST /api/audio/api/channel-artwork/{channel_id}/fetch_info/ # Fetch artist info
|
||||
```
|
||||
|
||||
### Celery Tasks
|
||||
|
||||
#### Manual Task Execution
|
||||
|
||||
Fetch metadata for specific audio:
|
||||
```python
|
||||
from audio.tasks_artwork import fetch_metadata_for_audio
|
||||
fetch_metadata_for_audio.delay(audio_id)
|
||||
```
|
||||
|
||||
Fetch artwork for specific audio:
|
||||
```python
|
||||
from audio.tasks_artwork import fetch_artwork_for_audio
|
||||
fetch_artwork_for_audio.delay(audio_id)
|
||||
```
|
||||
|
||||
Fetch artist info:
|
||||
```python
|
||||
from audio.tasks_artwork import fetch_artist_info
|
||||
fetch_artist_info.delay(channel_id)
|
||||
```
|
||||
|
||||
Fetch artist artwork:
|
||||
```python
|
||||
from audio.tasks_artwork import fetch_artist_artwork
|
||||
fetch_artist_artwork.delay(channel_id)
|
||||
```
|
||||
|
||||
Embed artwork in audio file:
|
||||
```python
|
||||
from audio.tasks_artwork import embed_artwork_in_audio
|
||||
embed_artwork_in_audio.delay(audio_id, artwork_id=None) # None = use best artwork
|
||||
```
|
||||
|
||||
Update ID3 tags from metadata:
|
||||
```python
|
||||
from audio.tasks_artwork import update_id3_tags_from_metadata
|
||||
update_id3_tags_from_metadata.delay(audio_id)
|
||||
```
|
||||
|
||||
#### Batch Operations
|
||||
|
||||
Auto-fetch artwork for 50 audio without artwork:
|
||||
```python
|
||||
from audio.tasks_artwork import auto_fetch_artwork_batch
|
||||
auto_fetch_artwork_batch.delay(limit=50)
|
||||
```
|
||||
|
||||
Auto-fetch artist info for 20 channels:
|
||||
```python
|
||||
from audio.tasks_artwork import auto_fetch_artist_info_batch
|
||||
auto_fetch_artist_info_batch.delay(limit=20)
|
||||
```
|
||||
|
||||
### ID3 Service
|
||||
|
||||
Direct tag manipulation for all supported formats:
|
||||
|
||||
```python
|
||||
from audio.id3_service import ID3TagService
|
||||
|
||||
service = ID3TagService()
|
||||
|
||||
# Read tags (supports MP3, M4A, FLAC, OGG, Opus, WavPack, APE, DSF, DFF, AIFF, WAV, etc.)
|
||||
tags = service.read_tags('/path/to/audio.dsf')
|
||||
print(tags)
|
||||
# {
|
||||
# 'title': 'Song Title',
|
||||
# 'artist': 'Artist Name',
|
||||
# 'album': 'Album Name',
|
||||
# 'year': '2024',
|
||||
# 'genre': 'Rock',
|
||||
# 'track_number': 5,
|
||||
# 'duration': 240.5,
|
||||
# 'bitrate': 256000,
|
||||
# 'has_cover': True,
|
||||
# 'format': 'DSF',
|
||||
# 'sample_rate': 2822400, # DSD64 (2.8224 MHz)
|
||||
# 'channels': 2,
|
||||
# 'bits_per_sample': 1
|
||||
# }
|
||||
|
||||
# Write tags (works with all supported formats)
|
||||
new_tags = {
|
||||
'title': 'New Title',
|
||||
'artist': 'New Artist',
|
||||
'album': 'New Album',
|
||||
'year': '2024',
|
||||
'genre': 'Jazz',
|
||||
'track_number': 3,
|
||||
'disc_number': 1,
|
||||
}
|
||||
service.write_tags('/path/to/audio.dsf', new_tags) # Works with DSF, FLAC, MP3, etc.
|
||||
|
||||
# Embed cover art (supports all formats including DSD)
|
||||
with open('/path/to/cover.jpg', 'rb') as f:
|
||||
image_data = f.read()
|
||||
service.embed_cover_art('/path/to/audio.dsf', image_data, 'image/jpeg')
|
||||
|
||||
# Extract cover art (works with all formats)
|
||||
cover_data = service.extract_cover_art('/path/to/audio.dsf')
|
||||
if cover_data:
|
||||
with open('/path/to/extracted_cover.jpg', 'wb') as f:
|
||||
f.write(cover_data)
|
||||
|
||||
# Check supported formats
|
||||
print(service.SUPPORTED_FORMATS)
|
||||
# {'.mp3': 'MP3', '.m4a': 'MP4', '.flac': 'FLAC', '.dsf': 'DSF', '.dff': 'DSDIFF', ...}
|
||||
```
|
||||
|
||||
### Last.fm Client
|
||||
|
||||
```python
|
||||
from audio.lastfm_client import LastFMClient
|
||||
|
||||
client = LastFMClient()
|
||||
|
||||
# Search for track
|
||||
track_info = client.search_track('Artist Name', 'Song Title')
|
||||
print(track_info)
|
||||
# {
|
||||
# 'title': 'Song Title',
|
||||
# 'artist': 'Artist Name',
|
||||
# 'album': 'Album Name',
|
||||
# 'url': 'https://www.last.fm/music/...',
|
||||
# 'duration': 240,
|
||||
# 'listeners': 50000,
|
||||
# 'playcount': 1000000,
|
||||
# 'tags': ['rock', 'alternative'],
|
||||
# 'mbid': '...',
|
||||
# 'images': [{'size': 'large', 'url': '...'}]
|
||||
# }
|
||||
|
||||
# Get artist info
|
||||
artist_info = client.get_artist_info('Artist Name')
|
||||
print(artist_info)
|
||||
# {
|
||||
# 'name': 'Artist Name',
|
||||
# 'url': 'https://www.last.fm/music/...',
|
||||
# 'listeners': 1000000,
|
||||
# 'playcount': 50000000,
|
||||
# 'bio': '...',
|
||||
# 'bio_summary': '...',
|
||||
# 'tags': ['rock', 'alternative'],
|
||||
# 'mbid': '...',
|
||||
# 'similar_artists': [...]
|
||||
# }
|
||||
|
||||
# Get album info
|
||||
album_info = client.get_album_info('Artist Name', 'Album Name')
|
||||
|
||||
# Download image
|
||||
client.download_image('https://...', '/path/to/save.jpg')
|
||||
```
|
||||
|
||||
### Fanart.tv Client
|
||||
|
||||
```python
|
||||
from audio.fanart_client import FanartClient
|
||||
|
||||
client = FanartClient()
|
||||
|
||||
# Get artist images (requires MusicBrainz ID)
|
||||
images = client.get_artist_images('mbid-here')
|
||||
print(images)
|
||||
# {
|
||||
# 'backgrounds': [{'id': '...', 'url': '...', 'likes': '100'}],
|
||||
# 'thumbnails': [...],
|
||||
# 'logos': [...],
|
||||
# 'logos_hd': [...],
|
||||
# 'banners': [...],
|
||||
# 'album_covers': [...]
|
||||
# }
|
||||
|
||||
# Get best thumbnail
|
||||
thumbnail_url = client.get_best_artist_image('mbid-here', 'thumbnail')
|
||||
|
||||
# Get album images (requires MusicBrainz release ID)
|
||||
album_images = client.get_album_images('release-mbid-here')
|
||||
|
||||
# Search for MusicBrainz ID by name
|
||||
mbid = client.search_by_artist_name('Artist Name')
|
||||
```
|
||||
|
||||
## Django Admin
|
||||
|
||||
The artwork models are registered in Django admin with useful actions:
|
||||
|
||||
### Artwork Admin
|
||||
- Filter by type, source, primary flag
|
||||
- Search by audio/channel name and URL
|
||||
- Actions:
|
||||
- Download selected artwork
|
||||
- Set as primary
|
||||
|
||||
### Music Metadata Admin
|
||||
- Filter by genre, year
|
||||
- Search by audio, album, artist
|
||||
- Actions:
|
||||
- Fetch from Last.fm
|
||||
- Update ID3 tags
|
||||
|
||||
### Artist Info Admin
|
||||
- Search by channel name, bio, tags
|
||||
- Actions:
|
||||
- Fetch from Last.fm
|
||||
|
||||
## Architecture
|
||||
|
||||
### Models
|
||||
|
||||
#### Artwork
|
||||
```python
|
||||
- audio: ForeignKey to Audio (optional)
|
||||
- channel: ForeignKey to Channel (optional)
|
||||
- artwork_type: audio_thumbnail, audio_cover, album_cover, artist_image, artist_banner, artist_logo
|
||||
- source: youtube, lastfm, fanart, manual
|
||||
- url: Remote URL
|
||||
- local_path: Local file path
|
||||
- width, height: Dimensions
|
||||
- priority: Priority for selection (higher = better)
|
||||
- is_primary: Primary artwork flag
|
||||
```
|
||||
|
||||
#### MusicMetadata
|
||||
```python
|
||||
- audio: OneToOne with Audio
|
||||
- album_name, album_artist, release_year
|
||||
- track_number, disc_number
|
||||
- genre, tags
|
||||
- lastfm_url, lastfm_mbid, play_count, listeners
|
||||
- fanart_artist_id, fanart_album_id
|
||||
```
|
||||
|
||||
#### ArtistInfo
|
||||
```python
|
||||
- channel: OneToOne with Channel
|
||||
- bio, bio_summary
|
||||
- lastfm_url, lastfm_mbid, lastfm_listeners, lastfm_playcount
|
||||
- tags, similar_artists
|
||||
- fanart_id
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
#### ID3TagService
|
||||
- Broad codec support: MP3, MP4/M4A, FLAC, OGG Vorbis, Opus, WavPack, APE, Musepack, DSF, DFF, AIFF, WAV
|
||||
- Cover art embedding/extraction for all formats
|
||||
- DSD format support (DSF, DSDIFF) with sample rate detection
|
||||
- Uses mutagen library with format-specific handlers
|
||||
|
||||
#### LastFMClient
|
||||
- Wrapper for pylast library
|
||||
- Track, album, and artist search
|
||||
- Image URL extraction
|
||||
- MusicBrainz ID retrieval
|
||||
|
||||
#### FanartClient
|
||||
- REST API client for Fanart.tv
|
||||
- High-quality artwork retrieval
|
||||
- Requires MusicBrainz IDs
|
||||
|
||||
### Celery Tasks
|
||||
|
||||
All tasks are designed to be:
|
||||
- **Asynchronous** - Don't block request handling
|
||||
- **Retryable** - Auto-retry on failure (max 3 times)
|
||||
- **Idempotent** - Safe to run multiple times
|
||||
- **Scheduled** - Run automatically via Celery Beat
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use Celery tasks** for external API calls and file operations
|
||||
2. **Check for existing artwork** before creating duplicates
|
||||
3. **Set appropriate priorities** - Fanart (30) > Last.fm (20) > YouTube (10)
|
||||
4. **Cache artwork locally** to reduce API calls
|
||||
5. **Use MusicBrainz IDs** when available for better matching
|
||||
6. **Handle missing API keys gracefully** - fallback to YouTube thumbnails
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No artwork fetched
|
||||
- Check API keys are configured correctly
|
||||
- Verify artist/track names match Last.fm database
|
||||
- Check Celery logs for errors
|
||||
|
||||
### Artwork not embedded in file
|
||||
- Ensure audio file format is supported (all major formats including DSD)
|
||||
- Check file permissions
|
||||
- Verify artwork was downloaded locally first
|
||||
- Note: Some formats (OGG, Opus) use base64-encoded pictures in metadata
|
||||
|
||||
### Last.fm API errors
|
||||
- Rate limit: 5 requests per second
|
||||
- Check API key validity
|
||||
- Some tracks may not be in database
|
||||
|
||||
### Fanart.tv API errors
|
||||
- Requires valid MusicBrainz ID
|
||||
- Free tier has rate limits
|
||||
- Not all artists have artwork
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] MusicBrainz API integration for better matching
|
||||
- [ ] Spotify API for additional metadata
|
||||
- [ ] iTunes API for artwork
|
||||
- [ ] Manual artwork upload via frontend
|
||||
- [ ] Artwork quality scoring
|
||||
- [ ] Bulk metadata import/export
|
||||
- [ ] Artwork generation for missing covers
|
||||
394
backend/audio/README_QUICK_SYNC.md
Normal file
394
backend/audio/README_QUICK_SYNC.md
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
# Quick Sync - Adaptive Streaming
|
||||
|
||||
## Overview
|
||||
|
||||
Quick Sync is an adaptive streaming system that automatically adjusts audio quality based on network speed and system resources for optimal playback experience.
|
||||
|
||||
## Features
|
||||
|
||||
### Adaptive Quality Selection
|
||||
- **Auto Mode**: Automatically selects optimal quality based on network and system
|
||||
- **Manual Modes**: Low (64kbps), Medium (128kbps), High (256kbps), Ultra (320kbps)
|
||||
- **Real-time Monitoring**: Continuous network speed and system resource monitoring
|
||||
- **Smart Buffer Management**: Dynamic buffer sizing based on connection quality
|
||||
|
||||
### Network Speed Detection
|
||||
- Measures download speed using CDN test file
|
||||
- Caches results for 5 minutes to reduce overhead
|
||||
- Speed thresholds:
|
||||
- Ultra: 5+ Mbps
|
||||
- High: 2-5 Mbps
|
||||
- Medium: 1-2 Mbps
|
||||
- Low: 0.5-1 Mbps
|
||||
|
||||
### System Resource Monitoring
|
||||
- CPU usage monitoring
|
||||
- Memory usage tracking
|
||||
- Automatic quality adjustment under high system load
|
||||
- Prevents playback issues during heavy resource usage
|
||||
|
||||
### Smart Preferences
|
||||
- **Prefer Quality**: Upgrades quality when system resources allow
|
||||
- **Adapt to System**: Downgrades quality under heavy CPU/memory load
|
||||
- **Apply to Downloads**: Uses Quick Sync settings for downloaded audio
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend Components
|
||||
|
||||
#### QuickSyncService (`audio/quick_sync_service.py`)
|
||||
Core service for adaptive streaming logic.
|
||||
|
||||
**Key Methods:**
|
||||
```python
|
||||
get_system_resources() -> Dict
|
||||
Returns CPU, memory, disk usage
|
||||
|
||||
measure_network_speed(test_url: str, timeout: int) -> float
|
||||
Measures download speed in Mbps
|
||||
|
||||
get_recommended_quality(user_preferences: Dict) -> Tuple[str, Dict]
|
||||
Returns quality level and settings based on network/system
|
||||
|
||||
get_buffer_settings(quality: str, network_speed: float) -> Dict
|
||||
Returns optimal buffer configuration
|
||||
|
||||
get_quick_sync_status(user_preferences: Dict) -> Dict
|
||||
Complete status with network, system, quality info
|
||||
```
|
||||
|
||||
**Quality Presets:**
|
||||
```python
|
||||
QUALITY_PRESETS = {
|
||||
'low': {'bitrate': 64, 'buffer_size': 5, 'preload': 'metadata'},
|
||||
'medium': {'bitrate': 128, 'buffer_size': 10, 'preload': 'auto'},
|
||||
'high': {'bitrate': 256, 'buffer_size': 15, 'preload': 'auto'},
|
||||
'ultra': {'bitrate': 320, 'buffer_size': 20, 'preload': 'auto'},
|
||||
'auto': {'bitrate': 0, 'buffer_size': 0, 'preload': 'auto'},
|
||||
}
|
||||
```
|
||||
|
||||
#### API Views (`audio/views_quick_sync.py`)
|
||||
|
||||
**QuickSyncStatusView**
|
||||
- GET `/api/audio/quick-sync/status/`
|
||||
- Returns current status and user preferences
|
||||
|
||||
**QuickSyncPreferencesView**
|
||||
- GET/POST `/api/audio/quick-sync/preferences/`
|
||||
- Manage user Quick Sync preferences
|
||||
|
||||
**QuickSyncTestView**
|
||||
- POST `/api/audio/quick-sync/test/`
|
||||
- Run manual network speed test
|
||||
|
||||
**QuickSyncQualityPresetsView**
|
||||
- GET `/api/audio/quick-sync/presets/`
|
||||
- Get available quality presets and thresholds
|
||||
|
||||
### Frontend Components
|
||||
|
||||
#### QuickSyncContext (`context/QuickSyncContext.tsx`)
|
||||
React context providing Quick Sync state management.
|
||||
|
||||
**Provided Values:**
|
||||
```typescript
|
||||
interface QuickSyncContextType {
|
||||
status: QuickSyncStatus | null;
|
||||
preferences: QuickSyncPreferences | null;
|
||||
loading: boolean;
|
||||
updatePreferences: (prefs: Partial<QuickSyncPreferences>) => Promise<void>;
|
||||
runSpeedTest: () => Promise<void>;
|
||||
refreshStatus: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
**Status Interface:**
|
||||
```typescript
|
||||
interface QuickSyncStatus {
|
||||
network: {
|
||||
speed_mbps: number;
|
||||
status: 'excellent' | 'good' | 'fair' | 'poor';
|
||||
};
|
||||
system: {
|
||||
cpu_percent: number;
|
||||
memory_percent: number;
|
||||
status: 'low_load' | 'moderate_load' | 'high_load';
|
||||
};
|
||||
quality: {
|
||||
level: 'low' | 'medium' | 'high' | 'ultra' | 'auto';
|
||||
bitrate: number;
|
||||
description: string;
|
||||
auto_selected: boolean;
|
||||
};
|
||||
buffer: {
|
||||
buffer_size: number;
|
||||
preload: string;
|
||||
max_buffer_size: number;
|
||||
rebuffer_threshold: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### QuickSyncSettings Component (`components/QuickSyncSettings.tsx`)
|
||||
Settings page UI for Quick Sync configuration.
|
||||
|
||||
**Features:**
|
||||
- Real-time network speed and system resource display
|
||||
- Quality mode selector (Auto/Ultra/High/Medium/Low)
|
||||
- Preference toggles (prefer quality, adapt to system, apply to downloads)
|
||||
- Manual speed test button
|
||||
- Buffer settings information
|
||||
- Visual status indicators with color-coded alerts
|
||||
|
||||
## Usage
|
||||
|
||||
### Backend Setup
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
pip install psutil>=5.9.0
|
||||
```
|
||||
|
||||
2. URLs are automatically included in `audio/urls.py`
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
1. Wrap app with QuickSyncProvider in `main.tsx`:
|
||||
```tsx
|
||||
import { QuickSyncProvider } from './context/QuickSyncContext';
|
||||
|
||||
<QuickSyncProvider>
|
||||
<AppWithTheme />
|
||||
</QuickSyncProvider>
|
||||
```
|
||||
|
||||
2. Import QuickSyncSettings in SettingsPage:
|
||||
```tsx
|
||||
import QuickSyncSettings from '../components/QuickSyncSettings';
|
||||
|
||||
<QuickSyncSettings />
|
||||
```
|
||||
|
||||
### Using Quick Sync in Audio Player
|
||||
|
||||
```tsx
|
||||
import { useQuickSync } from '../context/QuickSyncContext';
|
||||
|
||||
const Player = () => {
|
||||
const { status, preferences } = useQuickSync();
|
||||
|
||||
// Use recommended quality
|
||||
const quality = status?.quality.level || 'medium';
|
||||
const bitrate = status?.quality.bitrate || 128;
|
||||
|
||||
// Use buffer settings
|
||||
const bufferSize = status?.buffer.buffer_size || 10;
|
||||
const preload = status?.buffer.preload || 'auto';
|
||||
|
||||
return (
|
||||
<audio
|
||||
preload={preload}
|
||||
// Apply quality settings to audio source
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Quality Decision Logic
|
||||
|
||||
### Auto Mode Flow
|
||||
|
||||
1. **Measure Network Speed**
|
||||
- Download 1MB test file from CDN
|
||||
- Calculate speed in Mbps
|
||||
- Cache result for 5 minutes
|
||||
|
||||
2. **Check System Resources**
|
||||
- Get CPU and memory usage
|
||||
- Determine system load status
|
||||
|
||||
3. **Select Base Quality**
|
||||
- Based on network speed thresholds
|
||||
- Ultra (5+ Mbps) → High (2-5 Mbps) → Medium (1-2 Mbps) → Low (0.5-1 Mbps)
|
||||
|
||||
4. **Adjust for System Load**
|
||||
- If CPU > 80% or Memory > 85%: Downgrade by 1 level
|
||||
- If CPU < 30% and Memory < 50% and prefer_quality: Upgrade by 1 level
|
||||
|
||||
5. **Apply Buffer Settings**
|
||||
- Larger buffer for slower connections
|
||||
- Smaller buffer for fast connections
|
||||
- Rebuffer threshold at 30% of buffer size
|
||||
|
||||
## Configuration
|
||||
|
||||
### User Preferences
|
||||
|
||||
Stored in Django cache with key `quick_sync_prefs_{user_id}`:
|
||||
|
||||
```python
|
||||
{
|
||||
'mode': 'auto', # auto, low, medium, high, ultra
|
||||
'prefer_quality': True, # Prefer higher quality when possible
|
||||
'adapt_to_system': True, # Adjust based on CPU/memory
|
||||
'auto_download_quality': False, # Apply to downloads
|
||||
}
|
||||
```
|
||||
|
||||
### Cache Keys
|
||||
|
||||
- `quick_sync_network_speed`: Network speed (5 min TTL)
|
||||
- `quick_sync_system_resources`: System resources (5 min TTL)
|
||||
- `quick_sync_prefs_{user_id}`: User preferences (no TTL)
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Network Speed Testing
|
||||
- Uses 1MB download to minimize overhead
|
||||
- Cached for 5 minutes
|
||||
- Falls back to 2.0 Mbps on error
|
||||
- Uses Cloudflare speed test endpoint
|
||||
|
||||
### System Resource Monitoring
|
||||
- Uses psutil for accurate metrics
|
||||
- 1-second CPU measurement interval
|
||||
- Cached for 5 minutes
|
||||
- Minimal performance impact
|
||||
|
||||
### API Rate Limiting
|
||||
- Status endpoint called every 5 minutes
|
||||
- Manual speed test requires user action
|
||||
- Preferences cached indefinitely
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Network Speed Detection Issues
|
||||
|
||||
**Problem**: Speed test fails or returns unrealistic values
|
||||
|
||||
**Solutions:**
|
||||
- Check CDN availability (speed.cloudflare.com)
|
||||
- Verify network connectivity
|
||||
- Increase timeout value (default 5s)
|
||||
- Use custom test_url parameter
|
||||
|
||||
### System Resource Monitoring Issues
|
||||
|
||||
**Problem**: CPU/memory values incorrect
|
||||
|
||||
**Solutions:**
|
||||
- Ensure psutil is installed
|
||||
- Check system permissions
|
||||
- Verify /proc filesystem access (Linux)
|
||||
|
||||
### Quality Not Adapting
|
||||
|
||||
**Problem**: Quality doesn't change despite network/system changes
|
||||
|
||||
**Solutions:**
|
||||
- Clear cache: `cache.delete('quick_sync_network_speed')`
|
||||
- Verify preferences: mode should be 'auto'
|
||||
- Check adapt_to_system is enabled
|
||||
- Run manual speed test
|
||||
|
||||
## API Reference
|
||||
|
||||
### GET /api/audio/quick-sync/status/
|
||||
Returns Quick Sync status and preferences.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": {
|
||||
"network": {
|
||||
"speed_mbps": 3.5,
|
||||
"status": "good"
|
||||
},
|
||||
"system": {
|
||||
"cpu_percent": 25.0,
|
||||
"memory_percent": 60.0,
|
||||
"status": "low_load"
|
||||
},
|
||||
"quality": {
|
||||
"level": "high",
|
||||
"bitrate": 256,
|
||||
"description": "High quality - best experience",
|
||||
"auto_selected": true
|
||||
},
|
||||
"buffer": {
|
||||
"buffer_size": 15,
|
||||
"preload": "auto",
|
||||
"max_buffer_size": 30,
|
||||
"rebuffer_threshold": 4.5
|
||||
}
|
||||
},
|
||||
"preferences": {
|
||||
"mode": "auto",
|
||||
"prefer_quality": true,
|
||||
"adapt_to_system": true,
|
||||
"auto_download_quality": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/audio/quick-sync/preferences/
|
||||
Update user preferences.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"mode": "auto",
|
||||
"prefer_quality": true,
|
||||
"adapt_to_system": true,
|
||||
"auto_download_quality": false
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/audio/quick-sync/test/
|
||||
Run network speed test.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"network_speed_mbps": 4.2,
|
||||
"system_resources": {
|
||||
"cpu_percent": 30.0,
|
||||
"memory_percent": 55.0
|
||||
},
|
||||
"recommended_quality": "high",
|
||||
"timestamp": 1702656789.123
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/audio/quick-sync/presets/
|
||||
Get quality presets and thresholds.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"presets": {
|
||||
"low": {"bitrate": 64, "buffer_size": 5, "preload": "metadata"},
|
||||
"medium": {"bitrate": 128, "buffer_size": 10, "preload": "auto"},
|
||||
"high": {"bitrate": 256, "buffer_size": 15, "preload": "auto"},
|
||||
"ultra": {"bitrate": 320, "buffer_size": 20, "preload": "auto"}
|
||||
},
|
||||
"thresholds": {
|
||||
"ultra": 5.0,
|
||||
"high": 2.0,
|
||||
"medium": 1.0,
|
||||
"low": 0.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] ABR (Adaptive Bitrate) streaming with HLS/DASH
|
||||
- [ ] Bandwidth prediction using historical data
|
||||
- [ ] Quality switching mid-playback
|
||||
- [ ] Network type detection (WiFi/Cellular/Ethernet)
|
||||
- [ ] Offline quality presets
|
||||
- [ ] Per-device quality preferences
|
||||
- [ ] Analytics and quality metrics
|
||||
- [ ] Multi-CDN support for speed testing
|
||||
210
backend/audio/SUPPORTED_FORMATS.md
Normal file
210
backend/audio/SUPPORTED_FORMATS.md
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
# SoundWave - Supported Audio Formats
|
||||
|
||||
## Overview
|
||||
SoundWave's ID3 service provides comprehensive support for 15+ audio formats including high-resolution DSD files.
|
||||
|
||||
## Supported Formats
|
||||
|
||||
### Lossy Formats
|
||||
|
||||
| Format | Extension | Tag Format | Cover Art | Notes |
|
||||
|--------|-----------|------------|-----------|-------|
|
||||
| MP3 | `.mp3` | ID3v2 | ✅ APIC | Most common format |
|
||||
| MP4/M4A | `.m4a`, `.mp4`, `.m4b`, `.m4p` | iTunes | ✅ covr | Apple format |
|
||||
| OGG Vorbis | `.ogg`, `.oga` | Vorbis Comments | ✅ Base64 | Open format |
|
||||
| Opus | `.opus` | Vorbis Comments | ✅ Base64 | Low latency codec |
|
||||
| Musepack | `.mpc` | APEv2 | ✅ Binary | High quality lossy |
|
||||
|
||||
### Lossless Formats
|
||||
|
||||
| Format | Extension | Tag Format | Cover Art | Notes |
|
||||
|--------|-----------|------------|-----------|-------|
|
||||
| FLAC | `.flac` | Vorbis Comments | ✅ Picture | Most popular lossless |
|
||||
| WavPack | `.wv` | APEv2 | ✅ Binary | Hybrid lossless/lossy |
|
||||
| Monkey's Audio | `.ape` | APEv2 | ✅ Binary | High compression |
|
||||
| AIFF | `.aiff`, `.aif`, `.aifc` | ID3v2 | ✅ APIC | Apple Interchange |
|
||||
| WAV | `.wav` | ID3v2 | ✅ APIC | Uncompressed PCM |
|
||||
|
||||
### High-Resolution DSD Formats
|
||||
|
||||
| Format | Extension | Tag Format | Cover Art | Sample Rates | Notes |
|
||||
|--------|-----------|------------|-----------|--------------|-------|
|
||||
| DSF | `.dsf` | ID3v2 | ✅ APIC | DSD64, DSD128, DSD256 | Sony DSD Stream File |
|
||||
| DSDIFF | `.dff` | ID3v2 | ✅ APIC | DSD64, DSD128, DSD256 | Philips DSD Interchange |
|
||||
|
||||
## DSD Sample Rates
|
||||
|
||||
| Rate | Frequency | Description |
|
||||
|------|-----------|-------------|
|
||||
| DSD64 | 2.8224 MHz | Standard DSD, SACD quality |
|
||||
| DSD128 | 5.6448 MHz | Double DSD, 2x SACD |
|
||||
| DSD256 | 11.2896 MHz | Quad DSD, 4x SACD |
|
||||
| DSD512 | 22.5792 MHz | 8x SACD (rarely used) |
|
||||
|
||||
## Tag Format Details
|
||||
|
||||
### ID3v2 (MP3, DSF, DFF, AIFF, WAV)
|
||||
- **Frames**: TIT2 (title), TPE1 (artist), TALB (album), TPE2 (album artist), TDRC (year), TCON (genre), TRCK (track), TPOS (disc)
|
||||
- **Cover Art**: APIC frame with encoding, MIME type, description, and binary data
|
||||
- **Encoding**: UTF-8 (encoding=3)
|
||||
|
||||
### MP4/iTunes Tags (M4A, MP4, M4B)
|
||||
- **Atoms**: ©nam (title), ©ART (artist), ©alb (album), aART (album artist), ©day (year), ©gen (genre), trkn (track), disk (disc)
|
||||
- **Cover Art**: covr atom with MP4Cover format (JPEG or PNG)
|
||||
- **Encoding**: UTF-8
|
||||
|
||||
### Vorbis Comments (FLAC, OGG, Opus)
|
||||
- **Tags**: title, artist, album, albumartist, date, genre, tracknumber, discnumber
|
||||
- **Cover Art**:
|
||||
- FLAC: Native Picture block
|
||||
- OGG/Opus: Base64-encoded metadata_block_picture
|
||||
- **Encoding**: UTF-8
|
||||
- **Case Insensitive**: Field names are case-insensitive
|
||||
|
||||
### APEv2 (WavPack, APE, Musepack)
|
||||
- **Tags**: Title, Artist, Album, Album Artist, Year, Genre, Track, Disc
|
||||
- **Cover Art**: Binary item "Cover Art (Front)"
|
||||
- **Encoding**: UTF-8
|
||||
- **Case Sensitive**: Field names are case-sensitive
|
||||
|
||||
## Feature Comparison
|
||||
|
||||
| Feature | ID3v2 | MP4 | Vorbis | APEv2 |
|
||||
|---------|-------|-----|--------|-------|
|
||||
| Multiple Values | ✅ | ✅ | ✅ | ✅ |
|
||||
| Embedded Lyrics | ✅ | ✅ | ✅ | ❌ |
|
||||
| ReplayGain | ✅ | ❌ | ✅ | ✅ |
|
||||
| Cover Art | ✅ | ✅ | ✅ | ✅ |
|
||||
| Unicode | ✅ | ✅ | ✅ | ✅ |
|
||||
| Compression | ❌ | ❌ | ❌ | ✅ |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Read Tags from Any Format
|
||||
|
||||
```python
|
||||
from audio.id3_service import ID3TagService
|
||||
|
||||
service = ID3TagService()
|
||||
|
||||
# Works with any supported format
|
||||
for format in ['.mp3', '.m4a', '.flac', '.dsf', '.dff', '.ogg', '.wv']:
|
||||
tags = service.read_tags(f'/path/to/audio{format}')
|
||||
print(f"{format}: {tags}")
|
||||
```
|
||||
|
||||
### Write Tags to Any Format
|
||||
|
||||
```python
|
||||
tags = {
|
||||
'title': 'Song Title',
|
||||
'artist': 'Artist Name',
|
||||
'album': 'Album Name',
|
||||
'year': '2024',
|
||||
'genre': 'Jazz',
|
||||
'track_number': 5,
|
||||
}
|
||||
|
||||
# Works with any supported format
|
||||
for format in ['.mp3', '.m4a', '.flac', '.dsf']:
|
||||
service.write_tags(f'/path/to/audio{format}', tags)
|
||||
```
|
||||
|
||||
### Embed Cover Art in Any Format
|
||||
|
||||
```python
|
||||
with open('/path/to/cover.jpg', 'rb') as f:
|
||||
image_data = f.read()
|
||||
|
||||
# Works with all formats
|
||||
service.embed_cover_art('/path/to/audio.dsf', image_data, 'image/jpeg')
|
||||
service.embed_cover_art('/path/to/audio.flac', image_data, 'image/jpeg')
|
||||
service.embed_cover_art('/path/to/audio.mp3', image_data, 'image/jpeg')
|
||||
```
|
||||
|
||||
### DSD-Specific Properties
|
||||
|
||||
```python
|
||||
# DSF and DFF files include additional properties
|
||||
tags = service.read_tags('/path/to/audio.dsf')
|
||||
print(f"Sample Rate: {tags['sample_rate']} Hz") # 2822400 (DSD64)
|
||||
print(f"Channels: {tags['channels']}") # 2 (stereo)
|
||||
print(f"Bits per Sample: {tags['bits_per_sample']}") # 1 (DSD)
|
||||
print(f"Format: {tags['format']}") # DSF
|
||||
```
|
||||
|
||||
## Format Detection
|
||||
|
||||
The service automatically detects the format based on file extension:
|
||||
|
||||
```python
|
||||
service = ID3TagService()
|
||||
print(service.SUPPORTED_FORMATS)
|
||||
|
||||
# Output:
|
||||
# {
|
||||
# '.mp3': 'MP3',
|
||||
# '.m4a': 'MP4',
|
||||
# '.flac': 'FLAC',
|
||||
# '.ogg': 'OGG',
|
||||
# '.opus': 'OPUS',
|
||||
# '.wv': 'WAVPACK',
|
||||
# '.ape': 'APE',
|
||||
# '.mpc': 'MUSEPACK',
|
||||
# '.dsf': 'DSF',
|
||||
# '.dff': 'DSDIFF',
|
||||
# '.aiff': 'AIFF',
|
||||
# '.wav': 'WAVE',
|
||||
# }
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use appropriate MIME types for cover art**:
|
||||
- JPEG: `'image/jpeg'` (recommended for photos)
|
||||
- PNG: `'image/png'` (recommended for graphics/logos)
|
||||
|
||||
2. **Check format support before processing**:
|
||||
```python
|
||||
if path.suffix.lower() in service.SUPPORTED_FORMATS:
|
||||
tags = service.read_tags(path)
|
||||
```
|
||||
|
||||
3. **Handle missing tags gracefully**:
|
||||
```python
|
||||
tags = service.read_tags(file_path)
|
||||
if tags:
|
||||
title = tags.get('title', 'Unknown Title')
|
||||
```
|
||||
|
||||
4. **DSD files**: Remember that DSD files (DSF, DFF) use 1-bit encoding at very high sample rates (2.8+ MHz).
|
||||
|
||||
5. **FLAC vs DSF**:
|
||||
- FLAC: PCM-based, wider software support, smaller file size
|
||||
- DSF: Native DSD, higher fidelity for DSD sources, larger files
|
||||
|
||||
## Limitations
|
||||
|
||||
- **WAV**: Limited tag support (ID3 chunks not universally supported)
|
||||
- **OGG/Opus**: Cover art stored as base64 (larger than binary)
|
||||
- **APE**: Windows-centric format, limited Linux support
|
||||
- **DSD**: Large file sizes (1-bit @ 2.8+ MHz = ~5.6 Mbps for DSD64)
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- **Read operations**: Fast for all formats (~1-5ms per file)
|
||||
- **Write operations**: Vary by format:
|
||||
- Fastest: Vorbis comments (~5ms)
|
||||
- Fast: ID3v2, MP4 (~10ms)
|
||||
- Moderate: APEv2 (~20ms)
|
||||
- **Cover art embedding**: Adds ~50-100ms depending on image size
|
||||
- **DSD files**: Slower due to large file sizes (100MB+ common)
|
||||
|
||||
## References
|
||||
|
||||
- ID3v2.4: http://id3.org/id3v2.4.0-structure
|
||||
- MP4 atoms: https://developer.apple.com/library/archive/documentation/QuickTime/QTFF/
|
||||
- Vorbis Comments: https://www.xiph.org/vorbis/doc/v-comment.html
|
||||
- APEv2: https://wiki.hydrogenaud.io/index.php?title=APEv2_specification
|
||||
- DSF: https://dsd-guide.com/sites/default/files/white-papers/DSFFileFormatSpec_E.pdf
|
||||
- Mutagen docs: https://mutagen.readthedocs.io/
|
||||
1
backend/audio/__init__.py
Normal file
1
backend/audio/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Audio app
|
||||
25
backend/audio/admin.py
Normal file
25
backend/audio/admin.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Audio admin"""
|
||||
|
||||
from django.contrib import admin
|
||||
from audio.models import Audio, AudioProgress
|
||||
|
||||
|
||||
@admin.register(Audio)
|
||||
class AudioAdmin(admin.ModelAdmin):
|
||||
"""Audio admin"""
|
||||
list_display = ('title', 'channel_name', 'duration', 'published_date', 'play_count', 'has_lyrics')
|
||||
list_filter = ('channel_name', 'audio_format', 'published_date')
|
||||
search_fields = ('title', 'channel_name', 'youtube_id')
|
||||
readonly_fields = ('downloaded_date', 'play_count', 'last_played', 'downloaded', 'has_lyrics')
|
||||
|
||||
|
||||
@admin.register(AudioProgress)
|
||||
class AudioProgressAdmin(admin.ModelAdmin):
|
||||
"""Audio progress admin"""
|
||||
list_display = ('user', 'audio', 'position', 'completed', 'last_updated')
|
||||
list_filter = ('completed', 'last_updated')
|
||||
search_fields = ('user__username', 'audio__title')
|
||||
|
||||
|
||||
# Import lyrics admin
|
||||
from audio.admin_lyrics import * # noqa
|
||||
247
backend/audio/admin_artwork.py
Normal file
247
backend/audio/admin_artwork.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
"""Admin configuration for artwork and metadata"""
|
||||
from django.contrib import admin
|
||||
from audio.models_artwork import Artwork, MusicMetadata, ArtistInfo
|
||||
|
||||
|
||||
@admin.register(Artwork)
|
||||
class ArtworkAdmin(admin.ModelAdmin):
|
||||
"""Admin for Artwork model"""
|
||||
|
||||
list_display = [
|
||||
'id',
|
||||
'audio',
|
||||
'channel',
|
||||
'artwork_type',
|
||||
'source',
|
||||
'priority',
|
||||
'is_primary',
|
||||
'has_local_file',
|
||||
'created_at',
|
||||
]
|
||||
list_filter = [
|
||||
'artwork_type',
|
||||
'source',
|
||||
'is_primary',
|
||||
'created_at',
|
||||
]
|
||||
search_fields = [
|
||||
'audio__audio_title',
|
||||
'channel__channel_name',
|
||||
'url',
|
||||
]
|
||||
readonly_fields = ['created_at']
|
||||
ordering = ['-priority', '-created_at']
|
||||
|
||||
fieldsets = (
|
||||
('Related Objects', {
|
||||
'fields': ('audio', 'channel')
|
||||
}),
|
||||
('Artwork Details', {
|
||||
'fields': ('artwork_type', 'source', 'url', 'local_path')
|
||||
}),
|
||||
('Image Properties', {
|
||||
'fields': ('width', 'height', 'priority', 'is_primary')
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('created_at',)
|
||||
}),
|
||||
)
|
||||
|
||||
def has_local_file(self, obj):
|
||||
"""Check if artwork has local file"""
|
||||
return bool(obj.local_path)
|
||||
has_local_file.boolean = True
|
||||
has_local_file.short_description = 'Local File'
|
||||
|
||||
actions = ['download_artwork', 'set_as_primary']
|
||||
|
||||
def download_artwork(self, request, queryset):
|
||||
"""Download artwork from URLs"""
|
||||
from audio.tasks_artwork import download_artwork
|
||||
count = 0
|
||||
for artwork in queryset:
|
||||
if artwork.url:
|
||||
download_artwork.delay(artwork.id)
|
||||
count += 1
|
||||
self.message_user(request, f'Queued {count} artwork downloads')
|
||||
download_artwork.short_description = 'Download selected artwork'
|
||||
|
||||
def set_as_primary(self, request, queryset):
|
||||
"""Set selected artwork as primary"""
|
||||
count = 0
|
||||
for artwork in queryset:
|
||||
# Unset other primary
|
||||
if artwork.audio:
|
||||
Artwork.objects.filter(
|
||||
audio=artwork.audio,
|
||||
artwork_type=artwork.artwork_type
|
||||
).update(is_primary=False)
|
||||
elif artwork.channel:
|
||||
Artwork.objects.filter(
|
||||
channel=artwork.channel,
|
||||
artwork_type=artwork.artwork_type
|
||||
).update(is_primary=False)
|
||||
|
||||
artwork.is_primary = True
|
||||
artwork.save()
|
||||
count += 1
|
||||
self.message_user(request, f'Set {count} artwork as primary')
|
||||
set_as_primary.short_description = 'Set as primary'
|
||||
|
||||
|
||||
@admin.register(MusicMetadata)
|
||||
class MusicMetadataAdmin(admin.ModelAdmin):
|
||||
"""Admin for MusicMetadata model"""
|
||||
|
||||
list_display = [
|
||||
'id',
|
||||
'audio',
|
||||
'album_name',
|
||||
'album_artist',
|
||||
'genre',
|
||||
'release_year',
|
||||
'play_count',
|
||||
'listeners',
|
||||
'updated_at',
|
||||
]
|
||||
list_filter = [
|
||||
'genre',
|
||||
'release_year',
|
||||
'updated_at',
|
||||
]
|
||||
search_fields = [
|
||||
'audio__audio_title',
|
||||
'album_name',
|
||||
'album_artist',
|
||||
'genre',
|
||||
]
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
ordering = ['-updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Audio', {
|
||||
'fields': ('audio',)
|
||||
}),
|
||||
('Album Information', {
|
||||
'fields': (
|
||||
'album_name',
|
||||
'album_artist',
|
||||
'release_year',
|
||||
'track_number',
|
||||
'disc_number',
|
||||
)
|
||||
}),
|
||||
('Genre & Tags', {
|
||||
'fields': ('genre', 'tags')
|
||||
}),
|
||||
('Last.fm Data', {
|
||||
'fields': (
|
||||
'lastfm_url',
|
||||
'lastfm_mbid',
|
||||
'play_count',
|
||||
'listeners',
|
||||
)
|
||||
}),
|
||||
('Fanart.tv IDs', {
|
||||
'fields': ('fanart_artist_id', 'fanart_album_id')
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('created_at', 'updated_at')
|
||||
}),
|
||||
)
|
||||
|
||||
actions = ['fetch_from_lastfm', 'update_id3_tags']
|
||||
|
||||
def fetch_from_lastfm(self, request, queryset):
|
||||
"""Fetch metadata from Last.fm"""
|
||||
from audio.tasks_artwork import fetch_metadata_for_audio
|
||||
count = 0
|
||||
for metadata in queryset:
|
||||
fetch_metadata_for_audio.delay(metadata.audio.id)
|
||||
count += 1
|
||||
self.message_user(request, f'Queued {count} metadata fetches')
|
||||
fetch_from_lastfm.short_description = 'Fetch from Last.fm'
|
||||
|
||||
def update_id3_tags(self, request, queryset):
|
||||
"""Update ID3 tags in audio files"""
|
||||
from audio.tasks_artwork import update_id3_tags_from_metadata
|
||||
count = 0
|
||||
for metadata in queryset:
|
||||
update_id3_tags_from_metadata.delay(metadata.audio.id)
|
||||
count += 1
|
||||
self.message_user(request, f'Queued {count} ID3 tag updates')
|
||||
update_id3_tags.short_description = 'Update ID3 tags'
|
||||
|
||||
|
||||
@admin.register(ArtistInfo)
|
||||
class ArtistInfoAdmin(admin.ModelAdmin):
|
||||
"""Admin for ArtistInfo model"""
|
||||
|
||||
list_display = [
|
||||
'id',
|
||||
'channel',
|
||||
'lastfm_listeners',
|
||||
'lastfm_playcount',
|
||||
'has_bio',
|
||||
'tags_count',
|
||||
'updated_at',
|
||||
]
|
||||
list_filter = [
|
||||
'updated_at',
|
||||
]
|
||||
search_fields = [
|
||||
'channel__channel_name',
|
||||
'bio',
|
||||
'tags',
|
||||
]
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
ordering = ['-updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Channel', {
|
||||
'fields': ('channel',)
|
||||
}),
|
||||
('Biography', {
|
||||
'fields': ('bio', 'bio_summary')
|
||||
}),
|
||||
('Last.fm Data', {
|
||||
'fields': (
|
||||
'lastfm_url',
|
||||
'lastfm_mbid',
|
||||
'lastfm_listeners',
|
||||
'lastfm_playcount',
|
||||
)
|
||||
}),
|
||||
('Tags & Similar', {
|
||||
'fields': ('tags', 'similar_artists')
|
||||
}),
|
||||
('Fanart.tv', {
|
||||
'fields': ('fanart_id',)
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('created_at', 'updated_at')
|
||||
}),
|
||||
)
|
||||
|
||||
def has_bio(self, obj):
|
||||
"""Check if artist has bio"""
|
||||
return bool(obj.bio)
|
||||
has_bio.boolean = True
|
||||
has_bio.short_description = 'Has Bio'
|
||||
|
||||
def tags_count(self, obj):
|
||||
"""Get number of tags"""
|
||||
return len(obj.tags) if obj.tags else 0
|
||||
tags_count.short_description = 'Tags'
|
||||
|
||||
actions = ['fetch_from_lastfm']
|
||||
|
||||
def fetch_from_lastfm(self, request, queryset):
|
||||
"""Fetch artist info from Last.fm"""
|
||||
from audio.tasks_artwork import fetch_artist_info
|
||||
count = 0
|
||||
for artist_info in queryset:
|
||||
fetch_artist_info.delay(artist_info.channel.id)
|
||||
count += 1
|
||||
self.message_user(request, f'Queued {count} artist info fetches')
|
||||
fetch_from_lastfm.short_description = 'Fetch from Last.fm'
|
||||
117
backend/audio/admin_lyrics.py
Normal file
117
backend/audio/admin_lyrics.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Admin interface for lyrics"""
|
||||
from django.contrib import admin
|
||||
from audio.models_lyrics import Lyrics, LyricsCache
|
||||
|
||||
|
||||
@admin.register(Lyrics)
|
||||
class LyricsAdmin(admin.ModelAdmin):
|
||||
"""Admin for Lyrics model"""
|
||||
|
||||
list_display = [
|
||||
'audio',
|
||||
'has_lyrics',
|
||||
'is_synced',
|
||||
'is_instrumental',
|
||||
'source',
|
||||
'language',
|
||||
'fetch_attempted',
|
||||
'fetch_attempts',
|
||||
'fetched_date',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'is_instrumental',
|
||||
'source',
|
||||
'language',
|
||||
'fetch_attempted',
|
||||
'fetched_date',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'audio__title',
|
||||
'audio__channel_name',
|
||||
'audio__youtube_id',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'audio',
|
||||
'fetched_date',
|
||||
'has_lyrics',
|
||||
'is_synced',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Audio Information', {
|
||||
'fields': ('audio',)
|
||||
}),
|
||||
('Lyrics', {
|
||||
'fields': ('synced_lyrics', 'plain_lyrics', 'is_instrumental')
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('source', 'language')
|
||||
}),
|
||||
('Fetch Status', {
|
||||
'fields': ('fetch_attempted', 'fetch_attempts', 'last_error', 'fetched_date')
|
||||
}),
|
||||
('Properties', {
|
||||
'fields': ('has_lyrics', 'is_synced'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(LyricsCache)
|
||||
class LyricsCacheAdmin(admin.ModelAdmin):
|
||||
"""Admin for LyricsCache model"""
|
||||
|
||||
list_display = [
|
||||
'title',
|
||||
'artist_name',
|
||||
'duration',
|
||||
'source',
|
||||
'cached_date',
|
||||
'access_count',
|
||||
'not_found',
|
||||
]
|
||||
|
||||
list_filter = [
|
||||
'source',
|
||||
'not_found',
|
||||
'cached_date',
|
||||
'language',
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
'title',
|
||||
'artist_name',
|
||||
'album_name',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
'cached_date',
|
||||
'last_accessed',
|
||||
'access_count',
|
||||
]
|
||||
|
||||
fieldsets = (
|
||||
('Track Information', {
|
||||
'fields': ('title', 'artist_name', 'album_name', 'duration')
|
||||
}),
|
||||
('Cached Lyrics', {
|
||||
'fields': ('synced_lyrics', 'plain_lyrics', 'is_instrumental')
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('source', 'language', 'not_found')
|
||||
}),
|
||||
('Cache Statistics', {
|
||||
'fields': ('cached_date', 'last_accessed', 'access_count')
|
||||
}),
|
||||
)
|
||||
|
||||
actions = ['clear_not_found']
|
||||
|
||||
def clear_not_found(self, request, queryset):
|
||||
"""Clear not_found cache entries"""
|
||||
count = queryset.filter(not_found=True).delete()[0]
|
||||
self.message_user(request, f'Cleared {count} not_found cache entries')
|
||||
clear_not_found.short_description = 'Clear not_found entries'
|
||||
294
backend/audio/fanart_client.py
Normal file
294
backend/audio/fanart_client.py
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
"""Fanart.tv API client for fetching artist and album artwork"""
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FanartClient:
|
||||
"""Client for Fanart.tv API"""
|
||||
|
||||
# Register for API key at: https://fanart.tv/get-an-api-key/
|
||||
API_KEY = getattr(settings, 'FANART_API_KEY', '')
|
||||
BASE_URL = 'http://webservice.fanart.tv/v3'
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
self.api_key = api_key or self.API_KEY
|
||||
if not self.api_key:
|
||||
logger.warning("Fanart.tv API key not configured")
|
||||
|
||||
def get_artist_images(self, musicbrainz_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get artist images by MusicBrainz ID
|
||||
|
||||
Args:
|
||||
musicbrainz_id: MusicBrainz artist ID
|
||||
|
||||
Returns:
|
||||
Dictionary with artist images organized by type
|
||||
"""
|
||||
if not self.api_key:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{self.BASE_URL}/music/{musicbrainz_id}"
|
||||
params = {'api_key': self.api_key}
|
||||
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Organize images by type
|
||||
images = {
|
||||
'backgrounds': [],
|
||||
'thumbnails': [],
|
||||
'logos': [],
|
||||
'logos_hd': [],
|
||||
'banners': [],
|
||||
'album_covers': []
|
||||
}
|
||||
|
||||
# Artist backgrounds
|
||||
if 'artistbackground' in data:
|
||||
for img in data['artistbackground']:
|
||||
images['backgrounds'].append({
|
||||
'id': img['id'],
|
||||
'url': img['url'],
|
||||
'likes': img.get('likes', '0')
|
||||
})
|
||||
|
||||
# Artist thumbnails
|
||||
if 'artistthumb' in data:
|
||||
for img in data['artistthumb']:
|
||||
images['thumbnails'].append({
|
||||
'id': img['id'],
|
||||
'url': img['url'],
|
||||
'likes': img.get('likes', '0')
|
||||
})
|
||||
|
||||
# Music logos
|
||||
if 'musiclogo' in data:
|
||||
for img in data['musiclogo']:
|
||||
images['logos'].append({
|
||||
'id': img['id'],
|
||||
'url': img['url'],
|
||||
'likes': img.get('likes', '0')
|
||||
})
|
||||
|
||||
# HD Music logos
|
||||
if 'hdmusiclogo' in data:
|
||||
for img in data['hdmusiclogo']:
|
||||
images['logos_hd'].append({
|
||||
'id': img['id'],
|
||||
'url': img['url'],
|
||||
'likes': img.get('likes', '0')
|
||||
})
|
||||
|
||||
# Music banners
|
||||
if 'musicbanner' in data:
|
||||
for img in data['musicbanner']:
|
||||
images['banners'].append({
|
||||
'id': img['id'],
|
||||
'url': img['url'],
|
||||
'likes': img.get('likes', '0')
|
||||
})
|
||||
|
||||
# Album covers
|
||||
if 'albums' in data:
|
||||
for album_id, album_data in data['albums'].items():
|
||||
if 'albumcover' in album_data:
|
||||
for img in album_data['albumcover']:
|
||||
images['album_covers'].append({
|
||||
'id': img['id'],
|
||||
'url': img['url'],
|
||||
'album_id': album_id,
|
||||
'likes': img.get('likes', '0')
|
||||
})
|
||||
|
||||
# Sort by likes (descending)
|
||||
for category in images:
|
||||
images[category].sort(key=lambda x: int(x['likes']), reverse=True)
|
||||
|
||||
return images
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.warning(f"Fanart.tv artist not found: {musicbrainz_id}")
|
||||
else:
|
||||
logger.error(f"HTTP error fetching artist from Fanart.tv: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching artist from Fanart.tv: {e}")
|
||||
return None
|
||||
|
||||
def get_album_images(self, musicbrainz_release_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get album images by MusicBrainz release ID
|
||||
|
||||
Args:
|
||||
musicbrainz_release_id: MusicBrainz release ID
|
||||
|
||||
Returns:
|
||||
Dictionary with album images
|
||||
"""
|
||||
if not self.api_key:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"{self.BASE_URL}/music/albums/{musicbrainz_release_id}"
|
||||
params = {'api_key': self.api_key}
|
||||
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
images = {
|
||||
'covers': [],
|
||||
'discs': []
|
||||
}
|
||||
|
||||
# Album covers
|
||||
if 'albums' in data:
|
||||
for album_id, album_data in data['albums'].items():
|
||||
if 'albumcover' in album_data:
|
||||
for img in album_data['albumcover']:
|
||||
images['covers'].append({
|
||||
'id': img['id'],
|
||||
'url': img['url'],
|
||||
'likes': img.get('likes', '0')
|
||||
})
|
||||
|
||||
# CD art
|
||||
if 'cdart' in album_data:
|
||||
for img in album_data['cdart']:
|
||||
images['discs'].append({
|
||||
'id': img['id'],
|
||||
'url': img['url'],
|
||||
'disc': img.get('disc', '1'),
|
||||
'likes': img.get('likes', '0')
|
||||
})
|
||||
|
||||
# Sort by likes
|
||||
images['covers'].sort(key=lambda x: int(x['likes']), reverse=True)
|
||||
images['discs'].sort(key=lambda x: int(x['likes']), reverse=True)
|
||||
|
||||
return images
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.warning(f"Fanart.tv album not found: {musicbrainz_release_id}")
|
||||
else:
|
||||
logger.error(f"HTTP error fetching album from Fanart.tv: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching album from Fanart.tv: {e}")
|
||||
return None
|
||||
|
||||
def get_best_artist_image(self, musicbrainz_id: str, image_type: str = 'thumbnail') -> Optional[str]:
|
||||
"""
|
||||
Get best (most liked) artist image of specific type
|
||||
|
||||
Args:
|
||||
musicbrainz_id: MusicBrainz artist ID
|
||||
image_type: Type of image ('thumbnail', 'background', 'logo', 'logo_hd', 'banner')
|
||||
|
||||
Returns:
|
||||
URL of the best image or None
|
||||
"""
|
||||
images = self.get_artist_images(musicbrainz_id)
|
||||
if not images:
|
||||
return None
|
||||
|
||||
# Map to correct key
|
||||
type_map = {
|
||||
'thumbnail': 'thumbnails',
|
||||
'background': 'backgrounds',
|
||||
'logo': 'logos',
|
||||
'logo_hd': 'logos_hd',
|
||||
'banner': 'banners'
|
||||
}
|
||||
|
||||
key = type_map.get(image_type, image_type)
|
||||
if key in images and images[key]:
|
||||
return images[key][0]['url'] # First item is most liked
|
||||
|
||||
return None
|
||||
|
||||
def get_best_album_cover(self, musicbrainz_release_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get best (most liked) album cover
|
||||
|
||||
Args:
|
||||
musicbrainz_release_id: MusicBrainz release ID
|
||||
|
||||
Returns:
|
||||
URL of the best cover or None
|
||||
"""
|
||||
images = self.get_album_images(musicbrainz_release_id)
|
||||
if not images or not images.get('covers'):
|
||||
return None
|
||||
|
||||
return images['covers'][0]['url'] # First item is most liked
|
||||
|
||||
def download_image(self, url: str, output_path: str) -> bool:
|
||||
"""
|
||||
Download image from URL
|
||||
|
||||
Args:
|
||||
url: Image URL
|
||||
output_path: Local path to save image
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
response = requests.get(url, timeout=30, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
logger.info(f"Downloaded image to {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading image from {url}: {e}")
|
||||
return False
|
||||
|
||||
def search_by_artist_name(self, artist_name: str) -> Optional[str]:
|
||||
"""
|
||||
Search for MusicBrainz ID by artist name
|
||||
Note: Fanart.tv doesn't have a search endpoint, so this uses MusicBrainz API
|
||||
|
||||
Args:
|
||||
artist_name: Artist name to search for
|
||||
|
||||
Returns:
|
||||
MusicBrainz artist ID or None
|
||||
"""
|
||||
try:
|
||||
# Use MusicBrainz API for search
|
||||
url = 'https://musicbrainz.org/ws/2/artist/'
|
||||
params = {
|
||||
'query': f'artist:{artist_name}',
|
||||
'fmt': 'json',
|
||||
'limit': 1
|
||||
}
|
||||
headers = {
|
||||
'User-Agent': 'SoundWave/1.0 (https://github.com/tubearchivist/tubearchivist)'
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get('artists') and len(data['artists']) > 0:
|
||||
return data['artists'][0]['id']
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for artist on MusicBrainz: {e}")
|
||||
|
||||
return None
|
||||
632
backend/audio/id3_service.py
Normal file
632
backend/audio/id3_service.py
Normal file
|
|
@ -0,0 +1,632 @@
|
|||
"""ID3 tag service for reading and writing audio metadata with broad codec support"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TPE2, TDRC, TCON, TRCK, TPOS
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
from mutagen.oggopus import OggOpus
|
||||
from mutagen.wavpack import WavPack
|
||||
from mutagen.musepack import Musepack
|
||||
from mutagen.monkeysaudio import MonkeysAudio
|
||||
from mutagen.aiff import AIFF
|
||||
from mutagen.wave import WAVE
|
||||
from mutagen.dsf import DSF
|
||||
from mutagen.dsdiff import DSDIFF
|
||||
from mutagen.mp3 import MP3
|
||||
import base64
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ID3TagService:
|
||||
"""Service for reading and writing ID3 tags with broad codec support including DSD"""
|
||||
|
||||
SUPPORTED_FORMATS = {
|
||||
# Lossy formats
|
||||
'.mp3': 'MP3',
|
||||
'.m4a': 'MP4',
|
||||
'.m4b': 'MP4',
|
||||
'.m4p': 'MP4',
|
||||
'.mp4': 'MP4',
|
||||
'.ogg': 'OGG',
|
||||
'.oga': 'OGG',
|
||||
'.opus': 'OPUS',
|
||||
'.mpc': 'MUSEPACK',
|
||||
|
||||
# Lossless formats
|
||||
'.flac': 'FLAC',
|
||||
'.wv': 'WAVPACK',
|
||||
'.ape': 'APE',
|
||||
'.aiff': 'AIFF',
|
||||
'.aif': 'AIFF',
|
||||
'.aifc': 'AIFF',
|
||||
'.wav': 'WAVE',
|
||||
|
||||
# High-resolution DSD formats
|
||||
'.dsf': 'DSF',
|
||||
'.dff': 'DSDIFF',
|
||||
}
|
||||
|
||||
def read_tags(self, file_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Read tags from audio file
|
||||
|
||||
Supports: MP3, MP4/M4A, FLAC, OGG Vorbis, Opus, WavPack, APE, Musepack,
|
||||
DSF, DFF (DSDIFF), AIFF, WAV
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
|
||||
Returns:
|
||||
Dictionary with tag information or None if error
|
||||
"""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return None
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
format_type = self.SUPPORTED_FORMATS.get(suffix)
|
||||
|
||||
if not format_type:
|
||||
logger.warning(f"Unsupported audio format: {suffix}")
|
||||
return None
|
||||
|
||||
# Handle different audio formats
|
||||
if format_type == 'MP4':
|
||||
audio = MP4(file_path)
|
||||
tags = self._read_mp4_tags(audio)
|
||||
elif format_type == 'MP3':
|
||||
audio = MP3(file_path)
|
||||
tags = self._read_id3_tags(audio)
|
||||
elif format_type == 'FLAC':
|
||||
audio = FLAC(file_path)
|
||||
tags = self._read_vorbis_tags(audio)
|
||||
elif format_type in ['OGG', 'OPUS']:
|
||||
audio = OggVorbis(file_path) if format_type == 'OGG' else OggOpus(file_path)
|
||||
tags = self._read_vorbis_tags(audio)
|
||||
elif format_type == 'WAVPACK':
|
||||
audio = WavPack(file_path)
|
||||
tags = self._read_apev2_tags(audio)
|
||||
elif format_type == 'APE':
|
||||
audio = MonkeysAudio(file_path)
|
||||
tags = self._read_apev2_tags(audio)
|
||||
elif format_type == 'MUSEPACK':
|
||||
audio = Musepack(file_path)
|
||||
tags = self._read_apev2_tags(audio)
|
||||
elif format_type == 'DSF':
|
||||
audio = DSF(file_path)
|
||||
tags = self._read_dsf_tags(audio)
|
||||
elif format_type == 'DSDIFF':
|
||||
audio = DSDIFF(file_path)
|
||||
tags = self._read_dsdiff_tags(audio)
|
||||
elif format_type == 'AIFF':
|
||||
audio = AIFF(file_path)
|
||||
tags = self._read_id3_tags(audio)
|
||||
elif format_type == 'WAVE':
|
||||
audio = WAVE(file_path)
|
||||
tags = self._read_id3_tags(audio)
|
||||
else:
|
||||
logger.warning(f"Unsupported format type: {format_type}")
|
||||
return None
|
||||
|
||||
# Add audio properties
|
||||
if hasattr(audio, 'info'):
|
||||
tags['duration'] = getattr(audio.info, 'length', 0)
|
||||
tags['bitrate'] = getattr(audio.info, 'bitrate', 0)
|
||||
# DSD-specific properties
|
||||
if format_type in ['DSF', 'DSDIFF']:
|
||||
tags['sample_rate'] = getattr(audio.info, 'sample_rate', 0)
|
||||
tags['channels'] = getattr(audio.info, 'channels', 0)
|
||||
tags['bits_per_sample'] = getattr(audio.info, 'bits_per_sample', 1)
|
||||
|
||||
tags['format'] = format_type
|
||||
|
||||
return tags
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading tags from {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def _read_mp4_tags(self, audio: MP4) -> Dict[str, Any]:
|
||||
"""Read tags from MP4/M4A file"""
|
||||
tags = {
|
||||
'title': audio.get('\xa9nam', [''])[0] if '\xa9nam' in audio else '',
|
||||
'artist': audio.get('\xa9ART', [''])[0] if '\xa9ART' in audio else '',
|
||||
'album': audio.get('\xa9alb', [''])[0] if '\xa9alb' in audio else '',
|
||||
'album_artist': audio.get('aART', [''])[0] if 'aART' in audio else '',
|
||||
'year': audio.get('\xa9day', [''])[0] if '\xa9day' in audio else '',
|
||||
'genre': audio.get('\xa9gen', [''])[0] if '\xa9gen' in audio else '',
|
||||
'has_cover': 'covr' in audio,
|
||||
}
|
||||
|
||||
# Track number
|
||||
if 'trkn' in audio:
|
||||
track_info = audio['trkn'][0]
|
||||
tags['track_number'] = track_info[0] if track_info[0] > 0 else None
|
||||
|
||||
# Disc number
|
||||
if 'disk' in audio:
|
||||
disc_info = audio['disk'][0]
|
||||
tags['disc_number'] = disc_info[0] if disc_info[0] > 0 else None
|
||||
|
||||
return tags
|
||||
|
||||
def _read_id3_tags(self, audio) -> Dict[str, Any]:
|
||||
"""Read tags from ID3 format (MP3, AIFF, WAV, DSF, DFF)"""
|
||||
tags = self._empty_tags()
|
||||
|
||||
if not hasattr(audio, 'tags') or audio.tags is None:
|
||||
return tags
|
||||
|
||||
id3 = audio.tags
|
||||
|
||||
tags['title'] = str(id3.get('TIT2', ''))
|
||||
tags['artist'] = str(id3.get('TPE1', ''))
|
||||
tags['album'] = str(id3.get('TALB', ''))
|
||||
tags['album_artist'] = str(id3.get('TPE2', ''))
|
||||
tags['genre'] = str(id3.get('TCON', ''))
|
||||
|
||||
# Year
|
||||
if 'TDRC' in id3:
|
||||
tags['year'] = str(id3['TDRC'])
|
||||
|
||||
# Track number
|
||||
if 'TRCK' in id3:
|
||||
track_str = str(id3['TRCK'])
|
||||
try:
|
||||
tags['track_number'] = int(track_str.split('/')[0]) if '/' in track_str else int(track_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Disc number
|
||||
if 'TPOS' in id3:
|
||||
disc_str = str(id3['TPOS'])
|
||||
try:
|
||||
tags['disc_number'] = int(disc_str.split('/')[0]) if '/' in disc_str else int(disc_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Check for cover art
|
||||
tags['has_cover'] = any(key.startswith('APIC') for key in id3.keys())
|
||||
|
||||
return tags
|
||||
|
||||
def _read_vorbis_tags(self, audio) -> Dict[str, Any]:
|
||||
"""Read tags from Vorbis comment format (FLAC, OGG, Opus)"""
|
||||
tags = {
|
||||
'title': audio.get('title', [''])[0],
|
||||
'artist': audio.get('artist', [''])[0],
|
||||
'album': audio.get('album', [''])[0],
|
||||
'album_artist': audio.get('albumartist', [''])[0] or audio.get('album artist', [''])[0],
|
||||
'year': audio.get('date', [''])[0] or audio.get('year', [''])[0],
|
||||
'genre': audio.get('genre', [''])[0],
|
||||
'has_cover': False,
|
||||
}
|
||||
|
||||
# Check for embedded pictures (FLAC)
|
||||
if hasattr(audio, 'pictures'):
|
||||
tags['has_cover'] = len(audio.pictures) > 0
|
||||
elif 'metadata_block_picture' in audio:
|
||||
tags['has_cover'] = True
|
||||
|
||||
# Track number
|
||||
track = audio.get('tracknumber', [''])[0]
|
||||
if track:
|
||||
try:
|
||||
tags['track_number'] = int(track.split('/')[0]) if '/' in track else int(track)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Disc number
|
||||
disc = audio.get('discnumber', [''])[0]
|
||||
if disc:
|
||||
try:
|
||||
tags['disc_number'] = int(disc.split('/')[0]) if '/' in disc else int(disc)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return tags
|
||||
|
||||
def _read_apev2_tags(self, audio) -> Dict[str, Any]:
|
||||
"""Read tags from APEv2 format (WavPack, APE, Musepack)"""
|
||||
tags = {
|
||||
'title': str(audio.get('Title', [''])[0]) if audio.get('Title') else '',
|
||||
'artist': str(audio.get('Artist', [''])[0]) if audio.get('Artist') else '',
|
||||
'album': str(audio.get('Album', [''])[0]) if audio.get('Album') else '',
|
||||
'album_artist': str(audio.get('Album Artist', [''])[0]) if audio.get('Album Artist') else '',
|
||||
'year': str(audio.get('Year', [''])[0]) if audio.get('Year') else '',
|
||||
'genre': str(audio.get('Genre', [''])[0]) if audio.get('Genre') else '',
|
||||
'has_cover': audio.get('Cover Art (Front)') is not None,
|
||||
}
|
||||
|
||||
# Track number
|
||||
track = audio.get('Track')
|
||||
if track:
|
||||
track_str = str(track[0]) if isinstance(track, list) else str(track)
|
||||
try:
|
||||
tags['track_number'] = int(track_str.split('/')[0]) if '/' in track_str else int(track_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Disc number
|
||||
disc = audio.get('Disc')
|
||||
if disc:
|
||||
disc_str = str(disc[0]) if isinstance(disc, list) else str(disc)
|
||||
try:
|
||||
tags['disc_number'] = int(disc_str.split('/')[0]) if '/' in disc_str else int(disc_str)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return tags
|
||||
|
||||
def _read_dsf_tags(self, audio: DSF) -> Dict[str, Any]:
|
||||
"""Read tags from DSF file (DSD Stream File)"""
|
||||
# DSF uses ID3v2 tags
|
||||
if hasattr(audio, 'tags') and audio.tags:
|
||||
return self._read_id3_tags(audio)
|
||||
return self._empty_tags()
|
||||
|
||||
def _read_dsdiff_tags(self, audio: DSDIFF) -> Dict[str, Any]:
|
||||
"""Read tags from DSDIFF/DFF file"""
|
||||
# DSDIFF uses ID3v2 tags
|
||||
if hasattr(audio, 'tags') and audio.tags:
|
||||
return self._read_id3_tags(audio)
|
||||
return self._empty_tags()
|
||||
|
||||
def _empty_tags(self) -> Dict[str, Any]:
|
||||
"""Return empty tags structure"""
|
||||
return {
|
||||
'title': '',
|
||||
'artist': '',
|
||||
'album': '',
|
||||
'album_artist': '',
|
||||
'year': '',
|
||||
'genre': '',
|
||||
'track_number': None,
|
||||
'disc_number': None,
|
||||
'has_cover': False,
|
||||
}
|
||||
|
||||
def write_tags(self, file_path: str, tags: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Write tags to audio file
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
tags: Dictionary with tag values
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return False
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
format_type = self.SUPPORTED_FORMATS.get(suffix)
|
||||
|
||||
if not format_type:
|
||||
logger.warning(f"Unsupported audio format for writing: {suffix}")
|
||||
return False
|
||||
|
||||
# Handle different audio formats
|
||||
if format_type == 'MP4':
|
||||
audio = MP4(file_path)
|
||||
self._write_mp4_tags(audio, tags)
|
||||
elif format_type == 'MP3':
|
||||
audio = MP3(file_path)
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
self._write_id3_tags(audio.tags, tags)
|
||||
elif format_type in ['FLAC', 'OGG', 'OPUS']:
|
||||
if format_type == 'FLAC':
|
||||
audio = FLAC(file_path)
|
||||
elif format_type == 'OGG':
|
||||
audio = OggVorbis(file_path)
|
||||
else:
|
||||
audio = OggOpus(file_path)
|
||||
self._write_vorbis_tags(audio, tags)
|
||||
elif format_type in ['WAVPACK', 'APE', 'MUSEPACK']:
|
||||
if format_type == 'WAVPACK':
|
||||
audio = WavPack(file_path)
|
||||
elif format_type == 'APE':
|
||||
audio = MonkeysAudio(file_path)
|
||||
else:
|
||||
audio = Musepack(file_path)
|
||||
self._write_apev2_tags(audio, tags)
|
||||
elif format_type in ['DSF', 'DSDIFF']:
|
||||
if format_type == 'DSF':
|
||||
audio = DSF(file_path)
|
||||
else:
|
||||
audio = DSDIFF(file_path)
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
self._write_id3_tags(audio.tags, tags)
|
||||
elif format_type in ['AIFF', 'WAVE']:
|
||||
if format_type == 'AIFF':
|
||||
audio = AIFF(file_path)
|
||||
else:
|
||||
audio = WAVE(file_path)
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
self._write_id3_tags(audio.tags, tags)
|
||||
else:
|
||||
logger.warning(f"Write not implemented for: {format_type}")
|
||||
return False
|
||||
|
||||
audio.save()
|
||||
logger.info(f"Successfully wrote tags to {file_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing tags to {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def _write_mp4_tags(self, audio: MP4, tags: Dict[str, Any]) -> None:
|
||||
"""Write tags to MP4/M4A file"""
|
||||
if 'title' in tags:
|
||||
audio['\xa9nam'] = tags['title']
|
||||
if 'artist' in tags:
|
||||
audio['\xa9ART'] = tags['artist']
|
||||
if 'album' in tags:
|
||||
audio['\xa9alb'] = tags['album']
|
||||
if 'album_artist' in tags:
|
||||
audio['aART'] = tags['album_artist']
|
||||
if 'year' in tags:
|
||||
audio['\xa9day'] = tags['year']
|
||||
if 'genre' in tags:
|
||||
audio['\xa9gen'] = tags['genre']
|
||||
if 'track_number' in tags:
|
||||
current_disc = audio.get('trkn', [(0, 0)])[0]
|
||||
audio['trkn'] = [(tags['track_number'], current_disc[1] if current_disc else 0)]
|
||||
if 'disc_number' in tags:
|
||||
current_disc = audio.get('disk', [(0, 0)])[0]
|
||||
audio['disk'] = [(tags['disc_number'], current_disc[1] if current_disc else 0)]
|
||||
|
||||
def _write_id3_tags(self, id3: ID3, tags: Dict[str, Any]) -> None:
|
||||
"""Write tags to ID3 format"""
|
||||
if 'title' in tags:
|
||||
id3.add(TIT2(encoding=3, text=tags['title']))
|
||||
if 'artist' in tags:
|
||||
id3.add(TPE1(encoding=3, text=tags['artist']))
|
||||
if 'album' in tags:
|
||||
id3.add(TALB(encoding=3, text=tags['album']))
|
||||
if 'album_artist' in tags:
|
||||
id3.add(TPE2(encoding=3, text=tags['album_artist']))
|
||||
if 'year' in tags:
|
||||
id3.add(TDRC(encoding=3, text=tags['year']))
|
||||
if 'genre' in tags:
|
||||
id3.add(TCON(encoding=3, text=tags['genre']))
|
||||
if 'track_number' in tags:
|
||||
id3.add(TRCK(encoding=3, text=str(tags['track_number'])))
|
||||
if 'disc_number' in tags:
|
||||
id3.add(TPOS(encoding=3, text=str(tags['disc_number'])))
|
||||
|
||||
def _write_vorbis_tags(self, audio, tags: Dict[str, Any]) -> None:
|
||||
"""Write tags to Vorbis comment format (FLAC, OGG, Opus)"""
|
||||
if 'title' in tags:
|
||||
audio['title'] = tags['title']
|
||||
if 'artist' in tags:
|
||||
audio['artist'] = tags['artist']
|
||||
if 'album' in tags:
|
||||
audio['album'] = tags['album']
|
||||
if 'album_artist' in tags:
|
||||
audio['albumartist'] = tags['album_artist']
|
||||
if 'year' in tags:
|
||||
audio['date'] = tags['year']
|
||||
if 'genre' in tags:
|
||||
audio['genre'] = tags['genre']
|
||||
if 'track_number' in tags:
|
||||
audio['tracknumber'] = str(tags['track_number'])
|
||||
if 'disc_number' in tags:
|
||||
audio['discnumber'] = str(tags['disc_number'])
|
||||
|
||||
def _write_apev2_tags(self, audio, tags: Dict[str, Any]) -> None:
|
||||
"""Write tags to APEv2 format (WavPack, APE, Musepack)"""
|
||||
if 'title' in tags:
|
||||
audio['Title'] = tags['title']
|
||||
if 'artist' in tags:
|
||||
audio['Artist'] = tags['artist']
|
||||
if 'album' in tags:
|
||||
audio['Album'] = tags['album']
|
||||
if 'album_artist' in tags:
|
||||
audio['Album Artist'] = tags['album_artist']
|
||||
if 'year' in tags:
|
||||
audio['Year'] = tags['year']
|
||||
if 'genre' in tags:
|
||||
audio['Genre'] = tags['genre']
|
||||
if 'track_number' in tags:
|
||||
audio['Track'] = str(tags['track_number'])
|
||||
if 'disc_number' in tags:
|
||||
audio['Disc'] = str(tags['disc_number'])
|
||||
|
||||
def embed_cover_art(self, file_path: str, image_data: bytes, mime_type: str = 'image/jpeg') -> bool:
|
||||
"""
|
||||
Embed cover art in audio file
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
image_data: Image data as bytes
|
||||
mime_type: MIME type of image (image/jpeg or image/png)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return False
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
format_type = self.SUPPORTED_FORMATS.get(suffix)
|
||||
|
||||
if not format_type:
|
||||
logger.warning(f"Unsupported format for cover art: {suffix}")
|
||||
return False
|
||||
|
||||
# Handle different audio formats
|
||||
if format_type == 'MP4':
|
||||
audio = MP4(file_path)
|
||||
if mime_type == 'image/png':
|
||||
cover = MP4Cover(image_data, imageformat=MP4Cover.FORMAT_PNG)
|
||||
else:
|
||||
cover = MP4Cover(image_data, imageformat=MP4Cover.FORMAT_JPEG)
|
||||
audio['covr'] = [cover]
|
||||
elif format_type in ['MP3', 'AIFF', 'WAVE', 'DSF', 'DSDIFF']:
|
||||
# All these formats support ID3v2
|
||||
if format_type == 'MP3':
|
||||
audio = MP3(file_path)
|
||||
elif format_type == 'DSF':
|
||||
audio = DSF(file_path)
|
||||
elif format_type == 'DSDIFF':
|
||||
audio = DSDIFF(file_path)
|
||||
elif format_type == 'AIFF':
|
||||
audio = AIFF(file_path)
|
||||
else:
|
||||
audio = WAVE(file_path)
|
||||
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
|
||||
# Remove existing APIC frames
|
||||
audio.tags.delall('APIC')
|
||||
|
||||
# Add new cover
|
||||
audio.tags.add(APIC(
|
||||
encoding=3,
|
||||
mime=mime_type,
|
||||
type=3, # Cover (front)
|
||||
desc='Cover',
|
||||
data=image_data
|
||||
))
|
||||
elif format_type in ['FLAC', 'OGG', 'OPUS']:
|
||||
if format_type == 'FLAC':
|
||||
audio = FLAC(file_path)
|
||||
elif format_type == 'OGG':
|
||||
audio = OggVorbis(file_path)
|
||||
else:
|
||||
audio = OggOpus(file_path)
|
||||
|
||||
# Create picture
|
||||
picture = Picture()
|
||||
picture.type = 3 # Cover (front)
|
||||
picture.mime = mime_type
|
||||
picture.desc = 'Cover'
|
||||
picture.data = image_data
|
||||
|
||||
if format_type == 'FLAC':
|
||||
# FLAC has native picture support
|
||||
audio.clear_pictures()
|
||||
audio.add_picture(picture)
|
||||
else:
|
||||
# OGG/Opus use base64 encoded metadata block
|
||||
if 'metadata_block_picture' in audio:
|
||||
del audio['metadata_block_picture']
|
||||
encoded = base64.b64encode(picture.write()).decode('ascii')
|
||||
audio['metadata_block_picture'] = [encoded]
|
||||
elif format_type in ['WAVPACK', 'APE', 'MUSEPACK']:
|
||||
if format_type == 'WAVPACK':
|
||||
audio = WavPack(file_path)
|
||||
elif format_type == 'APE':
|
||||
audio = MonkeysAudio(file_path)
|
||||
else:
|
||||
audio = Musepack(file_path)
|
||||
|
||||
# APEv2 stores cover as binary item
|
||||
audio['Cover Art (Front)'] = image_data
|
||||
else:
|
||||
logger.warning(f"Cover art embedding not implemented for: {format_type}")
|
||||
return False
|
||||
|
||||
audio.save()
|
||||
logger.info(f"Successfully embedded cover art in {file_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error embedding cover art in {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def extract_cover_art(self, file_path: str) -> Optional[bytes]:
|
||||
"""
|
||||
Extract cover art from audio file
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
|
||||
Returns:
|
||||
Cover art data as bytes or None if not found
|
||||
"""
|
||||
try:
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return None
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
format_type = self.SUPPORTED_FORMATS.get(suffix)
|
||||
|
||||
if not format_type:
|
||||
return None
|
||||
|
||||
# Handle different audio formats
|
||||
if format_type == 'MP4':
|
||||
audio = MP4(file_path)
|
||||
covers = audio.get('covr', [])
|
||||
if covers:
|
||||
return bytes(covers[0])
|
||||
elif format_type in ['MP3', 'AIFF', 'WAVE', 'DSF', 'DSDIFF']:
|
||||
if format_type == 'MP3':
|
||||
audio = MP3(file_path)
|
||||
elif format_type == 'DSF':
|
||||
audio = DSF(file_path)
|
||||
elif format_type == 'DSDIFF':
|
||||
audio = DSDIFF(file_path)
|
||||
elif format_type == 'AIFF':
|
||||
audio = AIFF(file_path)
|
||||
else:
|
||||
audio = WAVE(file_path)
|
||||
|
||||
if audio.tags:
|
||||
for key in audio.tags.keys():
|
||||
if key.startswith('APIC'):
|
||||
return audio.tags[key].data
|
||||
elif format_type == 'FLAC':
|
||||
audio = FLAC(file_path)
|
||||
if audio.pictures:
|
||||
return audio.pictures[0].data
|
||||
elif format_type in ['OGG', 'OPUS']:
|
||||
if format_type == 'OGG':
|
||||
audio = OggVorbis(file_path)
|
||||
else:
|
||||
audio = OggOpus(file_path)
|
||||
|
||||
# Check for base64 encoded picture
|
||||
if 'metadata_block_picture' in audio:
|
||||
encoded = audio['metadata_block_picture'][0]
|
||||
picture_data = base64.b64decode(encoded)
|
||||
picture = Picture(picture_data)
|
||||
return picture.data
|
||||
elif format_type in ['WAVPACK', 'APE', 'MUSEPACK']:
|
||||
if format_type == 'WAVPACK':
|
||||
audio = WavPack(file_path)
|
||||
elif format_type == 'APE':
|
||||
audio = MonkeysAudio(file_path)
|
||||
else:
|
||||
audio = Musepack(file_path)
|
||||
|
||||
cover = audio.get('Cover Art (Front)')
|
||||
if cover:
|
||||
return bytes(cover[0]) if isinstance(cover, list) else bytes(cover)
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting cover art from {file_path}: {e}")
|
||||
return None
|
||||
280
backend/audio/id3_service.py.backup
Normal file
280
backend/audio/id3_service.py.backup
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""ID3 tagging service using mutagen"""
|
||||
import os
|
||||
import logging
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TDRC, TRCK, TCON, TXXX
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ID3TagService:
|
||||
"""Service for reading and writing ID3 tags"""
|
||||
|
||||
@staticmethod
|
||||
def read_tags(file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Read ID3 tags from audio file
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
|
||||
Returns:
|
||||
Dictionary with tag information
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return {}
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
tags = {}
|
||||
|
||||
if ext == '.m4a' or ext == '.mp4':
|
||||
audio = MP4(file_path)
|
||||
tags = {
|
||||
'title': audio.get('\xa9nam', [''])[0],
|
||||
'artist': audio.get('\xa9ART', [''])[0],
|
||||
'album': audio.get('\xa9alb', [''])[0],
|
||||
'album_artist': audio.get('aART', [''])[0],
|
||||
'year': audio.get('\xa9day', [''])[0],
|
||||
'genre': audio.get('\xa9gen', [''])[0],
|
||||
'track_number': audio.get('trkn', [(0, 0)])[0][0],
|
||||
'disc_number': audio.get('disk', [(0, 0)])[0][0],
|
||||
'duration': audio.info.length if audio.info else 0,
|
||||
'bitrate': audio.info.bitrate if audio.info else 0,
|
||||
'has_cover': 'covr' in audio,
|
||||
}
|
||||
|
||||
elif ext == '.mp3':
|
||||
audio = ID3(file_path)
|
||||
tags = {
|
||||
'title': str(audio.get('TIT2', '')),
|
||||
'artist': str(audio.get('TPE1', '')),
|
||||
'album': str(audio.get('TALB', '')),
|
||||
'album_artist': str(audio.get('TPE2', '')),
|
||||
'year': str(audio.get('TDRC', '')),
|
||||
'genre': str(audio.get('TCON', '')),
|
||||
'track_number': str(audio.get('TRCK', '')).split('/')[0] if audio.get('TRCK') else '',
|
||||
'disc_number': str(audio.get('TPOS', '')).split('/')[0] if audio.get('TPOS') else '',
|
||||
'has_cover': 'APIC:' in audio or any(k.startswith('APIC') for k in audio.keys()),
|
||||
}
|
||||
|
||||
elif ext == '.flac':
|
||||
audio = FLAC(file_path)
|
||||
tags = {
|
||||
'title': audio.get('title', [''])[0],
|
||||
'artist': audio.get('artist', [''])[0],
|
||||
'album': audio.get('album', [''])[0],
|
||||
'album_artist': audio.get('albumartist', [''])[0],
|
||||
'year': audio.get('date', [''])[0],
|
||||
'genre': audio.get('genre', [''])[0],
|
||||
'track_number': audio.get('tracknumber', [''])[0],
|
||||
'disc_number': audio.get('discnumber', [''])[0],
|
||||
'duration': audio.info.length if audio.info else 0,
|
||||
'has_cover': len(audio.pictures) > 0,
|
||||
}
|
||||
|
||||
return tags
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading tags from {file_path}: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def write_tags(file_path: str, tags: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Write ID3 tags to audio file
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
tags: Dictionary with tag information
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return False
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
if ext == '.m4a' or ext == '.mp4':
|
||||
audio = MP4(file_path)
|
||||
|
||||
if 'title' in tags:
|
||||
audio['\xa9nam'] = tags['title']
|
||||
if 'artist' in tags:
|
||||
audio['\xa9ART'] = tags['artist']
|
||||
if 'album' in tags:
|
||||
audio['\xa9alb'] = tags['album']
|
||||
if 'album_artist' in tags:
|
||||
audio['aART'] = tags['album_artist']
|
||||
if 'year' in tags:
|
||||
audio['\xa9day'] = str(tags['year'])
|
||||
if 'genre' in tags:
|
||||
audio['\xa9gen'] = tags['genre']
|
||||
if 'track_number' in tags:
|
||||
audio['trkn'] = [(int(tags['track_number']), 0)]
|
||||
if 'disc_number' in tags:
|
||||
audio['disk'] = [(int(tags['disc_number']), 0)]
|
||||
|
||||
audio.save()
|
||||
|
||||
elif ext == '.mp3':
|
||||
try:
|
||||
audio = ID3(file_path)
|
||||
except:
|
||||
audio = ID3()
|
||||
|
||||
if 'title' in tags:
|
||||
audio['TIT2'] = TIT2(encoding=3, text=tags['title'])
|
||||
if 'artist' in tags:
|
||||
audio['TPE1'] = TPE1(encoding=3, text=tags['artist'])
|
||||
if 'album' in tags:
|
||||
audio['TALB'] = TALB(encoding=3, text=tags['album'])
|
||||
if 'year' in tags:
|
||||
audio['TDRC'] = TDRC(encoding=3, text=str(tags['year']))
|
||||
if 'genre' in tags:
|
||||
audio['TCON'] = TCON(encoding=3, text=tags['genre'])
|
||||
if 'track_number' in tags:
|
||||
audio['TRCK'] = TRCK(encoding=3, text=str(tags['track_number']))
|
||||
|
||||
audio.save(file_path)
|
||||
|
||||
elif ext == '.flac':
|
||||
audio = FLAC(file_path)
|
||||
|
||||
if 'title' in tags:
|
||||
audio['title'] = tags['title']
|
||||
if 'artist' in tags:
|
||||
audio['artist'] = tags['artist']
|
||||
if 'album' in tags:
|
||||
audio['album'] = tags['album']
|
||||
if 'album_artist' in tags:
|
||||
audio['albumartist'] = tags['album_artist']
|
||||
if 'year' in tags:
|
||||
audio['date'] = str(tags['year'])
|
||||
if 'genre' in tags:
|
||||
audio['genre'] = tags['genre']
|
||||
if 'track_number' in tags:
|
||||
audio['tracknumber'] = str(tags['track_number'])
|
||||
if 'disc_number' in tags:
|
||||
audio['discnumber'] = str(tags['disc_number'])
|
||||
|
||||
audio.save()
|
||||
|
||||
logger.info(f"Successfully wrote tags to {file_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing tags to {file_path}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def embed_cover_art(file_path: str, image_data: bytes, mime_type: str = 'image/jpeg') -> bool:
|
||||
"""
|
||||
Embed cover art into audio file
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
image_data: Image binary data
|
||||
mime_type: MIME type of image
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return False
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
if ext == '.m4a' or ext == '.mp4':
|
||||
audio = MP4(file_path)
|
||||
|
||||
if mime_type == 'image/png':
|
||||
cover_format = MP4Cover.FORMAT_PNG
|
||||
else:
|
||||
cover_format = MP4Cover.FORMAT_JPEG
|
||||
|
||||
audio['covr'] = [MP4Cover(image_data, imageformat=cover_format)]
|
||||
audio.save()
|
||||
|
||||
elif ext == '.mp3':
|
||||
try:
|
||||
audio = ID3(file_path)
|
||||
except:
|
||||
audio = ID3()
|
||||
|
||||
audio['APIC'] = APIC(
|
||||
encoding=3,
|
||||
mime=mime_type,
|
||||
type=3, # Cover (front)
|
||||
desc='Cover',
|
||||
data=image_data
|
||||
)
|
||||
audio.save(file_path)
|
||||
|
||||
elif ext == '.flac':
|
||||
audio = FLAC(file_path)
|
||||
|
||||
picture = Picture()
|
||||
picture.type = 3 # Cover (front)
|
||||
picture.mime = mime_type
|
||||
picture.desc = 'Cover'
|
||||
picture.data = image_data
|
||||
|
||||
audio.clear_pictures()
|
||||
audio.add_picture(picture)
|
||||
audio.save()
|
||||
|
||||
logger.info(f"Successfully embedded cover art in {file_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error embedding cover art in {file_path}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def extract_cover_art(file_path: str) -> Optional[bytes]:
|
||||
"""
|
||||
Extract cover art from audio file
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
|
||||
Returns:
|
||||
Image binary data or None
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
if ext == '.m4a' or ext == '.mp4':
|
||||
audio = MP4(file_path)
|
||||
covers = audio.get('covr', [])
|
||||
if covers:
|
||||
return bytes(covers[0])
|
||||
|
||||
elif ext == '.mp3':
|
||||
audio = ID3(file_path)
|
||||
for key in audio.keys():
|
||||
if key.startswith('APIC'):
|
||||
return audio[key].data
|
||||
|
||||
elif ext == '.flac':
|
||||
audio = FLAC(file_path)
|
||||
if audio.pictures:
|
||||
return audio.pictures[0].data
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting cover art from {file_path}: {e}")
|
||||
return None
|
||||
296
backend/audio/lastfm_client.py
Normal file
296
backend/audio/lastfm_client.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""Last.fm API client for fetching music metadata and artwork"""
|
||||
import pylast
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LastFMClient:
|
||||
"""Client for Last.fm API"""
|
||||
|
||||
# Register for API keys at: https://www.last.fm/api/account/create
|
||||
API_KEY = getattr(settings, 'LASTFM_API_KEY', '')
|
||||
API_SECRET = getattr(settings, 'LASTFM_API_SECRET', '')
|
||||
|
||||
def __init__(self, api_key: str = None, api_secret: str = None):
|
||||
self.api_key = api_key or self.API_KEY
|
||||
self.api_secret = api_secret or self.API_SECRET
|
||||
|
||||
if self.api_key and self.api_secret:
|
||||
self.network = pylast.LastFMNetwork(
|
||||
api_key=self.api_key,
|
||||
api_secret=self.api_secret
|
||||
)
|
||||
else:
|
||||
self.network = None
|
||||
logger.warning("Last.fm API credentials not configured")
|
||||
|
||||
def search_track(self, artist: str, title: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Search for track information
|
||||
|
||||
Args:
|
||||
artist: Artist name
|
||||
title: Track title
|
||||
|
||||
Returns:
|
||||
Dictionary with track information
|
||||
"""
|
||||
if not self.network:
|
||||
return None
|
||||
|
||||
try:
|
||||
track = self.network.get_track(artist, title)
|
||||
|
||||
# Get track info
|
||||
info = {
|
||||
'title': track.get_title(),
|
||||
'artist': track.get_artist().get_name(),
|
||||
'url': track.get_url(),
|
||||
'duration': track.get_duration() / 1000 if track.get_duration() else 0, # Convert ms to seconds
|
||||
'listeners': track.get_listener_count() or 0,
|
||||
'playcount': track.get_playcount() or 0,
|
||||
'tags': [tag.item.get_name() for tag in track.get_top_tags(limit=10)],
|
||||
}
|
||||
|
||||
# Try to get album info
|
||||
try:
|
||||
album = track.get_album()
|
||||
if album:
|
||||
info['album'] = album.get_title()
|
||||
info['album_url'] = album.get_url()
|
||||
info['album_cover'] = album.get_cover_image()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Try to get MusicBrainz ID
|
||||
try:
|
||||
mbid = track.get_mbid()
|
||||
if mbid:
|
||||
info['mbid'] = mbid
|
||||
except:
|
||||
pass
|
||||
|
||||
# Get cover images
|
||||
try:
|
||||
images = self._get_track_images(artist, title)
|
||||
if images:
|
||||
info['images'] = images
|
||||
except:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
except pylast.WSError as e:
|
||||
logger.warning(f"Last.fm track not found: {artist} - {title}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching track from Last.fm: {e}")
|
||||
return None
|
||||
|
||||
def get_artist_info(self, artist_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get artist information
|
||||
|
||||
Args:
|
||||
artist_name: Artist name
|
||||
|
||||
Returns:
|
||||
Dictionary with artist information
|
||||
"""
|
||||
if not self.network:
|
||||
return None
|
||||
|
||||
try:
|
||||
artist = self.network.get_artist(artist_name)
|
||||
|
||||
info = {
|
||||
'name': artist.get_name(),
|
||||
'url': artist.get_url(),
|
||||
'listeners': artist.get_listener_count() or 0,
|
||||
'playcount': artist.get_playcount() or 0,
|
||||
'bio': artist.get_bio_content(),
|
||||
'bio_summary': artist.get_bio_summary(),
|
||||
'tags': [tag.item.get_name() for tag in artist.get_top_tags(limit=10)],
|
||||
}
|
||||
|
||||
# Try to get MusicBrainz ID
|
||||
try:
|
||||
mbid = artist.get_mbid()
|
||||
if mbid:
|
||||
info['mbid'] = mbid
|
||||
except:
|
||||
pass
|
||||
|
||||
# Get similar artists
|
||||
try:
|
||||
similar = artist.get_similar(limit=10)
|
||||
info['similar_artists'] = [
|
||||
{
|
||||
'name': s.item.get_name(),
|
||||
'url': s.item.get_url(),
|
||||
'match': s.match
|
||||
}
|
||||
for s in similar
|
||||
]
|
||||
except:
|
||||
info['similar_artists'] = []
|
||||
|
||||
# Get images
|
||||
try:
|
||||
images = self._get_artist_images(artist_name)
|
||||
if images:
|
||||
info['images'] = images
|
||||
except:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
except pylast.WSError as e:
|
||||
logger.warning(f"Last.fm artist not found: {artist_name}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching artist from Last.fm: {e}")
|
||||
return None
|
||||
|
||||
def get_album_info(self, artist: str, album: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get album information
|
||||
|
||||
Args:
|
||||
artist: Artist name
|
||||
album: Album name
|
||||
|
||||
Returns:
|
||||
Dictionary with album information
|
||||
"""
|
||||
if not self.network:
|
||||
return None
|
||||
|
||||
try:
|
||||
album_obj = self.network.get_album(artist, album)
|
||||
|
||||
info = {
|
||||
'title': album_obj.get_title(),
|
||||
'artist': album_obj.get_artist().get_name(),
|
||||
'url': album_obj.get_url(),
|
||||
'playcount': album_obj.get_playcount() or 0,
|
||||
'listeners': album_obj.get_listener_count() or 0,
|
||||
'tags': [tag.item.get_name() for tag in album_obj.get_top_tags(limit=10)],
|
||||
}
|
||||
|
||||
# Try to get MusicBrainz ID
|
||||
try:
|
||||
mbid = album_obj.get_mbid()
|
||||
if mbid:
|
||||
info['mbid'] = mbid
|
||||
except:
|
||||
pass
|
||||
|
||||
# Get cover images
|
||||
try:
|
||||
cover = album_obj.get_cover_image()
|
||||
if cover:
|
||||
info['cover_url'] = cover
|
||||
info['images'] = self._get_album_images_sizes(cover)
|
||||
except:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
except pylast.WSError as e:
|
||||
logger.warning(f"Last.fm album not found: {artist} - {album}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching album from Last.fm: {e}")
|
||||
return None
|
||||
|
||||
def _get_track_images(self, artist: str, title: str) -> List[Dict[str, str]]:
|
||||
"""Get track/album images in different sizes"""
|
||||
try:
|
||||
track = self.network.get_track(artist, title)
|
||||
album = track.get_album()
|
||||
if album:
|
||||
cover_url = album.get_cover_image()
|
||||
if cover_url:
|
||||
return self._get_album_images_sizes(cover_url)
|
||||
except:
|
||||
pass
|
||||
return []
|
||||
|
||||
def _get_artist_images(self, artist_name: str) -> List[Dict[str, str]]:
|
||||
"""Get artist images in different sizes"""
|
||||
if not self.api_key:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Use direct API call for more control
|
||||
url = 'http://ws.audioscrobbler.com/2.0/'
|
||||
params = {
|
||||
'method': 'artist.getinfo',
|
||||
'artist': artist_name,
|
||||
'api_key': self.api_key,
|
||||
'format': 'json'
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=10)
|
||||
data = response.json()
|
||||
|
||||
if 'artist' in data and 'image' in data['artist']:
|
||||
images = []
|
||||
for img in data['artist']['image']:
|
||||
if img['#text']:
|
||||
images.append({
|
||||
'size': img['size'],
|
||||
'url': img['#text']
|
||||
})
|
||||
return images
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching artist images: {e}")
|
||||
|
||||
return []
|
||||
|
||||
def _get_album_images_sizes(self, cover_url: str) -> List[Dict[str, str]]:
|
||||
"""Convert single cover URL to different sizes"""
|
||||
# Last.fm image URLs follow a pattern
|
||||
images = []
|
||||
sizes = ['small', 'medium', 'large', 'extralarge', 'mega']
|
||||
|
||||
for size in sizes:
|
||||
# Replace size in URL
|
||||
url = cover_url.replace('/300x300/', f'/{size}/')
|
||||
images.append({
|
||||
'size': size,
|
||||
'url': url
|
||||
})
|
||||
|
||||
return images
|
||||
|
||||
def download_image(self, url: str, output_path: str) -> bool:
|
||||
"""
|
||||
Download image from URL
|
||||
|
||||
Args:
|
||||
url: Image URL
|
||||
output_path: Local path to save image
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
response = requests.get(url, timeout=30, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
|
||||
logger.info(f"Downloaded image to {output_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading image from {url}: {e}")
|
||||
return False
|
||||
287
backend/audio/lyrics_service.py
Normal file
287
backend/audio/lyrics_service.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"""Lyrics fetching service using LRCLIB API"""
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from django.core.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LRCLIBClient:
|
||||
"""Client for LRCLIB API (https://lrclib.net/)"""
|
||||
|
||||
DEFAULT_INSTANCE = "https://lrclib.net"
|
||||
USER_AGENT = "SoundWave/1.0 (https://github.com/soundwave)"
|
||||
TIMEOUT = 10 # seconds
|
||||
|
||||
def __init__(self, instance_url: str = None):
|
||||
self.instance_url = (instance_url or self.DEFAULT_INSTANCE).rstrip('/')
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': self.USER_AGENT,
|
||||
})
|
||||
|
||||
def get_lyrics(
|
||||
self,
|
||||
title: str,
|
||||
artist_name: str,
|
||||
album_name: str = "",
|
||||
duration: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch lyrics from LRCLIB API
|
||||
|
||||
Args:
|
||||
title: Track title
|
||||
artist_name: Artist name
|
||||
album_name: Album name (optional)
|
||||
duration: Track duration in seconds
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- synced_lyrics: LRC format lyrics with timestamps
|
||||
- plain_lyrics: Plain text lyrics
|
||||
- instrumental: Boolean if track is instrumental
|
||||
- language: Language code
|
||||
"""
|
||||
# Build request parameters
|
||||
params = {
|
||||
'track_name': title,
|
||||
'artist_name': artist_name,
|
||||
'album_name': album_name,
|
||||
'duration': round(duration) if duration else 0,
|
||||
}
|
||||
|
||||
# Make request
|
||||
api_endpoint = f"{self.instance_url}/api/get"
|
||||
|
||||
try:
|
||||
response = self.session.get(
|
||||
api_endpoint,
|
||||
params=params,
|
||||
timeout=self.TIMEOUT
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
# No lyrics found
|
||||
return {
|
||||
'synced_lyrics': '',
|
||||
'plain_lyrics': '',
|
||||
'instrumental': False,
|
||||
'language': '',
|
||||
'not_found': True,
|
||||
}
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Extract lyrics data
|
||||
synced = data.get('syncedLyrics') or ''
|
||||
plain = data.get('plainLyrics') or ''
|
||||
instrumental = data.get('instrumental', False)
|
||||
language = data.get('lang') or ''
|
||||
|
||||
# If we have synced lyrics but no plain, strip timestamps
|
||||
if synced and not plain:
|
||||
plain = self._strip_timestamps(synced)
|
||||
|
||||
return {
|
||||
'synced_lyrics': synced,
|
||||
'plain_lyrics': plain,
|
||||
'instrumental': instrumental,
|
||||
'language': language,
|
||||
'not_found': False,
|
||||
}
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error(f"LRCLIB API timeout for {title} - {artist_name}")
|
||||
raise LyricsAPIError("Request timeout")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"LRCLIB API error for {title} - {artist_name}: {e}")
|
||||
raise LyricsAPIError(f"API request failed: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _strip_timestamps(synced_lyrics: str) -> str:
|
||||
"""Strip timestamps from LRC format lyrics"""
|
||||
import re
|
||||
lines = []
|
||||
for line in synced_lyrics.split('\n'):
|
||||
# Remove all timestamp tags [mm:ss.xx]
|
||||
cleaned = re.sub(r'\[\d{2}:\d{2}\.\d{2,3}\]', '', line)
|
||||
# Remove metadata tags [tag:value]
|
||||
cleaned = re.sub(r'\[[a-z]+:.*?\]', '', cleaned)
|
||||
if cleaned.strip():
|
||||
lines.append(cleaned.strip())
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
class LyricsAPIError(Exception):
|
||||
"""Exception for lyrics API errors"""
|
||||
pass
|
||||
|
||||
|
||||
class LyricsService:
|
||||
"""Service for fetching and caching lyrics"""
|
||||
|
||||
def __init__(self, lrclib_instance: str = None):
|
||||
self.client = LRCLIBClient(lrclib_instance)
|
||||
|
||||
def fetch_lyrics(
|
||||
self,
|
||||
title: str,
|
||||
artist_name: str,
|
||||
album_name: str = "",
|
||||
duration: int = 0,
|
||||
use_cache: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch lyrics with caching
|
||||
|
||||
Args:
|
||||
title: Track title
|
||||
artist_name: Artist name
|
||||
album_name: Album name
|
||||
duration: Duration in seconds
|
||||
use_cache: Whether to use cached results
|
||||
|
||||
Returns:
|
||||
Dict with lyrics data
|
||||
"""
|
||||
# Create cache key
|
||||
cache_key = self._make_cache_key(title, artist_name, album_name, duration)
|
||||
|
||||
# Check cache first
|
||||
if use_cache:
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
logger.debug(f"Cache hit for {title} - {artist_name}")
|
||||
return cached
|
||||
|
||||
# Fetch from API
|
||||
try:
|
||||
logger.info(f"Fetching lyrics for {title} - {artist_name}")
|
||||
result = self.client.get_lyrics(title, artist_name, album_name, duration)
|
||||
|
||||
# Cache the result (even if not found, to avoid repeated requests)
|
||||
cache_timeout = 86400 * 7 # 7 days
|
||||
if result.get('not_found'):
|
||||
cache_timeout = 86400 # 1 day for not found
|
||||
|
||||
cache.set(cache_key, result, cache_timeout)
|
||||
|
||||
return result
|
||||
|
||||
except LyricsAPIError as e:
|
||||
logger.warning(f"Failed to fetch lyrics: {e}")
|
||||
# Cache the error for a short time to avoid hammering the API
|
||||
error_result = {
|
||||
'synced_lyrics': '',
|
||||
'plain_lyrics': '',
|
||||
'instrumental': False,
|
||||
'language': '',
|
||||
'not_found': True,
|
||||
'error': str(e),
|
||||
}
|
||||
cache.set(cache_key, error_result, 3600) # 1 hour
|
||||
return error_result
|
||||
|
||||
@staticmethod
|
||||
def _make_cache_key(title: str, artist: str, album: str, duration: int) -> str:
|
||||
"""Create cache key from track metadata"""
|
||||
import hashlib
|
||||
key_str = f"{title}|{artist}|{album}|{duration}"
|
||||
return f"lyrics:{hashlib.md5(key_str.encode()).hexdigest()}"
|
||||
|
||||
def fetch_and_store_lyrics(self, audio_obj, force: bool = False):
|
||||
"""
|
||||
Fetch lyrics and store in database
|
||||
|
||||
Args:
|
||||
audio_obj: Audio model instance
|
||||
force: Force fetch even if already attempted
|
||||
"""
|
||||
from audio.models_lyrics import Lyrics, LyricsCache
|
||||
|
||||
# Check if already attempted
|
||||
existing, created = Lyrics.objects.get_or_create(audio=audio_obj)
|
||||
|
||||
if not force and existing.fetch_attempted and existing.fetch_attempts >= 3:
|
||||
logger.debug(f"Skipping {audio_obj.title} - already attempted {existing.fetch_attempts} times")
|
||||
return existing
|
||||
|
||||
# Check database cache first
|
||||
duration_rounded = round(audio_obj.duration)
|
||||
cache_entry = LyricsCache.objects.filter(
|
||||
title=audio_obj.title,
|
||||
artist_name=audio_obj.channel_name,
|
||||
duration=duration_rounded
|
||||
).first()
|
||||
|
||||
if cache_entry and not force:
|
||||
# Use cached data
|
||||
existing.synced_lyrics = cache_entry.synced_lyrics
|
||||
existing.plain_lyrics = cache_entry.plain_lyrics
|
||||
existing.is_instrumental = cache_entry.is_instrumental
|
||||
existing.language = cache_entry.language
|
||||
existing.source = cache_entry.source
|
||||
existing.fetch_attempted = True
|
||||
existing.save()
|
||||
|
||||
# Update cache stats
|
||||
cache_entry.access_count += 1
|
||||
cache_entry.save()
|
||||
|
||||
logger.info(f"Using cached lyrics for {audio_obj.title}")
|
||||
return existing
|
||||
|
||||
# Fetch from API
|
||||
try:
|
||||
result = self.fetch_lyrics(
|
||||
title=audio_obj.title,
|
||||
artist_name=audio_obj.channel_name,
|
||||
album_name="", # YouTube doesn't provide album info
|
||||
duration=duration_rounded,
|
||||
use_cache=True
|
||||
)
|
||||
|
||||
# Update lyrics entry
|
||||
existing.synced_lyrics = result.get('synced_lyrics', '')
|
||||
existing.plain_lyrics = result.get('plain_lyrics', '')
|
||||
existing.is_instrumental = result.get('instrumental', False)
|
||||
existing.language = result.get('language', '')
|
||||
existing.source = 'lrclib'
|
||||
existing.fetch_attempted = True
|
||||
existing.fetch_attempts += 1
|
||||
existing.last_error = result.get('error', '')
|
||||
existing.save()
|
||||
|
||||
# Store in cache
|
||||
if not result.get('not_found'):
|
||||
LyricsCache.objects.update_or_create(
|
||||
title=audio_obj.title,
|
||||
artist_name=audio_obj.channel_name,
|
||||
album_name="",
|
||||
duration=duration_rounded,
|
||||
defaults={
|
||||
'synced_lyrics': result.get('synced_lyrics', ''),
|
||||
'plain_lyrics': result.get('plain_lyrics', ''),
|
||||
'is_instrumental': result.get('instrumental', False),
|
||||
'language': result.get('language', ''),
|
||||
'source': 'lrclib',
|
||||
'not_found': result.get('not_found', False),
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Fetched lyrics for {audio_obj.title}")
|
||||
return existing
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching lyrics for {audio_obj.title}: {e}")
|
||||
existing.fetch_attempted = True
|
||||
existing.fetch_attempts += 1
|
||||
existing.last_error = str(e)
|
||||
existing.save()
|
||||
return existing
|
||||
0
backend/audio/management/__init__.py
Normal file
0
backend/audio/management/__init__.py
Normal file
0
backend/audio/management/commands/__init__.py
Normal file
0
backend/audio/management/commands/__init__.py
Normal file
69
backend/audio/management/commands/fix_audio_extensions.py
Normal file
69
backend/audio/management/commands/fix_audio_extensions.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Management command to fix audio file extensions in database"""
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from audio.models import Audio
|
||||
import os
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fix audio file extensions in database to match actual files'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('Checking for audio files with incorrect extensions...\n')
|
||||
|
||||
# Find all audio entries with non-.m4a extensions
|
||||
problematic = Audio.objects.exclude(file_path__endswith='.m4a').exclude(file_path='')
|
||||
total = problematic.count()
|
||||
|
||||
if total == 0:
|
||||
self.stdout.write(self.style.SUCCESS('✅ No files need fixing'))
|
||||
return
|
||||
|
||||
self.stdout.write(f'Found {total} files with non-.m4a extensions\n')
|
||||
|
||||
fixed_count = 0
|
||||
missing_count = 0
|
||||
|
||||
for audio in problematic:
|
||||
old_path = audio.file_path
|
||||
|
||||
# Try different extensions that might be in database
|
||||
for ext in ['.webm', '.opus', '.mp3', '.ogg', '.wav']:
|
||||
if old_path.endswith(ext):
|
||||
# Try .m4a version (our post-processor creates .m4a files)
|
||||
new_path = old_path[:-len(ext)] + '.m4a'
|
||||
full_path = f"/app/audio/{new_path}"
|
||||
|
||||
if os.path.exists(full_path):
|
||||
audio.file_path = new_path
|
||||
audio.save()
|
||||
size = os.path.getsize(full_path)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'✅ Fixed: {audio.youtube_id} ({size/1024/1024:.1f} MB)'
|
||||
)
|
||||
)
|
||||
fixed_count += 1
|
||||
else:
|
||||
# Check if original file exists
|
||||
old_full_path = f"/app/audio/{old_path}"
|
||||
if os.path.exists(old_full_path):
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f'⚠️ File exists but with wrong extension: {audio.youtube_id}'
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f'❌ File missing: {audio.youtube_id}'
|
||||
)
|
||||
)
|
||||
missing_count += 1
|
||||
break
|
||||
|
||||
self.stdout.write('\n' + '='*50)
|
||||
self.stdout.write(self.style.SUCCESS(f'✅ Fixed: {fixed_count} file(s)'))
|
||||
if missing_count > 0:
|
||||
self.stdout.write(self.style.ERROR(f'❌ Missing: {missing_count} file(s)'))
|
||||
self.stdout.write('='*50)
|
||||
0
backend/audio/migrations/__init__.py
Normal file
0
backend/audio/migrations/__init__.py
Normal file
100
backend/audio/models.py
Normal file
100
backend/audio/models.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Audio models"""
|
||||
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Audio(models.Model):
|
||||
"""Audio file model"""
|
||||
# User isolation
|
||||
owner = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='audio_files',
|
||||
help_text="User who owns this audio file"
|
||||
)
|
||||
|
||||
youtube_id = models.CharField(max_length=50, db_index=True)
|
||||
title = models.CharField(max_length=500)
|
||||
description = models.TextField(blank=True)
|
||||
channel_id = models.CharField(max_length=50, db_index=True)
|
||||
channel_name = models.CharField(max_length=200)
|
||||
duration = models.IntegerField(help_text="Duration in seconds")
|
||||
file_path = models.CharField(max_length=500)
|
||||
file_size = models.BigIntegerField(help_text="File size in bytes")
|
||||
thumbnail_url = models.URLField(max_length=500, blank=True)
|
||||
published_date = models.DateTimeField()
|
||||
downloaded_date = models.DateTimeField(auto_now_add=True)
|
||||
view_count = models.IntegerField(default=0)
|
||||
like_count = models.IntegerField(default=0)
|
||||
audio_format = models.CharField(max_length=20, default='m4a')
|
||||
bitrate = models.IntegerField(null=True, blank=True, help_text="Bitrate in kbps")
|
||||
|
||||
# Playback tracking
|
||||
play_count = models.IntegerField(default=0)
|
||||
last_played = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-published_date']
|
||||
unique_together = ('owner', 'youtube_id') # Each user can have one copy of each video
|
||||
indexes = [
|
||||
models.Index(fields=['owner', 'youtube_id']),
|
||||
models.Index(fields=['owner', 'channel_id']),
|
||||
models.Index(fields=['owner', '-published_date']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.owner.username} - {self.title}"
|
||||
|
||||
@property
|
||||
def downloaded(self):
|
||||
"""Check if audio file has been downloaded"""
|
||||
return bool(self.file_path)
|
||||
|
||||
@property
|
||||
def has_lyrics(self):
|
||||
"""Check if audio has lyrics"""
|
||||
return hasattr(self, 'lyrics') and self.lyrics.has_lyrics
|
||||
|
||||
|
||||
class Channel(models.Model):
|
||||
"""YouTube channel model"""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='audio_channels')
|
||||
channel_id = models.CharField(max_length=100)
|
||||
channel_name = models.CharField(max_length=255)
|
||||
channel_description = models.TextField(blank=True)
|
||||
channel_thumbnail = models.URLField(blank=True)
|
||||
subscribed = models.BooleanField(default=False)
|
||||
subscriber_count = models.IntegerField(default=0)
|
||||
video_count = models.IntegerField(default=0)
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'channel_id')
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'channel_id']),
|
||||
models.Index(fields=['user', 'subscribed']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.channel_name} ({self.user.username})"
|
||||
|
||||
|
||||
class AudioProgress(models.Model):
|
||||
"""Track user progress on audio files"""
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='audio_progress')
|
||||
audio = models.ForeignKey(Audio, on_delete=models.CASCADE, related_name='user_progress')
|
||||
position = models.IntegerField(default=0, help_text="Current position in seconds")
|
||||
completed = models.BooleanField(default=False)
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('user', 'audio')
|
||||
indexes = [
|
||||
models.Index(fields=['user', 'audio']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.audio.title}"
|
||||
159
backend/audio/models_artwork.py
Normal file
159
backend/audio/models_artwork.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Models for artwork and metadata"""
|
||||
from django.db import models
|
||||
from audio.models import Audio
|
||||
from channel.models import Channel
|
||||
|
||||
|
||||
class Artwork(models.Model):
|
||||
"""Store artwork/cover art for audio and channels"""
|
||||
|
||||
ARTWORK_TYPE_CHOICES = [
|
||||
('audio_thumbnail', 'Audio Thumbnail'),
|
||||
('audio_cover', 'Audio Cover Art'),
|
||||
('album_cover', 'Album Cover'),
|
||||
('artist_image', 'Artist Image'),
|
||||
('artist_banner', 'Artist Banner'),
|
||||
('artist_logo', 'Artist Logo'),
|
||||
]
|
||||
|
||||
SOURCE_CHOICES = [
|
||||
('youtube', 'YouTube'),
|
||||
('lastfm', 'Last.fm'),
|
||||
('fanart', 'Fanart.tv'),
|
||||
('manual', 'Manual Upload'),
|
||||
]
|
||||
|
||||
# Related objects
|
||||
audio = models.ForeignKey(
|
||||
Audio,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='artworks',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
channel = models.ForeignKey(
|
||||
Channel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='artworks',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
|
||||
# Artwork details
|
||||
artwork_type = models.CharField(max_length=50, choices=ARTWORK_TYPE_CHOICES)
|
||||
source = models.CharField(max_length=50, choices=SOURCE_CHOICES)
|
||||
url = models.URLField(max_length=1000)
|
||||
local_path = models.CharField(max_length=500, blank=True, default='')
|
||||
|
||||
# Image metadata
|
||||
width = models.IntegerField(null=True, blank=True)
|
||||
height = models.IntegerField(null=True, blank=True)
|
||||
file_size = models.IntegerField(null=True, blank=True, help_text="Size in bytes")
|
||||
|
||||
# Priority for display (higher = preferred)
|
||||
priority = models.IntegerField(default=0)
|
||||
|
||||
# Metadata
|
||||
fetched_date = models.DateTimeField(auto_now_add=True)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-priority', '-fetched_date']
|
||||
indexes = [
|
||||
models.Index(fields=['audio', 'artwork_type']),
|
||||
models.Index(fields=['channel', 'artwork_type']),
|
||||
models.Index(fields=['is_primary']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
if self.audio:
|
||||
return f"{self.artwork_type} for {self.audio.title} ({self.source})"
|
||||
elif self.channel:
|
||||
return f"{self.artwork_type} for {self.channel.channel_name} ({self.source})"
|
||||
return f"{self.artwork_type} ({self.source})"
|
||||
|
||||
|
||||
class MusicMetadata(models.Model):
|
||||
"""Extended music metadata from Last.fm and other sources"""
|
||||
|
||||
audio = models.OneToOneField(
|
||||
Audio,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='music_metadata',
|
||||
primary_key=True
|
||||
)
|
||||
|
||||
# Album information
|
||||
album_name = models.CharField(max_length=500, blank=True, default='')
|
||||
album_artist = models.CharField(max_length=500, blank=True, default='')
|
||||
release_year = models.IntegerField(null=True, blank=True)
|
||||
|
||||
# Track information
|
||||
track_number = models.IntegerField(null=True, blank=True)
|
||||
disc_number = models.IntegerField(null=True, blank=True)
|
||||
|
||||
# Additional metadata
|
||||
genre = models.CharField(max_length=200, blank=True, default='')
|
||||
tags = models.JSONField(default=list, blank=True) # List of tags
|
||||
|
||||
# Last.fm specific
|
||||
lastfm_url = models.URLField(max_length=500, blank=True, default='')
|
||||
lastfm_mbid = models.CharField(max_length=100, blank=True, default='', help_text="MusicBrainz ID")
|
||||
play_count = models.IntegerField(default=0, help_text="Global play count from Last.fm")
|
||||
listeners = models.IntegerField(default=0, help_text="Unique listeners from Last.fm")
|
||||
|
||||
# Fanart.tv IDs
|
||||
fanart_artist_id = models.CharField(max_length=100, blank=True, default='')
|
||||
fanart_album_id = models.CharField(max_length=100, blank=True, default='')
|
||||
|
||||
# Metadata status
|
||||
metadata_fetched = models.BooleanField(default=False)
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Music Metadata"
|
||||
|
||||
def __str__(self):
|
||||
return f"Metadata for {self.audio.title}"
|
||||
|
||||
|
||||
class ArtistInfo(models.Model):
|
||||
"""Store artist/channel information from Last.fm and Fanart.tv"""
|
||||
|
||||
channel = models.OneToOneField(
|
||||
Channel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='artist_info',
|
||||
primary_key=True
|
||||
)
|
||||
|
||||
# Basic info
|
||||
bio = models.TextField(blank=True, default='')
|
||||
bio_summary = models.TextField(blank=True, default='')
|
||||
|
||||
# Links
|
||||
lastfm_url = models.URLField(max_length=500, blank=True, default='')
|
||||
lastfm_mbid = models.CharField(max_length=100, blank=True, default='')
|
||||
|
||||
# Stats
|
||||
lastfm_listeners = models.IntegerField(default=0)
|
||||
lastfm_playcount = models.IntegerField(default=0)
|
||||
|
||||
# Tags/Genres
|
||||
tags = models.JSONField(default=list, blank=True)
|
||||
|
||||
# Fanart.tv ID
|
||||
fanart_id = models.CharField(max_length=100, blank=True, default='')
|
||||
|
||||
# Social links from Last.fm
|
||||
similar_artists = models.JSONField(default=list, blank=True)
|
||||
|
||||
# Status
|
||||
metadata_fetched = models.BooleanField(default=False)
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Artist Info"
|
||||
|
||||
def __str__(self):
|
||||
return f"Info for {self.channel.channel_name}"
|
||||
149
backend/audio/models_local.py
Normal file
149
backend/audio/models_local.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Models for user-uploaded local audio files"""
|
||||
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
import os
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class LocalAudio(models.Model):
|
||||
"""User-uploaded local audio files"""
|
||||
|
||||
owner = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='local_audio_files',
|
||||
help_text="User who uploaded this file"
|
||||
)
|
||||
|
||||
# File info
|
||||
title = models.CharField(max_length=500)
|
||||
artist = models.CharField(max_length=200, blank=True)
|
||||
album = models.CharField(max_length=200, blank=True)
|
||||
year = models.IntegerField(null=True, blank=True)
|
||||
genre = models.CharField(max_length=100, blank=True)
|
||||
track_number = models.IntegerField(null=True, blank=True)
|
||||
|
||||
# File details
|
||||
file = models.FileField(upload_to='local_audio/%Y/%m/', max_length=500)
|
||||
file_size = models.BigIntegerField(help_text="File size in bytes")
|
||||
duration = models.IntegerField(null=True, blank=True, help_text="Duration in seconds")
|
||||
|
||||
# Audio properties
|
||||
audio_format = models.CharField(max_length=20, blank=True) # mp3, flac, m4a, etc.
|
||||
bitrate = models.IntegerField(null=True, blank=True, help_text="Bitrate in kbps")
|
||||
sample_rate = models.IntegerField(null=True, blank=True, help_text="Sample rate in Hz")
|
||||
channels = models.IntegerField(null=True, blank=True, help_text="Number of audio channels")
|
||||
|
||||
# Cover art
|
||||
cover_art = models.ImageField(upload_to='local_audio_covers/%Y/%m/', null=True, blank=True)
|
||||
|
||||
# Metadata
|
||||
original_filename = models.CharField(max_length=500)
|
||||
uploaded_date = models.DateTimeField(auto_now_add=True)
|
||||
modified_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Playback tracking
|
||||
play_count = models.IntegerField(default=0)
|
||||
last_played = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Tags and organization
|
||||
tags = models.JSONField(default=list, blank=True, help_text="User-defined tags")
|
||||
notes = models.TextField(blank=True, help_text="User notes about this file")
|
||||
|
||||
# Favorites
|
||||
is_favorite = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-uploaded_date']
|
||||
indexes = [
|
||||
models.Index(fields=['owner', '-uploaded_date']),
|
||||
models.Index(fields=['owner', 'is_favorite']),
|
||||
models.Index(fields=['owner', 'artist']),
|
||||
models.Index(fields=['owner', 'album']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.owner.username} - {self.title}"
|
||||
|
||||
@property
|
||||
def file_size_mb(self):
|
||||
"""Get file size in MB"""
|
||||
return self.file_size / (1024 * 1024)
|
||||
|
||||
@property
|
||||
def duration_formatted(self):
|
||||
"""Get formatted duration (MM:SS)"""
|
||||
if not self.duration:
|
||||
return "00:00"
|
||||
minutes = self.duration // 60
|
||||
seconds = self.duration % 60
|
||||
return f"{minutes:02d}:{seconds:02d}"
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Override delete to remove files"""
|
||||
# Delete the audio file
|
||||
if self.file:
|
||||
if os.path.isfile(self.file.path):
|
||||
os.remove(self.file.path)
|
||||
|
||||
# Delete cover art
|
||||
if self.cover_art:
|
||||
if os.path.isfile(self.cover_art.path):
|
||||
os.remove(self.cover_art.path)
|
||||
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class LocalAudioPlaylist(models.Model):
|
||||
"""Playlists for local audio files"""
|
||||
|
||||
owner = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='local_playlists'
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
# Playlist image
|
||||
cover_image = models.ImageField(upload_to='local_playlist_covers/', null=True, blank=True)
|
||||
|
||||
# Timestamps
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
modified_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_date']
|
||||
indexes = [
|
||||
models.Index(fields=['owner', '-created_date']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.owner.username} - {self.title}"
|
||||
|
||||
|
||||
class LocalAudioPlaylistItem(models.Model):
|
||||
"""Items in local audio playlist"""
|
||||
|
||||
playlist = models.ForeignKey(
|
||||
LocalAudioPlaylist,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items'
|
||||
)
|
||||
audio = models.ForeignKey(
|
||||
LocalAudio,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='playlist_items'
|
||||
)
|
||||
position = models.IntegerField(default=0)
|
||||
added_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['position']
|
||||
unique_together = ('playlist', 'audio')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.playlist.title} - {self.audio.title}"
|
||||
101
backend/audio/models_lyrics.py
Normal file
101
backend/audio/models_lyrics.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Lyrics models for audio tracks"""
|
||||
from django.db import models
|
||||
from audio.models import Audio
|
||||
|
||||
|
||||
class Lyrics(models.Model):
|
||||
"""Store lyrics for audio tracks"""
|
||||
|
||||
audio = models.OneToOneField(
|
||||
Audio,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='lyrics',
|
||||
primary_key=True
|
||||
)
|
||||
|
||||
# Synced lyrics in LRC format [mm:ss.xx]text
|
||||
synced_lyrics = models.TextField(blank=True, default='')
|
||||
|
||||
# Plain text lyrics without timestamps
|
||||
plain_lyrics = models.TextField(blank=True, default='')
|
||||
|
||||
# Track is instrumental (no lyrics)
|
||||
is_instrumental = models.BooleanField(default=False)
|
||||
|
||||
# Lyrics source (lrclib, genius, manual, etc.)
|
||||
source = models.CharField(max_length=50, default='lrclib')
|
||||
|
||||
# Language code (en, es, fr, etc.)
|
||||
language = models.CharField(max_length=10, blank=True, default='')
|
||||
|
||||
# When lyrics were fetched
|
||||
fetched_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Whether fetch was attempted (to avoid repeated failures)
|
||||
fetch_attempted = models.BooleanField(default=False)
|
||||
|
||||
# Number of fetch attempts
|
||||
fetch_attempts = models.IntegerField(default=0)
|
||||
|
||||
# Last fetch error message
|
||||
last_error = models.TextField(blank=True, default='')
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Lyrics"
|
||||
ordering = ['-fetched_date']
|
||||
|
||||
def __str__(self):
|
||||
return f"Lyrics for {self.audio.title}"
|
||||
|
||||
@property
|
||||
def has_lyrics(self):
|
||||
"""Check if lyrics are available"""
|
||||
return bool(self.synced_lyrics or self.plain_lyrics)
|
||||
|
||||
@property
|
||||
def is_synced(self):
|
||||
"""Check if lyrics are synchronized"""
|
||||
return bool(self.synced_lyrics)
|
||||
|
||||
def get_display_lyrics(self):
|
||||
"""Get lyrics for display (prefer synced over plain)"""
|
||||
if self.is_instrumental:
|
||||
return "[Instrumental]"
|
||||
return self.synced_lyrics or self.plain_lyrics or ""
|
||||
|
||||
|
||||
class LyricsCache(models.Model):
|
||||
"""Cache for LRCLIB API responses to avoid duplicate requests"""
|
||||
|
||||
# Composite key: title + artist + album + duration
|
||||
title = models.CharField(max_length=500)
|
||||
artist_name = models.CharField(max_length=500)
|
||||
album_name = models.CharField(max_length=500, blank=True, default='')
|
||||
duration = models.IntegerField() # Duration in seconds
|
||||
|
||||
# Cached response
|
||||
synced_lyrics = models.TextField(blank=True, default='')
|
||||
plain_lyrics = models.TextField(blank=True, default='')
|
||||
is_instrumental = models.BooleanField(default=False)
|
||||
|
||||
# Metadata
|
||||
language = models.CharField(max_length=10, blank=True, default='')
|
||||
source = models.CharField(max_length=50, default='lrclib')
|
||||
|
||||
# Cache management
|
||||
cached_date = models.DateTimeField(auto_now_add=True)
|
||||
last_accessed = models.DateTimeField(auto_now=True)
|
||||
access_count = models.IntegerField(default=0)
|
||||
|
||||
# Whether this is a "not found" cache entry
|
||||
not_found = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['title', 'artist_name', 'duration']),
|
||||
models.Index(fields=['cached_date']),
|
||||
]
|
||||
unique_together = [['title', 'artist_name', 'album_name', 'duration']]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} - {self.artist_name}"
|
||||
354
backend/audio/quick_sync_service.py
Normal file
354
backend/audio/quick_sync_service.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
"""Quick Sync service for adaptive streaming based on network and system resources"""
|
||||
import psutil
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuickSyncService:
|
||||
"""Service for adaptive streaming quality based on network and system resources"""
|
||||
|
||||
# Quality presets
|
||||
QUALITY_PRESETS = {
|
||||
'low': {
|
||||
'bitrate': 64, # kbps
|
||||
'buffer_size': 5, # seconds
|
||||
'preload': 'metadata',
|
||||
'description': 'Low quality - saves bandwidth',
|
||||
},
|
||||
'medium': {
|
||||
'bitrate': 128, # kbps
|
||||
'buffer_size': 10, # seconds
|
||||
'preload': 'auto',
|
||||
'description': 'Medium quality - balanced',
|
||||
},
|
||||
'high': {
|
||||
'bitrate': 256, # kbps
|
||||
'buffer_size': 15, # seconds
|
||||
'preload': 'auto',
|
||||
'description': 'High quality - best experience',
|
||||
},
|
||||
'ultra': {
|
||||
'bitrate': 320, # kbps
|
||||
'buffer_size': 20, # seconds
|
||||
'preload': 'auto',
|
||||
'description': 'Ultra quality - maximum fidelity',
|
||||
},
|
||||
'auto': {
|
||||
'bitrate': 0, # Auto-detect
|
||||
'buffer_size': 0, # Auto-adjust
|
||||
'preload': 'auto',
|
||||
'description': 'Automatic - adapts to connection',
|
||||
},
|
||||
}
|
||||
|
||||
# Network speed thresholds (Mbps)
|
||||
SPEED_THRESHOLDS = {
|
||||
'ultra': 5.0, # 5 Mbps+
|
||||
'high': 2.0, # 2-5 Mbps
|
||||
'medium': 1.0, # 1-2 Mbps
|
||||
'low': 0.5, # 0.5-1 Mbps
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.cache_timeout = 300 # 5 minutes
|
||||
|
||||
def get_system_resources(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current system resource usage
|
||||
|
||||
Returns:
|
||||
Dictionary with CPU, memory, and disk usage
|
||||
"""
|
||||
try:
|
||||
resources = {
|
||||
'cpu_percent': psutil.cpu_percent(interval=1),
|
||||
'memory_percent': psutil.virtual_memory().percent,
|
||||
'memory_available_mb': psutil.virtual_memory().available / (1024 * 1024),
|
||||
'disk_usage_percent': psutil.disk_usage('/').percent,
|
||||
'timestamp': time.time(),
|
||||
}
|
||||
|
||||
# Cache the results
|
||||
cache.set('quick_sync_system_resources', resources, self.cache_timeout)
|
||||
|
||||
return resources
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting system resources: {e}")
|
||||
return {
|
||||
'cpu_percent': 50,
|
||||
'memory_percent': 50,
|
||||
'memory_available_mb': 1000,
|
||||
'disk_usage_percent': 50,
|
||||
'timestamp': time.time(),
|
||||
}
|
||||
|
||||
def measure_network_speed(self, test_url: str = None, timeout: int = 5) -> float:
|
||||
"""
|
||||
Measure network download speed
|
||||
|
||||
Args:
|
||||
test_url: URL to download for speed test
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
Download speed in Mbps
|
||||
"""
|
||||
# Check cache first
|
||||
cached_speed = cache.get('quick_sync_network_speed')
|
||||
if cached_speed is not None:
|
||||
return cached_speed
|
||||
|
||||
try:
|
||||
# Use a small test file (1MB)
|
||||
if not test_url:
|
||||
# Use a reliable CDN for speed testing
|
||||
test_url = 'https://speed.cloudflare.com/__down?bytes=1000000'
|
||||
|
||||
start_time = time.time()
|
||||
response = requests.get(test_url, timeout=timeout, stream=True)
|
||||
|
||||
# Download 1MB
|
||||
chunk_size = 8192
|
||||
downloaded = 0
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
downloaded += len(chunk)
|
||||
if downloaded >= 1000000: # 1MB
|
||||
break
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Calculate speed in Mbps
|
||||
speed_mbps = (downloaded * 8) / (elapsed * 1000000)
|
||||
|
||||
# Cache the result
|
||||
cache.set('quick_sync_network_speed', speed_mbps, self.cache_timeout)
|
||||
|
||||
logger.info(f"Network speed measured: {speed_mbps:.2f} Mbps")
|
||||
|
||||
return speed_mbps
|
||||
except Exception as e:
|
||||
logger.warning(f"Error measuring network speed: {e}")
|
||||
# Return conservative estimate
|
||||
return 2.0
|
||||
|
||||
def get_recommended_quality(self, user_preferences: Dict[str, Any] = None) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get recommended quality based on network speed and system resources
|
||||
|
||||
Args:
|
||||
user_preferences: User's quick sync preferences
|
||||
|
||||
Returns:
|
||||
Tuple of (quality_level, quality_settings)
|
||||
"""
|
||||
# Get user preferences
|
||||
if not user_preferences:
|
||||
user_preferences = {
|
||||
'mode': 'auto',
|
||||
'prefer_quality': True,
|
||||
'adapt_to_system': True,
|
||||
}
|
||||
|
||||
mode = user_preferences.get('mode', 'auto')
|
||||
|
||||
# If manual mode, return the specified quality
|
||||
if mode != 'auto':
|
||||
return mode, self.QUALITY_PRESETS[mode]
|
||||
|
||||
# Auto mode - detect optimal quality
|
||||
network_speed = self.measure_network_speed()
|
||||
system_resources = self.get_system_resources()
|
||||
|
||||
# Determine quality based on network speed
|
||||
if network_speed >= self.SPEED_THRESHOLDS['ultra']:
|
||||
quality = 'ultra'
|
||||
elif network_speed >= self.SPEED_THRESHOLDS['high']:
|
||||
quality = 'high'
|
||||
elif network_speed >= self.SPEED_THRESHOLDS['medium']:
|
||||
quality = 'medium'
|
||||
else:
|
||||
quality = 'low'
|
||||
|
||||
# Adjust based on system resources if enabled
|
||||
if user_preferences.get('adapt_to_system', True):
|
||||
cpu_percent = system_resources.get('cpu_percent', 50)
|
||||
memory_percent = system_resources.get('memory_percent', 50)
|
||||
|
||||
# Downgrade quality if system is under heavy load
|
||||
if cpu_percent > 80 or memory_percent > 85:
|
||||
if quality == 'ultra':
|
||||
quality = 'high'
|
||||
elif quality == 'high':
|
||||
quality = 'medium'
|
||||
|
||||
# Upgrade if system has plenty of resources and user prefers quality
|
||||
elif cpu_percent < 30 and memory_percent < 50 and user_preferences.get('prefer_quality', False):
|
||||
if quality == 'medium' and network_speed >= self.SPEED_THRESHOLDS['high']:
|
||||
quality = 'high'
|
||||
elif quality == 'high' and network_speed >= self.SPEED_THRESHOLDS['ultra']:
|
||||
quality = 'ultra'
|
||||
|
||||
settings = self.QUALITY_PRESETS[quality].copy()
|
||||
settings['auto_selected'] = True
|
||||
settings['network_speed_mbps'] = network_speed
|
||||
settings['cpu_percent'] = system_resources.get('cpu_percent')
|
||||
settings['memory_percent'] = system_resources.get('memory_percent')
|
||||
|
||||
logger.info(f"Recommended quality: {quality} (speed: {network_speed:.2f} Mbps)")
|
||||
|
||||
return quality, settings
|
||||
|
||||
def get_buffer_settings(self, quality: str, network_speed: float = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get optimal buffer settings for quality level
|
||||
|
||||
Args:
|
||||
quality: Quality level
|
||||
network_speed: Current network speed in Mbps
|
||||
|
||||
Returns:
|
||||
Buffer configuration
|
||||
"""
|
||||
base_settings = self.QUALITY_PRESETS.get(quality, self.QUALITY_PRESETS['medium'])
|
||||
|
||||
buffer_config = {
|
||||
'buffer_size': base_settings['buffer_size'],
|
||||
'preload': base_settings['preload'],
|
||||
'max_buffer_size': base_settings['buffer_size'] * 2,
|
||||
'rebuffer_threshold': base_settings['buffer_size'] * 0.3,
|
||||
}
|
||||
|
||||
# Adjust based on network speed if provided
|
||||
if network_speed:
|
||||
if network_speed < 1.0:
|
||||
# Slow connection - increase buffer
|
||||
buffer_config['buffer_size'] = max(buffer_config['buffer_size'], 15)
|
||||
buffer_config['preload'] = 'auto'
|
||||
elif network_speed > 5.0:
|
||||
# Fast connection - can use smaller buffer
|
||||
buffer_config['buffer_size'] = min(buffer_config['buffer_size'], 10)
|
||||
|
||||
return buffer_config
|
||||
|
||||
def get_quick_sync_status(self, user_preferences: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Get complete quick sync status and recommendations
|
||||
|
||||
Args:
|
||||
user_preferences: User's quick sync preferences
|
||||
|
||||
Returns:
|
||||
Complete status including network, system, and quality info
|
||||
"""
|
||||
network_speed = self.measure_network_speed()
|
||||
system_resources = self.get_system_resources()
|
||||
quality, quality_settings = self.get_recommended_quality(user_preferences)
|
||||
buffer_settings = self.get_buffer_settings(quality, network_speed)
|
||||
|
||||
status = {
|
||||
'network': {
|
||||
'speed_mbps': network_speed,
|
||||
'status': self._get_network_status(network_speed),
|
||||
},
|
||||
'system': {
|
||||
'cpu_percent': system_resources['cpu_percent'],
|
||||
'memory_percent': system_resources['memory_percent'],
|
||||
'memory_available_mb': system_resources['memory_available_mb'],
|
||||
'status': self._get_system_status(system_resources),
|
||||
},
|
||||
'quality': {
|
||||
'level': quality,
|
||||
'bitrate': quality_settings['bitrate'],
|
||||
'description': quality_settings['description'],
|
||||
'auto_selected': quality_settings.get('auto_selected', False),
|
||||
},
|
||||
'buffer': buffer_settings,
|
||||
'timestamp': time.time(),
|
||||
}
|
||||
|
||||
return status
|
||||
|
||||
def _get_network_status(self, speed_mbps: float) -> str:
|
||||
"""Get network status description"""
|
||||
if speed_mbps >= 5.0:
|
||||
return 'excellent'
|
||||
elif speed_mbps >= 2.0:
|
||||
return 'good'
|
||||
elif speed_mbps >= 1.0:
|
||||
return 'fair'
|
||||
else:
|
||||
return 'poor'
|
||||
|
||||
def _get_system_status(self, resources: Dict[str, Any]) -> str:
|
||||
"""Get system status description"""
|
||||
cpu = resources.get('cpu_percent', 50)
|
||||
memory = resources.get('memory_percent', 50)
|
||||
|
||||
if cpu > 80 or memory > 85:
|
||||
return 'high_load'
|
||||
elif cpu > 50 or memory > 70:
|
||||
return 'moderate_load'
|
||||
else:
|
||||
return 'low_load'
|
||||
|
||||
def update_user_preferences(self, user_id: int, preferences: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Update user's quick sync preferences
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
preferences: New preferences
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
# Validate preferences
|
||||
mode = preferences.get('mode', 'auto')
|
||||
if mode not in self.QUALITY_PRESETS:
|
||||
mode = 'auto'
|
||||
|
||||
prefs = {
|
||||
'mode': mode,
|
||||
'prefer_quality': preferences.get('prefer_quality', True),
|
||||
'adapt_to_system': preferences.get('adapt_to_system', True),
|
||||
'auto_download_quality': preferences.get('auto_download_quality', False),
|
||||
}
|
||||
|
||||
# Cache user preferences
|
||||
cache.set(f'quick_sync_prefs_{user_id}', prefs, timeout=None)
|
||||
|
||||
logger.info(f"Updated quick sync preferences for user {user_id}: {prefs}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating quick sync preferences: {e}")
|
||||
return False
|
||||
|
||||
def get_user_preferences(self, user_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Get user's quick sync preferences
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
User preferences or defaults
|
||||
"""
|
||||
prefs = cache.get(f'quick_sync_prefs_{user_id}')
|
||||
if prefs:
|
||||
return prefs
|
||||
|
||||
# Return defaults
|
||||
return {
|
||||
'mode': 'auto',
|
||||
'prefer_quality': True,
|
||||
'adapt_to_system': True,
|
||||
'auto_download_quality': False,
|
||||
}
|
||||
65
backend/audio/serializers.py
Normal file
65
backend/audio/serializers.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Audio serializers"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from audio.models import Audio, AudioProgress
|
||||
|
||||
|
||||
class AudioSerializer(serializers.ModelSerializer):
|
||||
"""Audio file serializer"""
|
||||
|
||||
class Meta:
|
||||
model = Audio
|
||||
fields = [
|
||||
'id', 'youtube_id', 'title', 'description', 'channel_id',
|
||||
'channel_name', 'duration', 'file_path', 'file_size',
|
||||
'thumbnail_url', 'published_date', 'downloaded_date',
|
||||
'view_count', 'like_count', 'audio_format', 'bitrate',
|
||||
'play_count', 'last_played'
|
||||
]
|
||||
read_only_fields = ['id', 'downloaded_date', 'play_count', 'last_played']
|
||||
|
||||
|
||||
class AudioListSerializer(serializers.Serializer):
|
||||
"""Audio list response"""
|
||||
data = AudioSerializer(many=True)
|
||||
paginate = serializers.BooleanField(default=True)
|
||||
|
||||
|
||||
class AudioProgressSerializer(serializers.ModelSerializer):
|
||||
"""Audio progress serializer"""
|
||||
|
||||
class Meta:
|
||||
model = AudioProgress
|
||||
fields = ['id', 'audio', 'position', 'completed', 'last_updated']
|
||||
read_only_fields = ['id', 'last_updated']
|
||||
|
||||
|
||||
class AudioProgressUpdateSerializer(serializers.Serializer):
|
||||
"""Update audio progress"""
|
||||
position = serializers.IntegerField(min_value=0)
|
||||
completed = serializers.BooleanField(default=False)
|
||||
|
||||
|
||||
class AudioListQuerySerializer(serializers.Serializer):
|
||||
"""Query parameters for audio list"""
|
||||
channel = serializers.CharField(required=False)
|
||||
playlist = serializers.CharField(required=False)
|
||||
status = serializers.ChoiceField(
|
||||
choices=['played', 'unplayed', 'continue'],
|
||||
required=False
|
||||
)
|
||||
sort = serializers.ChoiceField(
|
||||
choices=['published', 'downloaded', 'views', 'likes', 'duration'],
|
||||
default='published'
|
||||
)
|
||||
order = serializers.ChoiceField(
|
||||
choices=['asc', 'desc'],
|
||||
default='desc'
|
||||
)
|
||||
|
||||
|
||||
class PlayerSerializer(serializers.Serializer):
|
||||
"""Audio player data"""
|
||||
audio = AudioSerializer()
|
||||
progress = AudioProgressSerializer(required=False)
|
||||
stream_url = serializers.URLField()
|
||||
92
backend/audio/serializers_artwork.py
Normal file
92
backend/audio/serializers_artwork.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Serializers for artwork and metadata"""
|
||||
from rest_framework import serializers
|
||||
from audio.models_artwork import Artwork, MusicMetadata, ArtistInfo
|
||||
|
||||
|
||||
class ArtworkSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Artwork model"""
|
||||
|
||||
class Meta:
|
||||
model = Artwork
|
||||
fields = [
|
||||
'id',
|
||||
'audio',
|
||||
'channel',
|
||||
'artwork_type',
|
||||
'source',
|
||||
'url',
|
||||
'local_path',
|
||||
'width',
|
||||
'height',
|
||||
'priority',
|
||||
'is_primary',
|
||||
'created_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class MusicMetadataSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for MusicMetadata model"""
|
||||
|
||||
class Meta:
|
||||
model = MusicMetadata
|
||||
fields = [
|
||||
'id',
|
||||
'audio',
|
||||
'album_name',
|
||||
'album_artist',
|
||||
'release_year',
|
||||
'track_number',
|
||||
'disc_number',
|
||||
'genre',
|
||||
'tags',
|
||||
'lastfm_url',
|
||||
'lastfm_mbid',
|
||||
'play_count',
|
||||
'listeners',
|
||||
'fanart_artist_id',
|
||||
'fanart_album_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class ArtistInfoSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ArtistInfo model"""
|
||||
|
||||
class Meta:
|
||||
model = ArtistInfo
|
||||
fields = [
|
||||
'id',
|
||||
'channel',
|
||||
'bio',
|
||||
'bio_summary',
|
||||
'lastfm_url',
|
||||
'lastfm_mbid',
|
||||
'lastfm_listeners',
|
||||
'lastfm_playcount',
|
||||
'tags',
|
||||
'fanart_id',
|
||||
'similar_artists',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class AudioWithArtworkSerializer(serializers.Serializer):
|
||||
"""Serializer for audio with artwork"""
|
||||
audio_id = serializers.IntegerField()
|
||||
audio_title = serializers.CharField()
|
||||
artist = serializers.CharField()
|
||||
artwork = ArtworkSerializer(many=True)
|
||||
metadata = MusicMetadataSerializer(required=False)
|
||||
|
||||
|
||||
class ChannelWithArtworkSerializer(serializers.Serializer):
|
||||
"""Serializer for channel with artwork"""
|
||||
channel_id = serializers.IntegerField()
|
||||
channel_name = serializers.CharField()
|
||||
artwork = ArtworkSerializer(many=True)
|
||||
artist_info = ArtistInfoSerializer(required=False)
|
||||
186
backend/audio/serializers_local.py
Normal file
186
backend/audio/serializers_local.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"""Serializers for local audio files"""
|
||||
|
||||
from rest_framework import serializers
|
||||
from audio.models_local import LocalAudio, LocalAudioPlaylist, LocalAudioPlaylistItem
|
||||
|
||||
|
||||
class LocalAudioSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for local audio files"""
|
||||
file_size_mb = serializers.FloatField(read_only=True)
|
||||
duration_formatted = serializers.CharField(read_only=True)
|
||||
file_url = serializers.SerializerMethodField()
|
||||
cover_art_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = LocalAudio
|
||||
fields = [
|
||||
'id',
|
||||
'title',
|
||||
'artist',
|
||||
'album',
|
||||
'year',
|
||||
'genre',
|
||||
'track_number',
|
||||
'file',
|
||||
'file_url',
|
||||
'file_size',
|
||||
'file_size_mb',
|
||||
'duration',
|
||||
'duration_formatted',
|
||||
'audio_format',
|
||||
'bitrate',
|
||||
'sample_rate',
|
||||
'channels',
|
||||
'cover_art',
|
||||
'cover_art_url',
|
||||
'original_filename',
|
||||
'uploaded_date',
|
||||
'modified_date',
|
||||
'play_count',
|
||||
'last_played',
|
||||
'tags',
|
||||
'notes',
|
||||
'is_favorite',
|
||||
]
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'file_size',
|
||||
'duration',
|
||||
'audio_format',
|
||||
'bitrate',
|
||||
'sample_rate',
|
||||
'channels',
|
||||
'original_filename',
|
||||
'uploaded_date',
|
||||
'modified_date',
|
||||
'play_count',
|
||||
'last_played',
|
||||
]
|
||||
|
||||
def get_file_url(self, obj):
|
||||
"""Get full URL for audio file"""
|
||||
request = self.context.get('request')
|
||||
if obj.file and request:
|
||||
return request.build_absolute_uri(obj.file.url)
|
||||
return None
|
||||
|
||||
def get_cover_art_url(self, obj):
|
||||
"""Get full URL for cover art"""
|
||||
request = self.context.get('request')
|
||||
if obj.cover_art and request:
|
||||
return request.build_absolute_uri(obj.cover_art.url)
|
||||
return None
|
||||
|
||||
|
||||
class LocalAudioUploadSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for uploading local audio files"""
|
||||
# Make title optional - will be extracted from ID3 tags if not provided
|
||||
title = serializers.CharField(required=False, allow_blank=True, max_length=500)
|
||||
|
||||
class Meta:
|
||||
model = LocalAudio
|
||||
fields = [
|
||||
'file',
|
||||
'title',
|
||||
'artist',
|
||||
'album',
|
||||
'year',
|
||||
'genre',
|
||||
'track_number',
|
||||
'cover_art',
|
||||
'tags',
|
||||
'notes',
|
||||
]
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Extract metadata and create local audio"""
|
||||
file = validated_data['file']
|
||||
|
||||
# Set original filename
|
||||
validated_data['original_filename'] = file.name
|
||||
validated_data['file_size'] = file.size
|
||||
|
||||
# Extract audio format from filename
|
||||
file_extension = file.name.split('.')[-1].lower()
|
||||
validated_data['audio_format'] = file_extension
|
||||
|
||||
# Try to extract metadata using mutagen
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
audio_file = MutagenFile(file)
|
||||
|
||||
if audio_file:
|
||||
# Get duration
|
||||
if hasattr(audio_file.info, 'length'):
|
||||
validated_data['duration'] = int(audio_file.info.length)
|
||||
|
||||
# Get bitrate
|
||||
if hasattr(audio_file.info, 'bitrate'):
|
||||
validated_data['bitrate'] = int(audio_file.info.bitrate / 1000) # Convert to kbps
|
||||
|
||||
# Get sample rate
|
||||
if hasattr(audio_file.info, 'sample_rate'):
|
||||
validated_data['sample_rate'] = audio_file.info.sample_rate
|
||||
|
||||
# Get channels
|
||||
if hasattr(audio_file.info, 'channels'):
|
||||
validated_data['channels'] = audio_file.info.channels
|
||||
|
||||
# Extract ID3 tags if not provided
|
||||
if not validated_data.get('title'):
|
||||
validated_data['title'] = audio_file.get('TIT2', [file.name])[0] if hasattr(audio_file, 'get') else file.name
|
||||
|
||||
if not validated_data.get('artist'):
|
||||
validated_data['artist'] = audio_file.get('TPE1', [''])[0] if hasattr(audio_file, 'get') else ''
|
||||
|
||||
if not validated_data.get('album'):
|
||||
validated_data['album'] = audio_file.get('TALB', [''])[0] if hasattr(audio_file, 'get') else ''
|
||||
except Exception as e:
|
||||
# If metadata extraction fails, use filename as title
|
||||
if not validated_data.get('title'):
|
||||
validated_data['title'] = file.name
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class LocalAudioPlaylistItemSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for playlist items"""
|
||||
audio_data = LocalAudioSerializer(source='audio', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = LocalAudioPlaylistItem
|
||||
fields = ['id', 'audio', 'audio_data', 'position', 'added_date']
|
||||
read_only_fields = ['id', 'added_date']
|
||||
|
||||
|
||||
class LocalAudioPlaylistSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for local audio playlists"""
|
||||
items = LocalAudioPlaylistItemSerializer(many=True, read_only=True)
|
||||
items_count = serializers.SerializerMethodField()
|
||||
cover_image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = LocalAudioPlaylist
|
||||
fields = [
|
||||
'id',
|
||||
'title',
|
||||
'description',
|
||||
'cover_image',
|
||||
'cover_image_url',
|
||||
'created_date',
|
||||
'modified_date',
|
||||
'items',
|
||||
'items_count',
|
||||
]
|
||||
read_only_fields = ['id', 'created_date', 'modified_date']
|
||||
|
||||
def get_items_count(self, obj):
|
||||
"""Get number of items in playlist"""
|
||||
return obj.items.count()
|
||||
|
||||
def get_cover_image_url(self, obj):
|
||||
"""Get full URL for cover image"""
|
||||
request = self.context.get('request')
|
||||
if obj.cover_image and request:
|
||||
return request.build_absolute_uri(obj.cover_image.url)
|
||||
return None
|
||||
87
backend/audio/serializers_lyrics.py
Normal file
87
backend/audio/serializers_lyrics.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Serializers for lyrics"""
|
||||
from rest_framework import serializers
|
||||
from audio.models_lyrics import Lyrics, LyricsCache
|
||||
|
||||
|
||||
class LyricsSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Lyrics model"""
|
||||
|
||||
audio_id = serializers.CharField(source='audio.youtube_id', read_only=True)
|
||||
audio_title = serializers.CharField(source='audio.title', read_only=True)
|
||||
has_lyrics = serializers.BooleanField(read_only=True)
|
||||
is_synced = serializers.BooleanField(read_only=True)
|
||||
display_lyrics = serializers.CharField(source='get_display_lyrics', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Lyrics
|
||||
fields = [
|
||||
'audio_id',
|
||||
'audio_title',
|
||||
'synced_lyrics',
|
||||
'plain_lyrics',
|
||||
'is_instrumental',
|
||||
'source',
|
||||
'language',
|
||||
'fetched_date',
|
||||
'fetch_attempted',
|
||||
'fetch_attempts',
|
||||
'last_error',
|
||||
'has_lyrics',
|
||||
'is_synced',
|
||||
'display_lyrics',
|
||||
]
|
||||
read_only_fields = [
|
||||
'audio_id',
|
||||
'audio_title',
|
||||
'fetched_date',
|
||||
'fetch_attempted',
|
||||
'fetch_attempts',
|
||||
'last_error',
|
||||
'has_lyrics',
|
||||
'is_synced',
|
||||
'display_lyrics',
|
||||
]
|
||||
|
||||
|
||||
class LyricsUpdateSerializer(serializers.Serializer):
|
||||
"""Serializer for manually updating lyrics"""
|
||||
|
||||
synced_lyrics = serializers.CharField(required=False, allow_blank=True)
|
||||
plain_lyrics = serializers.CharField(required=False, allow_blank=True)
|
||||
is_instrumental = serializers.BooleanField(required=False, default=False)
|
||||
language = serializers.CharField(required=False, allow_blank=True, max_length=10)
|
||||
|
||||
|
||||
class LyricsFetchSerializer(serializers.Serializer):
|
||||
"""Serializer for fetching lyrics"""
|
||||
|
||||
force = serializers.BooleanField(default=False, required=False)
|
||||
|
||||
|
||||
class LyricsCacheSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for LyricsCache model"""
|
||||
|
||||
class Meta:
|
||||
model = LyricsCache
|
||||
fields = [
|
||||
'id',
|
||||
'title',
|
||||
'artist_name',
|
||||
'album_name',
|
||||
'duration',
|
||||
'synced_lyrics',
|
||||
'plain_lyrics',
|
||||
'is_instrumental',
|
||||
'language',
|
||||
'source',
|
||||
'cached_date',
|
||||
'last_accessed',
|
||||
'access_count',
|
||||
'not_found',
|
||||
]
|
||||
read_only_fields = [
|
||||
'id',
|
||||
'cached_date',
|
||||
'last_accessed',
|
||||
'access_count',
|
||||
]
|
||||
556
backend/audio/tasks_artwork.py
Normal file
556
backend/audio/tasks_artwork.py
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
"""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}")
|
||||
217
backend/audio/tasks_lyrics.py
Normal file
217
backend/audio/tasks_lyrics.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""Celery tasks for automatic lyrics fetching"""
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name="audio.fetch_lyrics_for_audio")
|
||||
def fetch_lyrics_for_audio(audio_youtube_id: str, force: bool = False):
|
||||
"""
|
||||
Fetch lyrics for a single audio track
|
||||
|
||||
Args:
|
||||
audio_youtube_id: YouTube ID of the audio
|
||||
force: Force fetch even if already attempted
|
||||
"""
|
||||
from audio.models import Audio
|
||||
from audio.lyrics_service import LyricsService
|
||||
|
||||
try:
|
||||
audio = Audio.objects.get(youtube_id=audio_youtube_id)
|
||||
service = LyricsService()
|
||||
service.fetch_and_store_lyrics(audio, force=force)
|
||||
logger.info(f"Fetched lyrics for {audio.title}")
|
||||
return {"status": "success", "youtube_id": audio_youtube_id}
|
||||
except Audio.DoesNotExist:
|
||||
logger.error(f"Audio not found: {audio_youtube_id}")
|
||||
return {"status": "error", "error": "Audio not found"}
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching lyrics for {audio_youtube_id}: {e}")
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
@shared_task(name="audio.fetch_lyrics_batch")
|
||||
def fetch_lyrics_batch(audio_ids: list, delay_seconds: int = 2):
|
||||
"""
|
||||
Fetch lyrics for multiple audio tracks with delay between requests
|
||||
|
||||
Args:
|
||||
audio_ids: List of YouTube IDs
|
||||
delay_seconds: Delay between requests to avoid rate limiting
|
||||
"""
|
||||
import time
|
||||
from audio.models import Audio
|
||||
from audio.lyrics_service import LyricsService
|
||||
|
||||
service = LyricsService()
|
||||
results = {
|
||||
'success': 0,
|
||||
'failed': 0,
|
||||
'skipped': 0,
|
||||
}
|
||||
|
||||
for youtube_id in audio_ids:
|
||||
try:
|
||||
audio = Audio.objects.get(youtube_id=youtube_id)
|
||||
service.fetch_and_store_lyrics(audio, force=False)
|
||||
results['success'] += 1
|
||||
logger.info(f"Fetched lyrics for {audio.title}")
|
||||
except Audio.DoesNotExist:
|
||||
results['skipped'] += 1
|
||||
logger.warning(f"Audio not found: {youtube_id}")
|
||||
except Exception as e:
|
||||
results['failed'] += 1
|
||||
logger.error(f"Error fetching lyrics for {youtube_id}: {e}")
|
||||
|
||||
# Delay to avoid rate limiting
|
||||
if delay_seconds > 0:
|
||||
time.sleep(delay_seconds)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@shared_task(name="audio.auto_fetch_lyrics")
|
||||
def auto_fetch_lyrics(limit: int = 50, max_attempts: int = 3):
|
||||
"""
|
||||
Automatically fetch lyrics for audio without lyrics
|
||||
|
||||
This task should be scheduled to run periodically (e.g., every hour)
|
||||
|
||||
Args:
|
||||
limit: Maximum number of tracks to process
|
||||
max_attempts: Skip tracks that have been attempted this many times
|
||||
"""
|
||||
from audio.models import Audio
|
||||
from audio.models_lyrics import Lyrics
|
||||
from audio.lyrics_service import LyricsService
|
||||
|
||||
# Find audio without lyrics or with failed attempts
|
||||
audio_without_lyrics = Audio.objects.filter(
|
||||
downloaded=True
|
||||
).exclude(
|
||||
lyrics__fetch_attempted=True,
|
||||
lyrics__fetch_attempts__gte=max_attempts
|
||||
)[:limit]
|
||||
|
||||
if not audio_without_lyrics:
|
||||
logger.info("No audio tracks need lyrics fetching")
|
||||
return {"status": "no_work", "message": "No tracks need lyrics"}
|
||||
|
||||
service = LyricsService()
|
||||
results = {
|
||||
'processed': 0,
|
||||
'success': 0,
|
||||
'failed': 0,
|
||||
}
|
||||
|
||||
for audio in audio_without_lyrics:
|
||||
try:
|
||||
lyrics = service.fetch_and_store_lyrics(audio, force=False)
|
||||
results['processed'] += 1
|
||||
|
||||
if lyrics.has_lyrics:
|
||||
results['success'] += 1
|
||||
else:
|
||||
results['failed'] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in auto-fetch for {audio.title}: {e}")
|
||||
results['failed'] += 1
|
||||
|
||||
# Small delay to be nice to the API
|
||||
import time
|
||||
time.sleep(1)
|
||||
|
||||
logger.info(f"Auto-fetch completed: {results}")
|
||||
return results
|
||||
|
||||
|
||||
@shared_task(name="audio.cleanup_lyrics_cache")
|
||||
def cleanup_lyrics_cache(days_old: int = 30):
|
||||
"""
|
||||
Clean up old lyrics cache entries
|
||||
|
||||
Args:
|
||||
days_old: Remove cache entries older than this many days
|
||||
"""
|
||||
from audio.models_lyrics import LyricsCache
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = timezone.now() - timedelta(days=days_old)
|
||||
|
||||
# Delete old not_found entries
|
||||
deleted_count = LyricsCache.objects.filter(
|
||||
not_found=True,
|
||||
cached_date__lt=cutoff_date
|
||||
).delete()[0]
|
||||
|
||||
# Delete old unused entries (not accessed in the last N days)
|
||||
deleted_unused = LyricsCache.objects.filter(
|
||||
last_accessed__lt=cutoff_date,
|
||||
access_count=0
|
||||
).delete()[0]
|
||||
|
||||
logger.info(f"Cleaned up {deleted_count} not_found and {deleted_unused} unused cache entries")
|
||||
|
||||
return {
|
||||
'deleted_not_found': deleted_count,
|
||||
'deleted_unused': deleted_unused,
|
||||
}
|
||||
|
||||
|
||||
@shared_task(name="audio.refetch_failed_lyrics")
|
||||
def refetch_failed_lyrics(days_old: int = 7, limit: int = 20):
|
||||
"""
|
||||
Retry fetching lyrics for tracks that failed before
|
||||
|
||||
Args:
|
||||
days_old: Retry tracks that failed more than this many days ago
|
||||
limit: Maximum number of tracks to retry
|
||||
"""
|
||||
from audio.models_lyrics import Lyrics
|
||||
from audio.lyrics_service import LyricsService
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = timezone.now() - timedelta(days=days_old)
|
||||
|
||||
# Find tracks that failed but haven't been tried recently
|
||||
failed_lyrics = Lyrics.objects.filter(
|
||||
fetch_attempted=True,
|
||||
synced_lyrics='',
|
||||
plain_lyrics='',
|
||||
is_instrumental=False,
|
||||
fetched_date__lt=cutoff_date,
|
||||
fetch_attempts__lt=5 # Don't retry if attempted 5+ times
|
||||
)[:limit]
|
||||
|
||||
service = LyricsService()
|
||||
results = {
|
||||
'retried': 0,
|
||||
'success': 0,
|
||||
'failed': 0,
|
||||
}
|
||||
|
||||
for lyrics in failed_lyrics:
|
||||
try:
|
||||
updated = service.fetch_and_store_lyrics(lyrics.audio, force=True)
|
||||
results['retried'] += 1
|
||||
|
||||
if updated.has_lyrics:
|
||||
results['success'] += 1
|
||||
else:
|
||||
results['failed'] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrying lyrics for {lyrics.audio.title}: {e}")
|
||||
results['failed'] += 1
|
||||
|
||||
import time
|
||||
time.sleep(2) # Be nice to the API
|
||||
|
||||
logger.info(f"Refetch completed: {results}")
|
||||
return results
|
||||
46
backend/audio/urls.py
Normal file
46
backend/audio/urls.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""Audio URL patterns"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from audio.views import (
|
||||
AudioListView,
|
||||
AudioDetailView,
|
||||
AudioPlayerView,
|
||||
AudioProgressView,
|
||||
AudioDownloadView,
|
||||
)
|
||||
from audio.views_lyrics import LyricsViewSet, LyricsCacheViewSet
|
||||
|
||||
# Create router for ViewSets
|
||||
router = DefaultRouter()
|
||||
router.register(r'lyrics', LyricsViewSet, basename='lyrics')
|
||||
router.register(r'lyrics-cache', LyricsCacheViewSet, basename='lyrics-cache')
|
||||
|
||||
urlpatterns = [
|
||||
# YouTube audio endpoints (specific paths first)
|
||||
path('list/', AudioListView.as_view(), name='audio-list'),
|
||||
path('<str:youtube_id>/player/', AudioPlayerView.as_view(), name='audio-player'),
|
||||
path('<str:youtube_id>/progress/', AudioProgressView.as_view(), name='audio-progress'),
|
||||
path('<str:youtube_id>/download/', AudioDownloadView.as_view(), name='audio-download'),
|
||||
# Lyrics endpoints
|
||||
path('<str:youtube_id>/lyrics/', LyricsViewSet.as_view({
|
||||
'get': 'retrieve',
|
||||
'put': 'update_lyrics',
|
||||
'patch': 'update_lyrics',
|
||||
'delete': 'delete_lyrics',
|
||||
}), name='audio-lyrics'),
|
||||
path('<str:youtube_id>/lyrics/fetch/', LyricsViewSet.as_view({
|
||||
'post': 'fetch',
|
||||
}), name='audio-lyrics-fetch'),
|
||||
path('<str:youtube_id>/', AudioDetailView.as_view(), name='audio-detail'),
|
||||
|
||||
# Include sub-apps LAST (they have root patterns that catch everything)
|
||||
# Local audio endpoints
|
||||
path('', include('audio.urls_local')),
|
||||
# Quick Sync endpoints
|
||||
path('', include('audio.urls_quick_sync')),
|
||||
# Artwork and metadata endpoints
|
||||
path('api/', include('audio.urls_artwork')),
|
||||
# Include router URLs for batch operations
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
21
backend/audio/urls_artwork.py
Normal file
21
backend/audio/urls_artwork.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""URL configuration for artwork and metadata"""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from audio.views_artwork import (
|
||||
ArtworkViewSet,
|
||||
MusicMetadataViewSet,
|
||||
ArtistInfoViewSet,
|
||||
AudioArtworkViewSet,
|
||||
ChannelArtworkViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'artwork', ArtworkViewSet, basename='artwork')
|
||||
router.register(r'metadata', MusicMetadataViewSet, basename='metadata')
|
||||
router.register(r'artist-info', ArtistInfoViewSet, basename='artist-info')
|
||||
router.register(r'audio-artwork', AudioArtworkViewSet, basename='audio-artwork')
|
||||
router.register(r'channel-artwork', ChannelArtworkViewSet, basename='channel-artwork')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
13
backend/audio/urls_local.py
Normal file
13
backend/audio/urls_local.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""URL configuration for local audio files"""
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from audio.views_local import LocalAudioViewSet, LocalAudioPlaylistViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'local-audio', LocalAudioViewSet, basename='local-audio')
|
||||
router.register(r'local-playlists', LocalAudioPlaylistViewSet, basename='local-playlists')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
15
backend/audio/urls_quick_sync.py
Normal file
15
backend/audio/urls_quick_sync.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""URL configuration for Quick Sync"""
|
||||
from django.urls import path
|
||||
from audio.views_quick_sync import (
|
||||
QuickSyncStatusView,
|
||||
QuickSyncPreferencesView,
|
||||
QuickSyncTestView,
|
||||
QuickSyncQualityPresetsView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path('quick-sync/status/', QuickSyncStatusView.as_view(), name='quick-sync-status'),
|
||||
path('quick-sync/preferences/', QuickSyncPreferencesView.as_view(), name='quick-sync-preferences'),
|
||||
path('quick-sync/test/', QuickSyncTestView.as_view(), name='quick-sync-test'),
|
||||
path('quick-sync/presets/', QuickSyncQualityPresetsView.as_view(), name='quick-sync-presets'),
|
||||
]
|
||||
266
backend/audio/views.py
Normal file
266
backend/audio/views.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
"""Audio API views"""
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from audio.models import Audio, AudioProgress
|
||||
from audio.serializers import (
|
||||
AudioListSerializer,
|
||||
AudioSerializer,
|
||||
AudioProgressUpdateSerializer,
|
||||
PlayerSerializer,
|
||||
)
|
||||
from common.views import ApiBaseView, AdminWriteOnly
|
||||
|
||||
|
||||
class AudioListView(ApiBaseView):
|
||||
"""Audio list endpoint
|
||||
GET: returns list of audio files
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
"""Get audio list"""
|
||||
# Get query parameters
|
||||
channel_id = request.query_params.get('channel')
|
||||
playlist_id = request.query_params.get('playlist')
|
||||
status_filter = request.query_params.get('status')
|
||||
|
||||
# Base queryset - filter by user
|
||||
queryset = Audio.objects.filter(owner=request.user)
|
||||
|
||||
# Apply filters
|
||||
if channel_id:
|
||||
queryset = queryset.filter(channel_id=channel_id)
|
||||
if playlist_id:
|
||||
# TODO: Filter by playlist
|
||||
pass
|
||||
if status_filter:
|
||||
# TODO: Filter by play status
|
||||
pass
|
||||
|
||||
# Pagination
|
||||
page_size = 50
|
||||
page = int(request.query_params.get('page', 1))
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
|
||||
audio_list = queryset[start:end]
|
||||
serializer = AudioSerializer(audio_list, many=True)
|
||||
|
||||
return Response({
|
||||
'data': serializer.data,
|
||||
'paginate': True
|
||||
})
|
||||
|
||||
|
||||
class AudioDetailView(ApiBaseView):
|
||||
"""Audio detail endpoint
|
||||
GET: returns single audio file details
|
||||
POST: trigger actions (download)
|
||||
DELETE: delete audio file
|
||||
"""
|
||||
permission_classes = [AdminWriteOnly]
|
||||
|
||||
def get(self, request, youtube_id):
|
||||
"""Get audio details"""
|
||||
audio = get_object_or_404(Audio, youtube_id=youtube_id, owner=request.user)
|
||||
serializer = AudioSerializer(audio)
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request, youtube_id):
|
||||
"""Trigger actions on audio"""
|
||||
audio = get_object_or_404(Audio, youtube_id=youtube_id, owner=request.user)
|
||||
action = request.data.get('action')
|
||||
|
||||
if action == 'download':
|
||||
# Check if already downloaded
|
||||
if audio.file_path:
|
||||
return Response(
|
||||
{'detail': 'Audio already downloaded', 'status': 'already_downloaded'},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Add to download queue
|
||||
from download.models import DownloadQueue
|
||||
from task.tasks import download_audio_task
|
||||
|
||||
# Create download queue item
|
||||
queue_item, created = DownloadQueue.objects.get_or_create(
|
||||
owner=request.user,
|
||||
youtube_id=youtube_id,
|
||||
defaults={
|
||||
'url': f'https://www.youtube.com/watch?v={youtube_id}',
|
||||
'title': audio.title,
|
||||
'channel_name': audio.channel_name,
|
||||
'auto_start': True,
|
||||
}
|
||||
)
|
||||
|
||||
# Trigger download task
|
||||
if created or queue_item.status == 'failed':
|
||||
download_audio_task.delay(queue_item.id)
|
||||
return Response(
|
||||
{'detail': 'Download started', 'status': 'downloading'},
|
||||
status=status.HTTP_202_ACCEPTED
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{'detail': 'Download already in progress', 'status': queue_item.status},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(
|
||||
{'detail': 'Invalid action'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
def delete(self, request, youtube_id):
|
||||
"""Delete audio file"""
|
||||
audio = get_object_or_404(Audio, youtube_id=youtube_id, owner=request.user)
|
||||
audio.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class AudioPlayerView(ApiBaseView):
|
||||
"""Audio player endpoint
|
||||
GET: returns audio player data with stream URL
|
||||
"""
|
||||
|
||||
def get(self, request, youtube_id):
|
||||
"""Get player data"""
|
||||
audio = get_object_or_404(Audio, youtube_id=youtube_id, owner=request.user)
|
||||
|
||||
# Trigger lyrics fetch if not already fetched (async, non-blocking)
|
||||
try:
|
||||
if not hasattr(audio, 'lyrics') or not audio.lyrics.fetch_attempted:
|
||||
from audio.tasks_lyrics import fetch_lyrics_for_audio
|
||||
fetch_lyrics_for_audio.delay(youtube_id)
|
||||
except Exception:
|
||||
pass # Don't block playback if lyrics fetch fails
|
||||
|
||||
# Get user progress
|
||||
progress = None
|
||||
try:
|
||||
progress = AudioProgress.objects.get(user=request.user, audio=audio)
|
||||
except AudioProgress.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Build stream URL with proper encoding for special characters
|
||||
from urllib.parse import quote
|
||||
# Encode the file path, preserving forward slashes
|
||||
encoded_path = '/'.join(quote(part, safe='') for part in audio.file_path.split('/'))
|
||||
stream_url = f"/media/{encoded_path}"
|
||||
|
||||
data = {
|
||||
'audio': AudioSerializer(audio).data,
|
||||
'stream_url': stream_url
|
||||
}
|
||||
if progress:
|
||||
data['progress'] = {
|
||||
'position': progress.position,
|
||||
'completed': progress.completed
|
||||
}
|
||||
|
||||
return Response(data)
|
||||
|
||||
|
||||
class AudioProgressView(ApiBaseView):
|
||||
"""Audio progress endpoint
|
||||
POST: update playback progress
|
||||
"""
|
||||
|
||||
def post(self, request, youtube_id):
|
||||
"""Update audio progress"""
|
||||
audio = get_object_or_404(Audio, youtube_id=youtube_id, owner=request.user)
|
||||
|
||||
serializer = AudioProgressUpdateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
progress, created = AudioProgress.objects.get_or_create(
|
||||
user=request.user,
|
||||
audio=audio,
|
||||
defaults={
|
||||
'position': serializer.validated_data['position'],
|
||||
'completed': serializer.validated_data.get('completed', False)
|
||||
}
|
||||
)
|
||||
|
||||
if not created:
|
||||
progress.position = serializer.validated_data['position']
|
||||
progress.completed = serializer.validated_data.get('completed', False)
|
||||
progress.save()
|
||||
|
||||
# Update audio play count
|
||||
if created or serializer.validated_data.get('completed'):
|
||||
audio.play_count += 1
|
||||
audio.save()
|
||||
|
||||
return Response({
|
||||
'position': progress.position,
|
||||
'completed': progress.completed
|
||||
})
|
||||
|
||||
|
||||
class AudioDownloadView(ApiBaseView):
|
||||
"""Audio file download endpoint
|
||||
GET: download audio file to user's device
|
||||
"""
|
||||
|
||||
def get(self, request, youtube_id):
|
||||
"""Download audio file with security checks"""
|
||||
from django.http import FileResponse, Http404
|
||||
import os
|
||||
from django.conf import settings
|
||||
from pathlib import Path
|
||||
|
||||
# Security: Verify ownership
|
||||
audio = get_object_or_404(Audio, youtube_id=youtube_id, owner=request.user)
|
||||
|
||||
if not audio.file_path:
|
||||
raise Http404("Audio file not available")
|
||||
|
||||
# Security: Prevent path traversal attacks
|
||||
file_path = audio.file_path
|
||||
if '..' in file_path or file_path.startswith('/') or '\\' in file_path:
|
||||
raise Http404("Invalid file path")
|
||||
|
||||
# Build and resolve full path
|
||||
full_path = Path(settings.MEDIA_ROOT) / file_path
|
||||
|
||||
# Security: Verify the resolved path is within MEDIA_ROOT
|
||||
try:
|
||||
full_path = full_path.resolve()
|
||||
media_root = Path(settings.MEDIA_ROOT).resolve()
|
||||
full_path.relative_to(media_root)
|
||||
except (ValueError, OSError):
|
||||
raise Http404("Access denied")
|
||||
|
||||
# Verify file exists and is a file (not directory)
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise Http404("Audio file not found on disk")
|
||||
|
||||
# Get file extension and determine content type
|
||||
_, ext = os.path.splitext(str(full_path))
|
||||
content_type = 'audio/mpeg' # Default
|
||||
if ext.lower() in ['.m4a', '.mp4']:
|
||||
content_type = 'audio/mp4'
|
||||
elif ext.lower() == '.opus':
|
||||
content_type = 'audio/opus'
|
||||
elif ext.lower() == '.webm':
|
||||
content_type = 'audio/webm'
|
||||
|
||||
# Create safe filename for download
|
||||
safe_title = "".join(c for c in audio.title if c.isalnum() or c in (' ', '-', '_')).strip()
|
||||
if not safe_title:
|
||||
safe_title = f"audio_{youtube_id}"
|
||||
filename = f"{safe_title}{ext}"
|
||||
|
||||
# Serve file with proper headers
|
||||
response = FileResponse(
|
||||
open(full_path, 'rb'),
|
||||
content_type=content_type,
|
||||
as_attachment=True,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
return response
|
||||
267
backend/audio/views_artwork.py
Normal file
267
backend/audio/views_artwork.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
"""Views for artwork and metadata management"""
|
||||
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 django.db.models import Prefetch
|
||||
|
||||
from audio.models import Audio, Channel
|
||||
from audio.models_artwork import Artwork, MusicMetadata, ArtistInfo
|
||||
from audio.serializers_artwork import (
|
||||
ArtworkSerializer,
|
||||
MusicMetadataSerializer,
|
||||
ArtistInfoSerializer,
|
||||
AudioWithArtworkSerializer,
|
||||
ChannelWithArtworkSerializer,
|
||||
)
|
||||
from audio.tasks_artwork import (
|
||||
fetch_metadata_for_audio,
|
||||
fetch_artwork_for_audio,
|
||||
fetch_artist_info,
|
||||
fetch_artist_artwork,
|
||||
download_artwork,
|
||||
embed_artwork_in_audio,
|
||||
update_id3_tags_from_metadata,
|
||||
)
|
||||
|
||||
|
||||
class ArtworkViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing artwork"""
|
||||
|
||||
queryset = Artwork.objects.all()
|
||||
serializer_class = ArtworkSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Filter by audio
|
||||
audio_id = self.request.query_params.get('audio_id')
|
||||
if audio_id:
|
||||
queryset = queryset.filter(audio_id=audio_id)
|
||||
|
||||
# Filter by channel
|
||||
channel_id = self.request.query_params.get('channel_id')
|
||||
if channel_id:
|
||||
queryset = queryset.filter(channel_id=channel_id)
|
||||
|
||||
# Filter by type
|
||||
artwork_type = self.request.query_params.get('type')
|
||||
if artwork_type:
|
||||
queryset = queryset.filter(artwork_type=artwork_type)
|
||||
|
||||
# Filter by source
|
||||
source = self.request.query_params.get('source')
|
||||
if source:
|
||||
queryset = queryset.filter(source=source)
|
||||
|
||||
return queryset.order_by('-priority', '-created_at')
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def download(self, request, pk=None):
|
||||
"""Download artwork from URL"""
|
||||
artwork = self.get_object()
|
||||
download_artwork.delay(artwork.id)
|
||||
return Response({
|
||||
'message': 'Artwork download queued',
|
||||
'artwork_id': artwork.id
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def set_primary(self, request, pk=None):
|
||||
"""Set artwork as primary"""
|
||||
artwork = self.get_object()
|
||||
|
||||
# Unset other primary artworks
|
||||
if artwork.audio:
|
||||
Artwork.objects.filter(
|
||||
audio=artwork.audio,
|
||||
artwork_type=artwork.artwork_type
|
||||
).update(is_primary=False)
|
||||
elif artwork.channel:
|
||||
Artwork.objects.filter(
|
||||
channel=artwork.channel,
|
||||
artwork_type=artwork.artwork_type
|
||||
).update(is_primary=False)
|
||||
|
||||
artwork.is_primary = True
|
||||
artwork.save()
|
||||
|
||||
return Response({
|
||||
'message': 'Artwork set as primary',
|
||||
'artwork_id': artwork.id
|
||||
})
|
||||
|
||||
|
||||
class MusicMetadataViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing music metadata"""
|
||||
|
||||
queryset = MusicMetadata.objects.all()
|
||||
serializer_class = MusicMetadataSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Filter by audio
|
||||
audio_id = self.request.query_params.get('audio_id')
|
||||
if audio_id:
|
||||
queryset = queryset.filter(audio_id=audio_id)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def fetch_from_lastfm(self, request, pk=None):
|
||||
"""Fetch metadata from Last.fm"""
|
||||
metadata = self.get_object()
|
||||
fetch_metadata_for_audio.delay(metadata.audio.id)
|
||||
return Response({
|
||||
'message': 'Metadata fetch queued',
|
||||
'audio_id': metadata.audio.id
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def update_id3_tags(self, request, pk=None):
|
||||
"""Update ID3 tags in audio file"""
|
||||
metadata = self.get_object()
|
||||
update_id3_tags_from_metadata.delay(metadata.audio.id)
|
||||
return Response({
|
||||
'message': 'ID3 tags update queued',
|
||||
'audio_id': metadata.audio.id
|
||||
})
|
||||
|
||||
|
||||
class ArtistInfoViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing artist information"""
|
||||
|
||||
queryset = ArtistInfo.objects.all()
|
||||
serializer_class = ArtistInfoSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Filter by channel
|
||||
channel_id = self.request.query_params.get('channel_id')
|
||||
if channel_id:
|
||||
queryset = queryset.filter(channel_id=channel_id)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def fetch_from_lastfm(self, request, pk=None):
|
||||
"""Fetch artist info from Last.fm"""
|
||||
artist_info = self.get_object()
|
||||
fetch_artist_info.delay(artist_info.channel.id)
|
||||
return Response({
|
||||
'message': 'Artist info fetch queued',
|
||||
'channel_id': artist_info.channel.id
|
||||
})
|
||||
|
||||
|
||||
class AudioArtworkViewSet(viewsets.ViewSet):
|
||||
"""ViewSet for audio artwork operations"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def retrieve(self, request, pk=None):
|
||||
"""Get audio with all artwork"""
|
||||
audio = get_object_or_404(Audio, pk=pk)
|
||||
|
||||
artwork = Artwork.objects.filter(audio=audio).order_by('-priority')
|
||||
try:
|
||||
metadata = MusicMetadata.objects.get(audio=audio)
|
||||
except MusicMetadata.DoesNotExist:
|
||||
metadata = None
|
||||
|
||||
data = {
|
||||
'audio_id': audio.id,
|
||||
'audio_title': audio.audio_title,
|
||||
'artist': audio.channel.channel_name if audio.channel else 'Unknown Artist',
|
||||
'artwork': ArtworkSerializer(artwork, many=True).data,
|
||||
'metadata': MusicMetadataSerializer(metadata).data if metadata else None,
|
||||
}
|
||||
|
||||
return Response(data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def fetch_artwork(self, request, pk=None):
|
||||
"""Fetch artwork for audio"""
|
||||
audio = get_object_or_404(Audio, pk=pk)
|
||||
fetch_artwork_for_audio.delay(audio.id)
|
||||
return Response({
|
||||
'message': 'Artwork fetch queued',
|
||||
'audio_id': audio.id
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def fetch_metadata(self, request, pk=None):
|
||||
"""Fetch metadata for audio"""
|
||||
audio = get_object_or_404(Audio, pk=pk)
|
||||
fetch_metadata_for_audio.delay(audio.id)
|
||||
return Response({
|
||||
'message': 'Metadata fetch queued',
|
||||
'audio_id': audio.id
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def embed_artwork(self, request, pk=None):
|
||||
"""Embed artwork in audio file"""
|
||||
audio = get_object_or_404(Audio, pk=pk)
|
||||
artwork_id = request.data.get('artwork_id')
|
||||
|
||||
if artwork_id:
|
||||
embed_artwork_in_audio.delay(audio.id, artwork_id)
|
||||
else:
|
||||
embed_artwork_in_audio.delay(audio.id)
|
||||
|
||||
return Response({
|
||||
'message': 'Artwork embed queued',
|
||||
'audio_id': audio.id
|
||||
})
|
||||
|
||||
|
||||
class ChannelArtworkViewSet(viewsets.ViewSet):
|
||||
"""ViewSet for channel artwork operations"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def retrieve(self, request, pk=None):
|
||||
"""Get channel with all artwork"""
|
||||
channel = get_object_or_404(Channel, pk=pk)
|
||||
|
||||
artwork = Artwork.objects.filter(channel=channel).order_by('-priority')
|
||||
try:
|
||||
artist_info = ArtistInfo.objects.get(channel=channel)
|
||||
except ArtistInfo.DoesNotExist:
|
||||
artist_info = None
|
||||
|
||||
data = {
|
||||
'channel_id': channel.id,
|
||||
'channel_name': channel.channel_name,
|
||||
'artwork': ArtworkSerializer(artwork, many=True).data,
|
||||
'artist_info': ArtistInfoSerializer(artist_info).data if artist_info else None,
|
||||
}
|
||||
|
||||
return Response(data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def fetch_artwork(self, request, pk=None):
|
||||
"""Fetch artwork for channel"""
|
||||
channel = get_object_or_404(Channel, pk=pk)
|
||||
fetch_artist_artwork.delay(channel.id)
|
||||
return Response({
|
||||
'message': 'Artist artwork fetch queued',
|
||||
'channel_id': channel.id
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def fetch_info(self, request, pk=None):
|
||||
"""Fetch artist info for channel"""
|
||||
channel = get_object_or_404(Channel, pk=pk)
|
||||
fetch_artist_info.delay(channel.id)
|
||||
return Response({
|
||||
'message': 'Artist info fetch queued',
|
||||
'channel_id': channel.id
|
||||
})
|
||||
276
backend/audio/views_local.py
Normal file
276
backend/audio/views_local.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
"""Views for local audio files"""
|
||||
|
||||
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 rest_framework.parsers import MultiPartParser, FormParser
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
|
||||
from audio.models_local import LocalAudio, LocalAudioPlaylist, LocalAudioPlaylistItem
|
||||
from audio.serializers_local import (
|
||||
LocalAudioSerializer,
|
||||
LocalAudioUploadSerializer,
|
||||
LocalAudioPlaylistSerializer,
|
||||
LocalAudioPlaylistItemSerializer,
|
||||
)
|
||||
from common.permissions import IsOwnerOrAdmin
|
||||
|
||||
|
||||
class LocalAudioViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing local audio files"""
|
||||
permission_classes = [IsAuthenticated, IsOwnerOrAdmin]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return LocalAudioUploadSerializer
|
||||
return LocalAudioSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter by user"""
|
||||
queryset = LocalAudio.objects.all()
|
||||
|
||||
# Regular users see only their files
|
||||
if not (self.request.user.is_admin or self.request.user.is_superuser):
|
||||
queryset = queryset.filter(owner=self.request.user)
|
||||
|
||||
# Search filter
|
||||
search = self.request.query_params.get('search')
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search) |
|
||||
Q(artist__icontains=search) |
|
||||
Q(album__icontains=search) |
|
||||
Q(genre__icontains=search)
|
||||
)
|
||||
|
||||
# Filter by artist
|
||||
artist = self.request.query_params.get('artist')
|
||||
if artist:
|
||||
queryset = queryset.filter(artist__icontains=artist)
|
||||
|
||||
# Filter by album
|
||||
album = self.request.query_params.get('album')
|
||||
if album:
|
||||
queryset = queryset.filter(album__icontains=album)
|
||||
|
||||
# Filter by genre
|
||||
genre = self.request.query_params.get('genre')
|
||||
if genre:
|
||||
queryset = queryset.filter(genre__icontains=genre)
|
||||
|
||||
# Filter by favorites
|
||||
favorites = self.request.query_params.get('favorites')
|
||||
if favorites == 'true':
|
||||
queryset = queryset.filter(is_favorite=True)
|
||||
|
||||
# Filter by tags
|
||||
tags = self.request.query_params.get('tags')
|
||||
if tags:
|
||||
tag_list = tags.split(',')
|
||||
for tag in tag_list:
|
||||
queryset = queryset.filter(tags__contains=[tag.strip()])
|
||||
|
||||
return queryset.order_by('-uploaded_date')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set owner on creation"""
|
||||
user = self.request.user
|
||||
|
||||
# Check storage quota
|
||||
if not (user.is_admin or user.is_superuser):
|
||||
if user.storage_used_gb >= user.storage_quota_gb:
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied(f"Storage quota exceeded ({user.storage_used_gb:.1f} / {user.storage_quota_gb} GB)")
|
||||
|
||||
local_audio = serializer.save(owner=user)
|
||||
|
||||
# Update user storage
|
||||
file_size_gb = local_audio.file_size / (1024 ** 3)
|
||||
user.storage_used_gb += file_size_gb
|
||||
user.save()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Update storage on deletion"""
|
||||
user = instance.owner
|
||||
file_size_gb = instance.file_size / (1024 ** 3)
|
||||
|
||||
# Delete the instance
|
||||
instance.delete()
|
||||
|
||||
# Update user storage
|
||||
user.storage_used_gb = max(0, user.storage_used_gb - file_size_gb)
|
||||
user.save()
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def play(self, request, pk=None):
|
||||
"""Increment play count"""
|
||||
audio = self.get_object()
|
||||
audio.play_count += 1
|
||||
audio.last_played = timezone.now()
|
||||
audio.save()
|
||||
|
||||
return Response({'message': 'Play count updated'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def toggle_favorite(self, request, pk=None):
|
||||
"""Toggle favorite status"""
|
||||
audio = self.get_object()
|
||||
audio.is_favorite = not audio.is_favorite
|
||||
audio.save()
|
||||
|
||||
return Response({
|
||||
'message': 'Favorite status updated',
|
||||
'is_favorite': audio.is_favorite
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def artists(self, request):
|
||||
"""Get list of artists"""
|
||||
queryset = self.get_queryset()
|
||||
artists = queryset.values_list('artist', flat=True).distinct().order_by('artist')
|
||||
artists = [a for a in artists if a] # Remove empty strings
|
||||
|
||||
return Response(artists)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def albums(self, request):
|
||||
"""Get list of albums"""
|
||||
queryset = self.get_queryset()
|
||||
albums = queryset.values('album', 'artist').distinct().order_by('album')
|
||||
albums = [a for a in albums if a['album']] # Remove empty albums
|
||||
|
||||
return Response(albums)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def genres(self, request):
|
||||
"""Get list of genres"""
|
||||
queryset = self.get_queryset()
|
||||
genres = queryset.values_list('genre', flat=True).distinct().order_by('genre')
|
||||
genres = [g for g in genres if g] # Remove empty strings
|
||||
|
||||
return Response(genres)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def stats(self, request):
|
||||
"""Get statistics"""
|
||||
queryset = self.get_queryset()
|
||||
|
||||
stats = {
|
||||
'total_files': queryset.count(),
|
||||
'total_artists': queryset.values('artist').distinct().count(),
|
||||
'total_albums': queryset.values('album').distinct().count(),
|
||||
'total_duration': sum(a.duration or 0 for a in queryset),
|
||||
'total_size_mb': sum(a.file_size for a in queryset) / (1024 * 1024),
|
||||
'favorites': queryset.filter(is_favorite=True).count(),
|
||||
}
|
||||
|
||||
return Response(stats)
|
||||
|
||||
|
||||
class LocalAudioPlaylistViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing local audio playlists"""
|
||||
permission_classes = [IsAuthenticated, IsOwnerOrAdmin]
|
||||
serializer_class = LocalAudioPlaylistSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter by user"""
|
||||
queryset = LocalAudioPlaylist.objects.prefetch_related('items__audio')
|
||||
|
||||
# Regular users see only their playlists
|
||||
if not (self.request.user.is_admin or self.request.user.is_superuser):
|
||||
queryset = queryset.filter(owner=self.request.user)
|
||||
|
||||
return queryset.order_by('-created_date')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set owner on creation"""
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def add_item(self, request, pk=None):
|
||||
"""Add audio to playlist"""
|
||||
playlist = self.get_object()
|
||||
audio_id = request.data.get('audio_id')
|
||||
|
||||
if not audio_id:
|
||||
return Response(
|
||||
{'error': 'audio_id is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
audio = LocalAudio.objects.get(id=audio_id, owner=request.user)
|
||||
except LocalAudio.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Audio not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Get next position
|
||||
last_item = playlist.items.order_by('-position').first()
|
||||
position = (last_item.position + 1) if last_item else 0
|
||||
|
||||
# Create item
|
||||
item, created = LocalAudioPlaylistItem.objects.get_or_create(
|
||||
playlist=playlist,
|
||||
audio=audio,
|
||||
defaults={'position': position}
|
||||
)
|
||||
|
||||
if not created:
|
||||
return Response(
|
||||
{'error': 'Audio already in playlist'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
serializer = LocalAudioPlaylistItemSerializer(item, context={'request': request})
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def remove_item(self, request, pk=None):
|
||||
"""Remove audio from playlist"""
|
||||
playlist = self.get_object()
|
||||
audio_id = request.data.get('audio_id')
|
||||
|
||||
if not audio_id:
|
||||
return Response(
|
||||
{'error': 'audio_id is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
item = LocalAudioPlaylistItem.objects.get(
|
||||
playlist=playlist,
|
||||
audio_id=audio_id
|
||||
)
|
||||
item.delete()
|
||||
return Response({'message': 'Item removed from playlist'})
|
||||
except LocalAudioPlaylistItem.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Item not found in playlist'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reorder(self, request, pk=None):
|
||||
"""Reorder playlist items"""
|
||||
playlist = self.get_object()
|
||||
item_order = request.data.get('item_order', [])
|
||||
|
||||
if not item_order:
|
||||
return Response(
|
||||
{'error': 'item_order is required (array of item IDs)'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Update positions
|
||||
for position, item_id in enumerate(item_order):
|
||||
LocalAudioPlaylistItem.objects.filter(
|
||||
playlist=playlist,
|
||||
id=item_id
|
||||
).update(position=position)
|
||||
|
||||
return Response({'message': 'Playlist reordered'})
|
||||
201
backend/audio/views_lyrics.py
Normal file
201
backend/audio/views_lyrics.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""Views for lyrics management"""
|
||||
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 audio.models import Audio
|
||||
from audio.models_lyrics import Lyrics, LyricsCache
|
||||
from audio.serializers_lyrics import (
|
||||
LyricsSerializer,
|
||||
LyricsUpdateSerializer,
|
||||
LyricsFetchSerializer,
|
||||
LyricsCacheSerializer,
|
||||
)
|
||||
from audio.lyrics_service import LyricsService
|
||||
from audio.tasks_lyrics import fetch_lyrics_for_audio, fetch_lyrics_batch
|
||||
|
||||
|
||||
class LyricsViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing lyrics"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = LyricsSerializer
|
||||
lookup_field = 'audio__youtube_id'
|
||||
lookup_url_kwarg = 'youtube_id'
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get lyrics queryset"""
|
||||
return Lyrics.objects.select_related('audio').all()
|
||||
|
||||
def retrieve(self, request, youtube_id=None):
|
||||
"""Get lyrics for a specific audio track"""
|
||||
audio = get_object_or_404(Audio, youtube_id=youtube_id)
|
||||
|
||||
# Get or create lyrics entry
|
||||
lyrics, created = Lyrics.objects.get_or_create(audio=audio)
|
||||
|
||||
# If no lyrics and not attempted, trigger fetch
|
||||
if not lyrics.fetch_attempted:
|
||||
# Trigger async fetch
|
||||
fetch_lyrics_for_audio.delay(youtube_id)
|
||||
|
||||
serializer = self.get_serializer(lyrics)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def fetch(self, request, youtube_id=None):
|
||||
"""Manually fetch lyrics for an audio track"""
|
||||
audio = get_object_or_404(Audio, youtube_id=youtube_id)
|
||||
|
||||
# Validate request data
|
||||
fetch_serializer = LyricsFetchSerializer(data=request.data)
|
||||
fetch_serializer.is_valid(raise_exception=True)
|
||||
|
||||
force = fetch_serializer.validated_data.get('force', False)
|
||||
|
||||
# Fetch lyrics synchronously
|
||||
service = LyricsService()
|
||||
lyrics = service.fetch_and_store_lyrics(audio, force=force)
|
||||
|
||||
serializer = self.get_serializer(lyrics)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['put', 'patch'])
|
||||
def update_lyrics(self, request, youtube_id=None):
|
||||
"""Manually update lyrics for an audio track"""
|
||||
audio = get_object_or_404(Audio, youtube_id=youtube_id)
|
||||
lyrics, created = Lyrics.objects.get_or_create(audio=audio)
|
||||
|
||||
# Validate and update
|
||||
update_serializer = LyricsUpdateSerializer(data=request.data)
|
||||
update_serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Update fields
|
||||
if 'synced_lyrics' in update_serializer.validated_data:
|
||||
lyrics.synced_lyrics = update_serializer.validated_data['synced_lyrics']
|
||||
if 'plain_lyrics' in update_serializer.validated_data:
|
||||
lyrics.plain_lyrics = update_serializer.validated_data['plain_lyrics']
|
||||
if 'is_instrumental' in update_serializer.validated_data:
|
||||
lyrics.is_instrumental = update_serializer.validated_data['is_instrumental']
|
||||
if 'language' in update_serializer.validated_data:
|
||||
lyrics.language = update_serializer.validated_data['language']
|
||||
|
||||
lyrics.source = 'manual'
|
||||
lyrics.fetch_attempted = True
|
||||
lyrics.save()
|
||||
|
||||
serializer = self.get_serializer(lyrics)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['delete'])
|
||||
def delete_lyrics(self, request, youtube_id=None):
|
||||
"""Delete lyrics for an audio track"""
|
||||
audio = get_object_or_404(Audio, youtube_id=youtube_id)
|
||||
|
||||
try:
|
||||
lyrics = Lyrics.objects.get(audio=audio)
|
||||
lyrics.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except Lyrics.DoesNotExist:
|
||||
return Response(
|
||||
{'message': 'No lyrics found for this track'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def fetch_batch(self, request):
|
||||
"""Fetch lyrics for multiple audio tracks"""
|
||||
youtube_ids = request.data.get('youtube_ids', [])
|
||||
|
||||
if not youtube_ids:
|
||||
return Response(
|
||||
{'error': 'youtube_ids is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Trigger async batch fetch
|
||||
fetch_lyrics_batch.delay(youtube_ids)
|
||||
|
||||
return Response({
|
||||
'message': f'Fetching lyrics for {len(youtube_ids)} tracks',
|
||||
'youtube_ids': youtube_ids,
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def fetch_all_missing(self, request):
|
||||
"""Fetch lyrics for all audio without lyrics"""
|
||||
from audio.tasks_lyrics import auto_fetch_lyrics
|
||||
|
||||
limit = request.data.get('limit', 50)
|
||||
|
||||
# Trigger async task
|
||||
result = auto_fetch_lyrics.delay(limit=limit)
|
||||
|
||||
return Response({
|
||||
'message': f'Fetching lyrics for up to {limit} tracks without lyrics',
|
||||
'task_id': result.id,
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def stats(self, request):
|
||||
"""Get lyrics statistics"""
|
||||
total_audio = Audio.objects.filter(downloaded=True).count()
|
||||
total_lyrics = Lyrics.objects.filter(fetch_attempted=True).count()
|
||||
with_synced = Lyrics.objects.exclude(synced_lyrics='').count()
|
||||
with_plain = Lyrics.objects.exclude(plain_lyrics='').count()
|
||||
instrumental = Lyrics.objects.filter(is_instrumental=True).count()
|
||||
failed = Lyrics.objects.filter(
|
||||
fetch_attempted=True,
|
||||
synced_lyrics='',
|
||||
plain_lyrics='',
|
||||
is_instrumental=False
|
||||
).count()
|
||||
|
||||
return Response({
|
||||
'total_audio': total_audio,
|
||||
'total_lyrics_attempted': total_lyrics,
|
||||
'with_synced_lyrics': with_synced,
|
||||
'with_plain_lyrics': with_plain,
|
||||
'instrumental': instrumental,
|
||||
'failed': failed,
|
||||
'coverage_percentage': round((with_synced + with_plain + instrumental) / total_audio * 100, 1) if total_audio > 0 else 0,
|
||||
})
|
||||
|
||||
|
||||
class LyricsCacheViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for viewing lyrics cache"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = LyricsCacheSerializer
|
||||
queryset = LyricsCache.objects.all()
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def cleanup(self, request):
|
||||
"""Clean up old cache entries"""
|
||||
from audio.tasks_lyrics import cleanup_lyrics_cache
|
||||
|
||||
days_old = request.data.get('days_old', 30)
|
||||
result = cleanup_lyrics_cache.delay(days_old=days_old)
|
||||
|
||||
return Response({
|
||||
'message': f'Cleaning up cache entries older than {days_old} days',
|
||||
'task_id': result.id,
|
||||
})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def stats(self, request):
|
||||
"""Get cache statistics"""
|
||||
total = LyricsCache.objects.count()
|
||||
not_found = LyricsCache.objects.filter(not_found=True).count()
|
||||
with_synced = LyricsCache.objects.exclude(synced_lyrics='').count()
|
||||
with_plain = LyricsCache.objects.exclude(plain_lyrics='').count()
|
||||
|
||||
return Response({
|
||||
'total_entries': total,
|
||||
'not_found_entries': not_found,
|
||||
'with_synced_lyrics': with_synced,
|
||||
'with_plain_lyrics': with_plain,
|
||||
'hit_rate': round((with_synced + with_plain) / total * 100, 1) if total > 0 else 0,
|
||||
})
|
||||
103
backend/audio/views_quick_sync.py
Normal file
103
backend/audio/views_quick_sync.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Views for Quick Sync adaptive streaming"""
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework import status
|
||||
from django.core.cache import cache
|
||||
|
||||
from audio.quick_sync_service import QuickSyncService
|
||||
|
||||
|
||||
class QuickSyncStatusView(APIView):
|
||||
"""Get Quick Sync status and recommendations"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
"""Get current quick sync status"""
|
||||
service = QuickSyncService()
|
||||
user_prefs = service.get_user_preferences(request.user.id)
|
||||
sync_status = service.get_quick_sync_status(user_prefs)
|
||||
|
||||
return Response({
|
||||
'status': sync_status,
|
||||
'preferences': user_prefs,
|
||||
})
|
||||
|
||||
|
||||
class QuickSyncPreferencesView(APIView):
|
||||
"""Manage Quick Sync user preferences"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
"""Get user's quick sync preferences"""
|
||||
service = QuickSyncService()
|
||||
prefs = service.get_user_preferences(request.user.id)
|
||||
|
||||
return Response(prefs)
|
||||
|
||||
def post(self, request):
|
||||
"""Update user's quick sync preferences"""
|
||||
service = QuickSyncService()
|
||||
|
||||
preferences = {
|
||||
'mode': request.data.get('mode', 'auto'),
|
||||
'prefer_quality': request.data.get('prefer_quality', True),
|
||||
'adapt_to_system': request.data.get('adapt_to_system', True),
|
||||
'auto_download_quality': request.data.get('auto_download_quality', False),
|
||||
}
|
||||
|
||||
success = service.update_user_preferences(request.user.id, preferences)
|
||||
|
||||
if success:
|
||||
# Get updated status
|
||||
sync_status = service.get_quick_sync_status(preferences)
|
||||
return Response({
|
||||
'message': 'Quick Sync preferences updated',
|
||||
'preferences': preferences,
|
||||
'status': sync_status,
|
||||
})
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Failed to update preferences'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
class QuickSyncTestView(APIView):
|
||||
"""Test network speed for Quick Sync"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
"""Run network speed test"""
|
||||
service = QuickSyncService()
|
||||
|
||||
# Clear cache to force new test
|
||||
cache.delete('quick_sync_network_speed')
|
||||
|
||||
speed = service.measure_network_speed()
|
||||
system_resources = service.get_system_resources()
|
||||
|
||||
return Response({
|
||||
'network_speed_mbps': speed,
|
||||
'system_resources': system_resources,
|
||||
'recommended_quality': service.get_recommended_quality()[0],
|
||||
'timestamp': system_resources['timestamp'],
|
||||
})
|
||||
|
||||
|
||||
class QuickSyncQualityPresetsView(APIView):
|
||||
"""Get available quality presets"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
"""Get all quality presets"""
|
||||
service = QuickSyncService()
|
||||
|
||||
return Response({
|
||||
'presets': service.QUALITY_PRESETS,
|
||||
'thresholds': service.SPEED_THRESHOLDS,
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue