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
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -22,10 +22,10 @@ node_modules/
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
audio/
|
/audio/
|
||||||
cache/
|
/cache/
|
||||||
es/
|
/es/
|
||||||
redis/
|
/redis/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
|
||||||
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