Fix: Include backend/audio Django app in repository

This commit is contained in:
Iulian 2025-12-24 01:58:56 +00:00
parent d04e726373
commit 644cfab298
37 changed files with 6632 additions and 4 deletions

8
.gitignore vendored
View file

@ -22,10 +22,10 @@ node_modules/
.pnpm-debug.log*
# Docker
audio/
cache/
es/
redis/
/audio/
/cache/
/es/
/redis/
# Environment
.env

View 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

View 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

View 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/

View file

@ -0,0 +1 @@
# Audio app

25
backend/audio/admin.py Normal file
View 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

View 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'

View 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'

View 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

View 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

View 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

View 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

View 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

View file

View 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)

View file

100
backend/audio/models.py Normal file
View 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}"

View 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}"

View 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}"

View 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}"

View 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,
}

View 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()

View 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)

View 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

View 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',
]

View 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}")

View 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
View 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)),
]

View 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)),
]

View 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)),
]

View 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
View 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

View 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
})

View 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'})

View 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,
})

View 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,
})