Add comprehensive backend services and API enhancements

- Complete Spotify integration with downloader and settings
- Advanced UX features and audio quality management
- Enhanced search capabilities and mobile offline support
- Music catalog browser and recap features
- Universal downloader and upload functionality
- Update tracking system with database models and migrations
- Comprehensive service layer architecture
- Enhanced lyrics API and streaming capabilities
- Extended application builder and startup configuration
- New logging infrastructure and services directory
This commit is contained in:
Tomas Dvorak
2026-03-17 17:56:20 +01:00
parent 65a1268dab
commit 4338dd1d9c
43 changed files with 19453 additions and 10 deletions
+9 -1
View File
@@ -21,6 +21,14 @@ from swingmusic.api import (
auth,
stream,
backup_and_restore,
spotify,
spotify_settings,
enhanced_search,
universal_downloader,
music_catalog,
update_tracking,
audio_quality,
upload,
)
from swingmusic.api.plugins import lyrics as lyrics_plugin
@@ -28,7 +36,7 @@ from swingmusic.api.plugins import mixes as mixes_plugin
__all__ = [
"album", "artist", "collections", "colors", "favorites", "folder", "imgserver", "playlist", "search", "settings",
"lyrics", "plugins", "scrobble", "home", "getall", "auth", "stream", "backup_and_restore",
"lyrics", "plugins", "scrobble", "home", "getall", "auth", "stream", "backup_and_restore", "spotify", "spotify_settings", "enhanced_search", "universal_downloader", "music_catalog", "update_tracking", "audio_quality", "upload",
"lyrics_plugin",
"mixes_plugin"
+624
View File
@@ -0,0 +1,624 @@
"""
Advanced UX API Endpoints
This module provides REST API endpoints for enhanced user experience features,
including intelligent search suggestions, recommendations, and personalization.
"""
import logging
from datetime import datetime
from typing import Dict, List, Optional, Any
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from swingmusic.db import db
from swingmusic.services.advanced_ux_service import advanced_ux_service, SuggestionType, SearchContext
from swingmusic.utils.request import APIError, success_response, error_response
from swingmusic.utils.validators import validate_search_query, validate_context
logger = logging.getLogger(__name__)
advanced_ux_bp = Blueprint('advanced_ux', __name__, url_prefix='/api/ux')
def get_current_user_id() -> int:
"""Get current user ID from Flask-Login"""
return current_user.id if current_user.is_authenticated else None
@advanced_ux_bp.route('/search/suggestions', methods=['GET'])
@login_required
async def get_search_suggestions():
"""
Get intelligent search suggestions
Query Parameters:
- q: Search query
- context: Search context (general, discovery, download, playlist, offline, social)
- limit: Maximum suggestions to return (default: 10)
"""
try:
user_id = get_current_user_id()
query = request.args.get('q', '').strip()
context_str = request.args.get('context', 'general')
limit = min(request.args.get('limit', 10, type=int), 50)
# Validate inputs
validate_search_query(query)
context = validate_context(context_str)
# Get suggestions
suggestions = await advanced_ux_service.get_search_suggestions(user_id, query, context, limit)
# Format response
formatted_suggestions = []
for suggestion in suggestions:
formatted_suggestion = {
'id': suggestion.id,
'type': suggestion.type.value,
'title': suggestion.title,
'subtitle': suggestion.subtitle,
'image_url': suggestion.image_url,
'url': suggestion.url,
'metadata': suggestion.metadata,
'relevance_score': suggestion.relevance_score,
'context': suggestion.context.value
}
formatted_suggestions.append(formatted_suggestion)
return success_response({
'suggestions': formatted_suggestions,
'query': query,
'context': context.value,
'total_count': len(formatted_suggestions)
})
except Exception as e:
logger.error(f"Error getting search suggestions: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/discovery/recommendations', methods=['GET'])
@login_required
async def get_discovery_recommendations():
"""
Get personalized discovery recommendations
Query Parameters:
- type: Recommendation type (tracks, artists, albums, mixed)
- limit: Maximum recommendations to return (default: 20)
"""
try:
user_id = get_current_user_id()
recommendation_type = request.args.get('type', 'mixed')
limit = min(request.args.get('limit', 20, type=int), 100)
# Validate recommendation type
valid_types = ['tracks', 'artists', 'albums', 'mixed']
if recommendation_type not in valid_types:
return error_response(f"Invalid type. Must be one of: {valid_types}", 400)
# Get recommendations
recommendations = await advanced_ux_service.get_discovery_recommendations(user_id, recommendation_type, limit)
# Format response
formatted_recommendations = []
for recommendation in recommendations:
formatted_recommendation = {
'id': recommendation.id,
'type': recommendation.type.value,
'title': recommendation.title,
'subtitle': recommendation.subtitle,
'image_url': recommendation.image_url,
'url': recommendation.url,
'metadata': recommendation.metadata,
'relevance_score': recommendation.relevance_score
}
formatted_recommendations.append(formatted_recommendation)
return success_response({
'recommendations': formatted_recommendations,
'type': recommendation_type,
'total_count': len(formatted_recommendations)
})
except Exception as e:
logger.error(f"Error getting discovery recommendations: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/contextual/suggestions', methods=['GET'])
@login_required
async def get_contextual_suggestions():
"""
Get contextual suggestions based on current track
Query Parameters:
- track_id: Currently playing track ID
- context_type: Context type (similar, same_artist, same_genre, popular)
"""
try:
user_id = get_current_user_id()
track_id = request.args.get('track_id')
context_type = request.args.get('context_type', 'similar')
if not track_id:
return error_response("track_id is required", 400)
# Validate context type
valid_contexts = ['similar', 'same_artist', 'same_genre', 'popular']
if context_type not in valid_contexts:
return error_response(f"Invalid context_type. Must be one of: {valid_contexts}", 400)
# Get contextual suggestions
suggestions = await advanced_ux_service.get_contextual_suggestions(user_id, track_id, context_type)
# Format response
formatted_suggestions = []
for suggestion in suggestions:
formatted_suggestion = {
'id': suggestion.id,
'type': suggestion.type.value,
'title': suggestion.title,
'subtitle': suggestion.subtitle,
'image_url': suggestion.image_url,
'url': suggestion.url,
'metadata': suggestion.metadata,
'relevance_score': suggestion.relevance_score
}
formatted_suggestions.append(formatted_suggestion)
return success_response({
'suggestions': formatted_suggestions,
'track_id': track_id,
'context_type': context_type,
'total_count': len(formatted_suggestions)
})
except Exception as e:
logger.error(f"Error getting contextual suggestions: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/download/suggestions', methods=['GET'])
@login_required
async def get_download_suggestions():
"""
Get download-specific suggestions with universal downloader integration
Query Parameters:
- q: Search query (optional)
- limit: Maximum suggestions to return (default: 15)
"""
try:
user_id = get_current_user_id()
query = request.args.get('q', '').strip()
limit = min(request.args.get('limit', 15, type=int), 50)
# Get download suggestions
suggestions = await advanced_ux_service.get_download_suggestions(user_id, query, limit)
# Format response
formatted_suggestions = []
for suggestion in suggestions:
formatted_suggestion = {
'id': suggestion.id,
'type': suggestion.type.value,
'title': suggestion.title,
'subtitle': suggestion.subtitle,
'image_url': suggestion.image_url,
'url': suggestion.url,
'metadata': suggestion.metadata,
'relevance_score': suggestion.relevance_score
}
formatted_suggestions.append(formatted_suggestion)
return success_response({
'suggestions': formatted_suggestions,
'query': query,
'total_count': len(formatted_suggestions)
})
except Exception as e:
logger.error(f"Error getting download suggestions: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/search/filters', methods=['GET'])
@login_required
async def get_enhanced_search_filters():
"""
Get enhanced search filters with user personalization
"""
try:
user_id = get_current_user_id()
# Get enhanced filters
filters = await advanced_ux_service.get_enhanced_search_filters(user_id)
# Format response
formatted_filters = []
for filter_item in filters:
formatted_filter = {
'filter_id': filter_item.filter_id,
'name': filter_item.name,
'type': filter_item.type,
'options': filter_item.options,
'is_active': filter_item.is_active,
'is_multi_select': filter_item.is_multi_select
}
formatted_filters.append(formatted_filter)
return success_response({
'filters': formatted_filters,
'total_count': len(formatted_filters)
})
except Exception as e:
logger.error(f"Error getting enhanced search filters: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/behavior/track', methods=['POST'])
@login_required
async def track_user_behavior():
"""
Track user behavior for personalization
Request Body:
{
"type": "search|play|download|like",
"data": {
"query": "search query",
"track_id": "track_id",
"artist": "artist_name",
"timestamp": "ISO timestamp",
"context": "context information"
}
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
interaction_type = data.get('type')
interaction_data = data.get('data', {})
# Validate interaction type
valid_types = ['search', 'play', 'download', 'like']
if interaction_type not in valid_types:
return error_response(f"Invalid type. Must be one of: {valid_types}", 400)
# Add user ID and timestamp to interaction data
interaction_data['user_id'] = user_id
if 'timestamp' not in interaction_data:
interaction_data['timestamp'] = datetime.utcnow().isoformat()
# Update user behavior
await advanced_ux_service.update_user_behavior(user_id, interaction_data)
return success_response({
'message': 'User behavior tracked successfully'
})
except Exception as e:
logger.error(f"Error tracking user behavior: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/behavior/profile', methods=['GET'])
@login_required
async def get_user_behavior_profile():
"""
Get user behavior profile for personalization insights
"""
try:
user_id = get_current_user_id()
# Get user behavior
behavior = await advanced_ux_service._get_user_behavior(user_id)
# Format response
profile = {
'user_id': behavior.user_id,
'favorite_genres': behavior.favorite_genres,
'favorite_artists': behavior.favorite_artists,
'listening_patterns': behavior.listening_patterns,
'download_preferences': behavior.download_preferences,
'interaction_patterns': behavior.interaction_patterns,
'last_updated': behavior.last_updated.isoformat(),
'search_history_count': len(behavior.search_history),
'recent_searches': behavior.search_history[-5:] if behavior.search_history else []
}
return success_response({
'profile': profile
})
except Exception as e:
logger.error(f"Error getting user behavior profile: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/trending/content', methods=['GET'])
@login_required
async def get_trending_content():
"""
Get trending content based on user preferences and global trends
Query Parameters:
- type: Content type (tracks, artists, albums, mixed)
- limit: Maximum items to return (default: 20)
- timeframe: Timeframe for trends (day, week, month, all)
"""
try:
user_id = get_current_user_id()
content_type = request.args.get('type', 'mixed')
limit = min(request.args.get('limit', 20, type=int), 100)
timeframe = request.args.get('timeframe', 'week')
# Validate inputs
valid_types = ['tracks', 'artists', 'albums', 'mixed']
if content_type not in valid_types:
return error_response(f"Invalid type. Must be one of: {valid_types}", 400)
valid_timeframes = ['day', 'week', 'month', 'all']
if timeframe not in valid_timeframes:
return error_response(f"Invalid timeframe. Must be one of: {valid_timeframes}", 400)
# Get trending content (this would integrate with analytics)
# For now, return discovery recommendations as trending
trending = await advanced_ux_service.get_discovery_recommendations(user_id, content_type, limit)
# Format response
formatted_trending = []
for item in trending:
formatted_item = {
'id': item.id,
'type': item.type.value,
'title': item.title,
'subtitle': item.subtitle,
'image_url': item.image_url,
'url': item.url,
'metadata': item.metadata,
'relevance_score': item.relevance_score,
'trend_score': item.relevance_score # Would calculate actual trend score
}
formatted_trending.append(formatted_item)
return success_response({
'trending': formatted_trending,
'type': content_type,
'timeframe': timeframe,
'total_count': len(formatted_trending)
})
except Exception as e:
logger.error(f"Error getting trending content: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/search/advanced', methods=['POST'])
@login_required
async def advanced_search():
"""
Perform advanced search with filters and personalization
Request Body:
{
"query": "search query",
"filters": {
"genre": ["rock", "pop"],
"mood": "energetic",
"year": ["2020", "2021"],
"quality": "high",
"duration": "medium"
},
"sort_by": "relevance|popularity|date",
"sort_order": "asc|desc",
"limit": 20,
"offset": 0
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
query = data.get('query', '').strip()
filters = data.get('filters', {})
sort_by = data.get('sort_by', 'relevance')
sort_order = data.get('sort_order', 'desc')
limit = min(data.get('limit', 20, type=int), 100)
offset = max(data.get('offset', 0, type=int), 0)
# Validate inputs
validate_search_query(query)
valid_sort_by = ['relevance', 'popularity', 'date', 'title', 'artist']
if sort_by not in valid_sort_by:
return error_response(f"Invalid sort_by. Must be one of: {valid_sort_by}", 400)
valid_sort_order = ['asc', 'desc']
if sort_order not in valid_sort_order:
return error_response(f"Invalid sort_order. Must be one of: {valid_sort_order}", 400)
# Perform advanced search
# This would implement complex search logic with filters
# For now, use basic search suggestions as placeholder
context = SearchContext.GENERAL
if filters.get('quality') == 'lossless' or 'download' in query.lower():
context = SearchContext.DOWNLOAD
suggestions = await advanced_ux_service.get_search_suggestions(user_id, query, context, limit + offset)
# Apply filters (simplified)
filtered_suggestions = []
for suggestion in suggestions:
include = True
# Genre filter
if 'genre' in filters and filters['genre']:
if not any(genre.lower() in (suggestion.subtitle or '').lower() for genre in filters['genre']):
include = False
# Quality filter
if 'quality' in filters and filters['quality']:
if filters['quality'] not in (suggestion.subtitle or '').lower():
include = False
if include:
filtered_suggestions.append(suggestion)
# Sort results
if sort_by == 'relevance':
filtered_suggestions.sort(key=lambda x: x.relevance_score, reverse=(sort_order == 'desc'))
elif sort_by == 'title':
filtered_suggestions.sort(key=lambda x: x.title.lower(), reverse=(sort_order == 'desc'))
elif sort_by == 'artist':
filtered_suggestions.sort(key=lambda x: (x.subtitle or '').lower(), reverse=(sort_order == 'desc'))
# Apply pagination
paginated_suggestions = filtered_suggestions[offset:offset + limit]
# Format response
formatted_results = []
for suggestion in paginated_suggestions:
formatted_result = {
'id': suggestion.id,
'type': suggestion.type.value,
'title': suggestion.title,
'subtitle': suggestion.subtitle,
'image_url': suggestion.image_url,
'url': suggestion.url,
'metadata': suggestion.metadata,
'relevance_score': suggestion.relevance_score
}
formatted_results.append(formatted_result)
return success_response({
'results': formatted_results,
'query': query,
'filters': filters,
'sort_by': sort_by,
'sort_order': sort_order,
'total_count': len(filtered_suggestions),
'limit': limit,
'offset': offset
})
except Exception as e:
logger.error(f"Error performing advanced search: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/suggestions/quick', methods=['GET'])
@login_required
async def get_quick_suggestions():
"""
Get quick suggestions for UI components (autocomplete, etc.)
Query Parameters:
- type: Suggestion type (search, discovery, download)
- limit: Maximum suggestions (default: 5)
"""
try:
user_id = get_current_user_id()
suggestion_type = request.args.get('type', 'search')
limit = min(request.args.get('limit', 5, type=int), 20)
# Validate suggestion type
valid_types = ['search', 'discovery', 'download']
if suggestion_type not in valid_types:
return error_response(f"Invalid type. Must be one of: {valid_types}", 400)
suggestions = []
if suggestion_type == 'search':
# Get default search suggestions
suggestions = await advanced_ux_service._get_default_suggestions(user_id, SearchContext.GENERAL, limit)
elif suggestion_type == 'discovery':
# Get discovery recommendations
suggestions = await advanced_ux_service.get_discovery_recommendations(user_id, 'mixed', limit)
elif suggestion_type == 'download':
# Get download suggestions
suggestions = await advanced_ux_service.get_download_suggestions(user_id, '', limit)
# Format response for quick UI
formatted_suggestions = []
for suggestion in suggestions:
formatted_suggestion = {
'id': suggestion.id,
'type': suggestion.type.value,
'title': suggestion.title,
'subtitle': suggestion.subtitle,
'image_url': suggestion.image_url,
'url': suggestion.url
}
formatted_suggestions.append(formatted_suggestion)
return success_response({
'suggestions': formatted_suggestions,
'type': suggestion_type,
'total_count': len(formatted_suggestions)
})
except Exception as e:
logger.error(f"Error getting quick suggestions: {e}")
return error_response("Internal server error", 500)
@advanced_ux_bp.route('/personalization/preferences', methods=['GET', 'PUT'])
@login_required
async def personalization_preferences():
"""
Get or update personalization preferences
GET: Returns current preferences
PUT: Updates preferences
"""
try:
user_id = get_current_user_id()
if request.method == 'GET':
# Get user behavior profile
behavior = await advanced_ux_service._get_user_behavior(user_id)
preferences = {
'favorite_genres': behavior.favorite_genres,
'favorite_artists': behavior.favorite_artists,
'download_preferences': behavior.download_preferences,
'interaction_patterns': behavior.interaction_patterns
}
return success_response({
'preferences': preferences
})
elif request.method == 'PUT':
# Update preferences
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Update user behavior with preferences
interaction_data = {
'type': 'preferences_update',
'data': data
}
await advanced_ux_service.update_user_behavior(user_id, interaction_data)
return success_response({
'message': 'Preferences updated successfully'
})
except Exception as e:
logger.error(f"Error handling personalization preferences: {e}")
return error_response("Internal server error", 500)
+805
View File
@@ -0,0 +1,805 @@
"""
Audio Quality Management API Endpoints
This module provides REST API endpoints for the advanced audio quality control system,
including adaptive streaming, audio enhancement, quality analysis, and user preferences.
"""
import logging
from typing import Dict, List, Optional, Any
from flask import Blueprint, request, jsonify, send_file
from flask_login import login_required, current_user
from swingmusic.db import db
from swingmusic.services.audio_quality_manager import (
audio_quality_manager, AudioQualitySettings, AudioFormat, QualityLevel,
SampleRate, BitDepth, SpatialAudioFormat
)
from swingmusic.utils.request import APIError, success_response, error_response
from swingmusic.utils.validators import validate_audio_file
logger = logging.getLogger(__name__)
audio_quality_bp = Blueprint('audio_quality', __name__, url_prefix='/api/audio-quality')
def get_current_user_id() -> int:
"""Get current user ID from Flask-Login"""
return current_user.id if current_user.is_authenticated else None
@audio_quality_bp.route('/settings', methods=['GET'])
@login_required
async def get_quality_settings():
"""
Get user's audio quality settings
"""
try:
settings = await audio_quality_manager._get_user_settings(get_current_user_id())
return success_response({
'settings': {
'streaming_quality': settings.streaming_quality.value,
'adaptive_quality': settings.adaptive_quality,
'network_aware_quality': settings.network_aware_quality,
'device_specific_quality': settings.device_specific_quality,
'download_format': settings.download_format.value,
'download_bitrate': settings.download_bitrate,
'download_sample_rate': settings.download_sample_rate.value,
'download_bit_depth': settings.download_bit_depth.value,
'enable_dolby_atmos': settings.enable_dolby_atmos,
'enable_360_audio': settings.enable_360_audio,
'spatial_audio_format': settings.spatial_audio_format.value,
'enable_adaptive_eq': settings.enable_adaptive_eq,
'enable_spatial_audio_processing': settings.enable_spatial_audio_processing,
'enable_loudness_normalization': settings.enable_loudness_normalization,
'target_loudness': settings.target_loudness,
'enable_crossfade': settings.enable_crossfade,
'crossfade_duration': settings.crossfade_duration,
'enable_gapless_playback': settings.enable_gapless_playback,
'enable_replaygain': settings.enable_replaygain,
'prioritize_fidelity': settings.prioritize_fidelity,
'prioritize_file_size': settings.prioritize_file_size,
'prioritize_compatibility': settings.prioritize_compatibility,
'custom_ffmpeg_params': settings.custom_ffmpeg_params or {},
'enable_experimental_codecs': settings.enable_experimental_codecs,
'cache_transcoded_files': settings.cache_transcoded_files
}
})
except Exception as e:
logger.error(f"Error getting quality settings: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/settings', methods=['POST'])
@login_required
async def update_quality_settings():
"""
Update user's audio quality settings
Request Body:
{
"streaming_quality": "lossless|high|medium|low|data_saver",
"adaptive_quality": true,
"network_aware_quality": true,
"device_specific_quality": true,
"download_format": "flac|mp3_320|mp3_256|aac_256|...",
"download_bitrate": 320,
"download_sample_rate": "44.1kHz|48kHz|96kHz|192kHz",
"download_bit_depth": "16bit|24bit|32bit",
"enable_dolby_atmos": false,
"enable_360_audio": false,
"spatial_audio_format": "stereo|binaural|dolby_atmos|...",
"enable_adaptive_eq": true,
"enable_spatial_audio_processing": false,
"enable_loudness_normalization": true,
"target_loudness": -14.0,
"enable_crossfade": false,
"crossfade_duration": 2.0,
"enable_gapless_playback": true,
"enable_replaygain": true,
"prioritize_fidelity": true,
"prioritize_file_size": false,
"prioritize_compatibility": false,
"custom_ffmpeg_params": {},
"enable_experimental_codecs": false,
"cache_transcoded_files": true
}
"""
try:
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate and convert settings
settings = AudioQualitySettings()
# Streaming quality
if 'streaming_quality' in data:
try:
settings.streaming_quality = QualityLevel(data['streaming_quality'])
except ValueError:
return error_response("Invalid streaming quality", 400)
# Boolean settings
for key in ['adaptive_quality', 'network_aware_quality', 'device_specific_quality',
'enable_dolby_atmos', 'enable_360_audio', 'enable_adaptive_eq',
'enable_spatial_audio_processing', 'enable_loudness_normalization',
'enable_crossfade', 'enable_gapless_playback', 'enable_replaygain',
'prioritize_fidelity', 'prioritize_file_size', 'prioritize_compatibility',
'enable_experimental_codecs', 'cache_transcoded_files']:
if key in data:
setattr(settings, key, bool(data[key]))
# Download format
if 'download_format' in data:
try:
settings.download_format = AudioFormat(data['download_format'])
except ValueError:
return error_response("Invalid download format", 400)
# Numeric settings
if 'download_bitrate' in data:
bitrate = data['download_bitrate']
if bitrate is not None and (not isinstance(bitrate, int) or bitrate < 0 or bitrate > 1000):
return error_response("Invalid download bitrate", 400)
settings.download_bitrate = bitrate
if 'target_loudness' in data:
loudness = data['target_loudness']
if not isinstance(loudness, (int, float)) or loudness < -70 or loudness > 0:
return error_response("Invalid target loudness", 400)
settings.target_loudness = float(loudness)
if 'crossfade_duration' in data:
duration = data['crossfade_duration']
if not isinstance(duration, (int, float)) or duration < 0 or duration > 10:
return error_response("Invalid crossfade duration", 400)
settings.crossfade_duration = float(duration)
# Enum settings
if 'download_sample_rate' in data:
try:
settings.download_sample_rate = SampleRate(data['download_sample_rate'])
except ValueError:
return error_response("Invalid download sample rate", 400)
if 'download_bit_depth' in data:
try:
settings.download_bit_depth = BitDepth(data['download_bit_depth'])
except ValueError:
return error_response("Invalid download bit depth", 400)
if 'spatial_audio_format' in data:
try:
settings.spatial_audio_format = SpatialAudioFormat(data['spatial_audio_format'])
except ValueError:
return error_response("Invalid spatial audio format", 400)
# Custom FFmpeg params
if 'custom_ffmpeg_params' in data:
if not isinstance(data['custom_ffmpeg_params'], dict):
return error_response("Custom FFmpeg params must be an object", 400)
settings.custom_ffmpeg_params = data['custom_ffmpeg_params']
# Update settings
success = await audio_quality_manager.update_user_settings(get_current_user_id(), settings)
if success:
return success_response({
'message': 'Audio quality settings updated successfully',
'settings': data
})
else:
return error_response("Failed to update settings", 500)
except Exception as e:
logger.error(f"Error updating quality settings: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/optimal-streaming', methods=['GET'])
@login_required
async def get_optimal_streaming_quality():
"""
Get optimal streaming quality based on current conditions
Query Parameters:
- context: JSON string with additional context (battery, network, etc.)
"""
try:
context_str = request.args.get('context', '{}')
try:
context = json.loads(context_str) if context_str else {}
except json.JSONDecodeError:
context = {}
optimal = await audio_quality_manager.get_optimal_streaming_quality(
get_current_user_id(), context
)
return success_response({
'optimal_quality': optimal,
'context': context
})
except Exception as e:
logger.error(f"Error getting optimal streaming quality: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/transcode', methods=['POST'])
@login_required
async def transcode_for_streaming():
"""
Transcode audio file for optimal streaming
Request Body:
{
"file_path": "/path/to/audio/file",
"context": {}
}
"""
try:
data = request.get_json()
if not data or not data.get('file_path'):
return error_response("file_path is required", 400)
file_path = data['file_path']
context = data.get('context', {})
# Validate file
if not validate_audio_file(file_path):
return error_response("Invalid audio file", 400)
# Transcode for streaming
transcoded_path = await audio_quality_manager.transcode_for_streaming(
file_path, get_current_user_id(), context
)
if transcoded_path:
return success_response({
'transcoded_path': transcoded_path,
'original_path': file_path
})
else:
return error_response("Transcoding failed", 500)
except Exception as e:
logger.error(f"Error transcoding for streaming: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/analyze', methods=['POST'])
@login_required
async def analyze_audio_file():
"""
Analyze audio file for quality metrics
Request Body:
{
"file_path": "/path/to/audio/file"
}
"""
try:
data = request.get_json()
if not data or not data.get('file_path'):
return error_response("file_path is required", 400)
file_path = data['file_path']
# Validate file
if not validate_audio_file(file_path):
return error_response("Invalid audio file", 400)
# Analyze file
analysis = await audio_quality_manager.analyze_audio_file(file_path)
return success_response({
'analysis': {
'file_path': analysis.file_path,
'format': analysis.format,
'duration': analysis.duration,
'sample_rate': analysis.sample_rate,
'bit_depth': analysis.bit_depth,
'bitrate': analysis.bitrate,
'channels': analysis.channels,
'codec': analysis.codec,
'dynamic_range': analysis.dynamic_range,
'peak_level': analysis.peak_level,
'rms_level': analysis.rms_level,
'loudness': analysis.loudness,
'frequency_response': analysis.frequency_response,
'spectral_centroid': analysis.spectral_centroid,
'spectral_rolloff': analysis.spectral_rolloff,
'signal_to_noise_ratio': analysis.signal_to_noise_ratio,
'total_harmonic_distortion': analysis.total_harmonic_distortion,
'detected_genre': analysis.detected_genre,
'acoustic_features': analysis.acoustic_features or {}
}
})
except Exception as e:
logger.error(f"Error analyzing audio file: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/compare', methods=['POST'])
@login_required
async def compare_quality_formats():
"""
Compare quality across different audio formats
Request Body:
{
"file_path": "/path/to/audio/file",
"formats": ["flac", "mp3_320", "mp3_256", "aac_256"]
}
"""
try:
data = request.get_json()
if not data or not data.get('file_path'):
return error_response("file_path is required", 400)
file_path = data['file_path']
formats = data.get('formats', ['flac', 'mp3_320'])
# Validate file
if not validate_audio_file(file_path):
return error_response("Invalid audio file", 400)
# Convert format strings to enum
format_enums = []
for format_str in formats:
try:
format_enums.append(AudioFormat(format_str))
except ValueError:
return error_response(f"Invalid format: {format_str}", 400)
# Compare formats
comparison = await audio_quality_manager.compare_quality_formats(
file_path, format_enums
)
return success_response({
'comparison': {
'original_file': comparison.original_file,
'formats': comparison.formats,
'size_difference': comparison.size_difference,
'quality_score': comparison.quality_score,
'transparency_score': comparison.transparency_score,
'recommended_format': comparison.recommended_format,
'recommended_reason': comparison.recommended_reason
}
})
except Exception as e:
logger.error(f"Error comparing quality formats: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/enhance', methods=['POST'])
@login_required
async def enhance_audio():
"""
Apply audio enhancements to a file
Request Body:
{
"input_path": "/path/to/input/file",
"output_path": "/path/to/output/file",
"enhancements": {
"enable_loudness_normalization": true,
"target_loudness": -14.0,
"enable_adaptive_eq": true,
"enable_spatial_audio_processing": false,
"spatial_audio_format": "stereo"
}
}
"""
try:
data = request.get_json()
if not data or not data.get('input_path') or not data.get('output_path'):
return error_response("input_path and output_path are required", 400)
input_path = data['input_path']
output_path = data['output_path']
enhancements = data.get('enhancements', {})
# Validate files
if not validate_audio_file(input_path):
return error_response("Invalid input audio file", 400)
# Build settings
settings = AudioQualitySettings()
# Apply enhancement settings
for key, value in enhancements.items():
if hasattr(settings, key):
setattr(settings, key, value)
# Apply enhancements
success = await audio_quality_manager.enhancement_service.apply_enhancements(
input_path, output_path, settings
)
if success:
return success_response({
'message': 'Audio enhancements applied successfully',
'input_path': input_path,
'output_path': output_path,
'enhancements': enhancements
})
else:
return error_response("Audio enhancement failed", 500)
except Exception as e:
logger.error(f"Error enhancing audio: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/formats', methods=['GET'])
@login_required
async def get_supported_formats():
"""
Get list of supported audio formats and their capabilities
"""
try:
formats = {
'lossless': {
'flac': {
'name': 'FLAC',
'description': 'Free Lossless Audio Codec',
'extension': '.flac',
'max_bitrate': None,
'sample_rates': ['44.1kHz', '48kHz', '96kHz', '192kHz'],
'bit_depths': ['16bit', '24bit'],
'channels': ['mono', 'stereo', '5.1', '7.1'],
'compression': 'lossless',
'compatibility': 'high'
},
'alac': {
'name': 'ALAC',
'description': 'Apple Lossless Audio Codec',
'extension': '.m4a',
'max_bitrate': None,
'sample_rates': ['44.1kHz', '48kHz', '96kHz'],
'bit_depths': ['16bit', '24bit'],
'channels': ['mono', 'stereo', '5.1'],
'compression': 'lossless',
'compatibility': 'medium' # Apple ecosystem
},
'wav': {
'name': 'WAV',
'description': 'Waveform Audio File Format',
'extension': '.wav',
'max_bitrate': None,
'sample_rates': ['44.1kHz', '48kHz', '96kHz', '192kHz'],
'bit_depths': ['16bit', '24bit', '32bit'],
'channels': ['mono', 'stereo', '5.1', '7.1'],
'compression': 'none',
'compatibility': 'high'
}
},
'lossy': {
'mp3_320': {
'name': 'MP3 320kbps',
'description': 'MPEG Audio Layer 3 at 320kbps',
'extension': '.mp3',
'max_bitrate': 320,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'very_high'
},
'mp3_256': {
'name': 'MP3 256kbps',
'description': 'MPEG Audio Layer 3 at 256kbps',
'extension': '.mp3',
'max_bitrate': 256,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'very_high'
},
'mp3_192': {
'name': 'MP3 192kbps',
'description': 'MPEG Audio Layer 3 at 192kbps',
'extension': '.mp3',
'max_bitrate': 192,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'very_high'
},
'mp3_128': {
'name': 'MP3 128kbps',
'description': 'MPEG Audio Layer 3 at 128kbps',
'extension': '.mp3',
'max_bitrate': 128,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'very_high'
},
'aac_256': {
'name': 'AAC 256kbps',
'description': 'Advanced Audio Coding at 256kbps',
'extension': '.m4a',
'max_bitrate': 256,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'high'
},
'aac_192': {
'name': 'AAC 192kbps',
'description': 'Advanced Audio Coding at 192kbps',
'extension': '.m4a',
'max_bitrate': 192,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'high'
},
'aac_128': {
'name': 'AAC 128kbps',
'description': 'Advanced Audio Coding at 128kbps',
'extension': '.m4a',
'max_bitrate': 128,
'sample_rates': ['44.1kHz', '48kHz'],
'bit_depths': ['16bit'],
'channels': ['stereo'],
'compression': 'lossy',
'compatibility': 'high'
},
'ogg_vorbis': {
'name': 'Ogg Vorbis',
'description': 'Ogg Vorbis compressed audio',
'extension': '.ogg',
'max_bitrate': 500,
'sample_rates': ['44.1kHz', '48kHz', '96kHz'],
'bit_depths': ['16bit', '24bit'],
'channels': ['mono', 'stereo', '5.1'],
'compression': 'lossy',
'compatibility': 'medium'
},
'ogg_opus': {
'name': 'Opus',
'description': 'Opus audio codec',
'extension': '.opus',
'max_bitrate': 510,
'sample_rates': ['48kHz'],
'bit_depths': ['16bit'],
'channels': ['mono', 'stereo'],
'compression': 'lossy',
'compatibility': 'medium'
}
}
}
return success_response({'formats': formats})
except Exception as e:
logger.error(f"Error getting supported formats: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/quality-presets', methods=['GET'])
@login_required
async def get_quality_presets():
"""
Get predefined quality presets for different use cases
"""
try:
presets = {
'audiophile': {
'name': 'Audiophile',
'description': 'Maximum quality for critical listening',
'settings': {
'streaming_quality': 'lossless',
'download_format': 'flac',
'download_sample_rate': '96kHz',
'download_bit_depth': '24bit',
'enable_loudness_normalization': false,
'prioritize_fidelity': true
}
},
'portable': {
'name': 'Portable',
'description': 'Balanced quality for mobile devices',
'settings': {
'streaming_quality': 'high',
'download_format': 'aac_256',
'adaptive_quality': true,
'network_aware_quality': true,
'device_specific_quality': true,
'enable_loudness_normalization': true,
'prioritize_compatibility': true
}
},
'data_saver': {
'name': 'Data Saver',
'description': 'Minimal bandwidth usage',
'settings': {
'streaming_quality': 'data_saver',
'download_format': 'mp3_128',
'adaptive_quality': true,
'network_aware_quality': true,
'enable_loudness_normalization': true,
'prioritize_file_size': true
}
},
'studio': {
'name': 'Studio',
'description': 'Professional quality for production',
'settings': {
'streaming_quality': 'lossless',
'download_format': 'wav',
'download_sample_rate': '192kHz',
'download_bit_depth': '32bit',
'enable_loudness_normalization': false,
'prioritize_fidelity': true,
'cache_transcoded_files': false
}
},
'gaming': {
'name': 'Gaming',
'description': 'Low latency with good quality',
'settings': {
'streaming_quality': 'medium',
'download_format': 'mp3_256',
'enable_crossfade': false,
'enable_gapless_playback': true,
'cache_transcoded_files': true
}
},
'podcast': {
'name': 'Podcast',
'description': 'Optimized for speech content',
'settings': {
'streaming_quality': 'medium',
'download_format': 'aac_128',
'enable_loudness_normalization': true,
'target_loudness': -16.0,
'enable_adaptive_eq': true,
'prioritize_file_size': true
}
}
}
return success_response({'presets': presets})
except Exception as e:
logger.error(f"Error getting quality presets: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/apply-preset', methods=['POST'])
@login_required
async def apply_quality_preset():
"""
Apply a quality preset to user settings
Request Body:
{
"preset_name": "audiophile|portable|data_saver|studio|gaming|podcast"
}
"""
try:
data = request.get_json()
if not data or not data.get('preset_name'):
return error_response("preset_name is required", 400)
preset_name = data['preset_name']
# Get presets
presets_response = await get_quality_presets()
presets = presets_response[1].get_json()['presets']
if preset_name not in presets:
return error_response(f"Unknown preset: {preset_name}", 400)
preset = presets[preset_name]
# Apply preset settings
success = await audio_quality_manager.update_user_settings(
get_current_user_id(),
AudioQualitySettings(**preset['settings'])
)
if success:
return success_response({
'message': f'Applied {preset["name"]} preset successfully',
'preset': preset,
'settings': preset['settings']
})
else:
return error_response("Failed to apply preset", 500)
except Exception as e:
logger.error(f"Error applying quality preset: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/cache/clear', methods=['POST'])
@login_required
async def clear_quality_cache():
"""
Clear audio quality analysis and transcoding cache
"""
try:
audio_quality_manager.clear_cache()
return success_response({
'message': 'Audio quality cache cleared successfully'
})
except Exception as e:
logger.error(f"Error clearing quality cache: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/network/status', methods=['GET'])
@login_required
async def get_network_status():
"""
Get current network status for quality optimization
"""
try:
from swingmusic.services.audio_quality_manager import NetworkMonitor
network_monitor = NetworkMonitor()
status = await network_monitor.get_network_status()
return success_response({
'network_status': status
})
except Exception as e:
logger.error(f"Error getting network status: {e}")
return error_response("Internal server error", 500)
@audio_quality_bp.route('/device/info', methods=['GET'])
@login_required
async def get_device_info():
"""
Get device information for quality optimization
"""
try:
from swingmusic.services.audio_quality_manager import DeviceDetector
device_detector = DeviceDetector()
device_info = device_detector.get_device_info()
return success_response({
'device_info': device_info
})
except Exception as e:
logger.error(f"Error getting device info: {e}")
return error_response("Internal server error", 500)
# Error handlers
@audio_quality_bp.errorhandler(404)
def not_found(error):
return error_response("Endpoint not found", 404)
@audio_quality_bp.errorhandler(500)
def internal_error(error):
return error_response("Internal server error", 500)
+463
View File
@@ -0,0 +1,463 @@
"""
Enhanced Search API for SwingMusic
Integrates global music catalog search with existing local search
"""
from flask import Blueprint, request, jsonify
from typing import Dict, List, Any, Optional
import asyncio
from swingmusic.services.music_catalog import music_catalog_service
from swingmusic.api.search import search as local_search
from swingmusic import logger
from swingmusic.db.spotify import UserCatalogPreferencesTable
# Create blueprint
enhanced_search_bp = Blueprint('enhanced_search', __name__, url_prefix='/api/search')
@enhanced_search_bp.route('/global', methods=['POST'])
def global_search():
"""
Search across global music catalog (Spotify)
Request body:
{
"query": "search query",
"type": "all|tracks|albums|artists|playlists",
"limit": 20,
"user_id": 1
}
"""
try:
data = request.get_json()
if not data or not data.get('query'):
return jsonify({'error': 'Search query is required'}), 400
query = data['query'].strip()
search_type = data.get('type', 'all')
limit = min(data.get('limit', 20), 50) # Cap at 50
user_id = data.get('user_id')
# Get user preferences if available
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_search_results)
# Run async search
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, search_type, limit)
)
finally:
loop.close()
# Filter based on user preferences
if user_prefs and not user_prefs.show_explicit:
result.tracks = [track for track in result.tracks if not track.explicit]
result.albums = [album for album in result.albums if not album.explicit]
# Convert to dict for JSON response
response_data = {
'query': result.query,
'total': result.total,
'tracks': [_catalog_item_to_dict(track) for track in result.tracks],
'albums': [_catalog_item_to_dict(album) for album in result.albums],
'artists': [_catalog_item_to_dict(artist) for artist in result.artists],
'playlists': [_catalog_item_to_dict(playlist) for playlist in result.playlists],
'source': 'global_catalog',
'cache_info': {
'from_cache': True, # TODO: Implement cache detection
'expires_at': None
}
}
return jsonify(response_data)
except Exception as e:
logger.error(f"Error in global search: {e}")
return jsonify({'error': 'Search failed'}), 500
@enhanced_search_bp.route('/combined', methods=['POST'])
def combined_search():
"""
Search both local library and global catalog
Request body:
{
"query": "search query",
"include_local": true,
"include_global": true,
"type": "all|tracks|albums|artists",
"limit": 20,
"user_id": 1
}
"""
try:
data = request.get_json()
if not data or not data.get('query'):
return jsonify({'error': 'Search query is required'}), 400
query = data['query'].strip()
include_local = data.get('include_local', True)
include_global = data.get('include_global', True)
search_type = data.get('type', 'all')
limit = min(data.get('limit', 20), 50)
user_id = data.get('user_id')
results = {
'query': query,
'local': {'tracks': [], 'albums': [], 'artists': []},
'global': {'tracks': [], 'albums': [], 'artists': [], 'playlists': []},
'total': 0
}
# Search local library
if include_local:
try:
# Use existing local search
local_results = local_search(query, search_type)
results['local'] = local_results if local_results else {'tracks': [], 'albums': [], 'artists': []}
except Exception as e:
logger.error(f"Error in local search: {e}")
# Search global catalog
if include_global:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
global_results = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, search_type, limit)
)
# Filter based on user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
if not user_prefs.show_explicit:
global_results.tracks = [track for track in global_results.tracks if not track.explicit]
global_results.albums = [album for album in global_results.albums if not album.explicit]
results['global'] = {
'tracks': [_catalog_item_to_dict(track) for track in global_results.tracks],
'albums': [_catalog_item_to_dict(album) for album in global_results.albums],
'artists': [_catalog_item_to_dict(artist) for artist in global_results.artists],
'playlists': [_catalog_item_to_dict(playlist) for playlist in global_results.playlists]
}
finally:
loop.close()
# Calculate total
results['total'] = (
len(results['local'].get('tracks', [])) +
len(results['local'].get('albums', [])) +
len(results['local'].get('artists', [])) +
len(results['global'].get('tracks', [])) +
len(results['global'].get('albums', [])) +
len(results['global'].get('artists', [])) +
len(results['global'].get('playlists', []))
)
return jsonify(results)
except Exception as e:
logger.error(f"Error in combined search: {e}")
return jsonify({'error': 'Search failed'}), 500
@enhanced_search_bp.route('/suggestions', methods=['GET'])
def search_suggestions():
"""
Get search suggestions based on query and user preferences
Query parameters:
- q: search query
- type: tracks|albums|artists|all
- limit: number of suggestions (default 10)
- user_id: user ID for preferences
"""
try:
query = request.args.get('q', '').strip()
if not query or len(query) < 2:
return jsonify({'suggestions': []})
search_type = request.args.get('type', 'all')
limit = min(int(request.args.get('limit', 10)), 20)
user_id = request.args.get('user_id')
# Get user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_search_results)
# Search cached items for fast suggestions
item_types = None
if search_type != 'all':
item_types = [search_type]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# For suggestions, search both cache and live
suggestions = []
# Search cached items first (fast)
from swingmusic.db.spotify import GlobalCatalogCacheTable
cached_items = GlobalCatalogCacheTable.search_cached(query, item_types, limit)
for item in cached_items:
if user_prefs and not user_prefs.show_explicit and item.explicit:
continue
suggestion = {
'id': item.spotify_id,
'type': item.item_type,
'title': item.title,
'artist': item.artist,
'album': item.album,
'image_url': item.image_url,
'popularity': item.popularity,
'source': 'cache'
}
suggestions.append(suggestion)
# If we need more suggestions, search global catalog
if len(suggestions) < limit:
remaining = limit - len(suggestions)
global_results = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, search_type, remaining)
)
for track in global_results.tracks[:remaining]:
if user_prefs and not user_prefs.show_explicit and track.explicit:
continue
suggestion = {
'id': track.spotify_id,
'type': 'track',
'title': track.title,
'artist': track.artist,
'album': track.album,
'image_url': track.image_url,
'popularity': track.popularity,
'source': 'global'
}
suggestions.append(suggestion)
return jsonify({'suggestions': suggestions[:limit]})
finally:
loop.close()
except Exception as e:
logger.error(f"Error in search suggestions: {e}")
return jsonify({'suggestions': []})
@enhanced_search_bp.route('/artist/<artist_id>', methods=['GET'])
def get_artist_info(artist_id: str):
"""
Get comprehensive artist information including top tracks and albums
Path parameters:
- artist_id: Spotify artist ID
Query parameters:
- user_id: user ID for preferences
"""
try:
user_id = request.args.get('user_id')
# Get user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
artist_info = loop.run_until_complete(
music_catalog_service.get_artist_info(artist_id)
)
if not artist_info:
return jsonify({'error': 'Artist not found'}), 404
# Filter based on user preferences
if user_prefs and not user_prefs.show_explicit:
artist_info.top_tracks = [
track for track in artist_info.top_tracks or [] if not track.explicit
]
artist_info.albums = [
album for album in artist_info.albums or [] if not album.explicit
]
response_data = {
'spotify_id': artist_info.spotify_id,
'name': artist_info.name,
'image_url': artist_info.image_url,
'followers': artist_info.followers,
'popularity': artist_info.popularity,
'genres': artist_info.genres or [],
'top_tracks': [_catalog_item_to_dict(track) for track in (artist_info.top_tracks or [])],
'albums': [_catalog_item_to_dict(album) for album in (artist_info.albums or [])],
'related_artists': artist_info.related_artists or []
}
return jsonify(response_data)
finally:
loop.close()
except Exception as e:
logger.error(f"Error getting artist info: {e}")
return jsonify({'error': 'Failed to get artist info'}), 500
@enhanced_search_bp.route('/album/<album_id>', methods=['GET'])
def get_album_details(album_id: str):
"""
Get detailed album information with tracklist
Path parameters:
- album_id: Spotify album ID
Query parameters:
- user_id: user ID for preferences
"""
try:
user_id = request.args.get('user_id')
# Get user preferences
user_prefs = None
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
album = loop.run_until_complete(
music_catalog_service.get_album_details(album_id)
)
if not album:
return jsonify({'error': 'Album not found'}), 404
# Filter based on user preferences
if user_prefs and not user_prefs.show_explicit and album.explicit:
return jsonify({'error': 'Explicit content filtered'}), 403
response_data = _catalog_item_to_dict(album)
# Add tracklist if available in data
if album.data and 'tracks' in album.data:
response_data['tracks'] = [
_catalog_item_to_dict(track) for track in album.data['tracks']
]
return jsonify(response_data)
finally:
loop.close()
except Exception as e:
logger.error(f"Error getting album details: {e}")
return jsonify({'error': 'Failed to get album details'}), 500
@enhanced_search_bp.route('/preferences/<int:user_id>', methods=['GET', 'POST'])
def user_preferences(user_id: int):
"""Get or update user catalog search preferences"""
try:
if request.method == 'GET':
prefs = UserCatalogPreferencesTable.get_or_create(user_id)
return jsonify({
'user_id': prefs.user_id,
'show_explicit': prefs.show_explicit,
'default_quality': prefs.default_quality,
'auto_download': prefs.auto_download,
'show_suggestions': prefs.show_suggestions,
'preferred_genres': prefs.preferred_genres or [],
'excluded_genres': prefs.excluded_genres or [],
'max_search_results': prefs.max_search_results,
'cache_ttl_preference': prefs.cache_ttl_preference
})
elif request.method == 'POST':
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
# Update only provided fields
update_data = {}
allowed_fields = [
'show_explicit', 'default_quality', 'auto_download',
'show_suggestions', 'preferred_genres', 'excluded_genres',
'max_search_results', 'cache_ttl_preference'
]
for field in allowed_fields:
if field in data:
update_data[field] = data[field]
if update_data:
UserCatalogPreferencesTable.update_preferences(user_id, update_data)
return jsonify({'message': 'Preferences updated successfully'})
except Exception as e:
logger.error(f"Error handling user preferences: {e}")
return jsonify({'error': 'Failed to handle preferences'}), 500
def _catalog_item_to_dict(item) -> Dict[str, Any]:
"""Convert CatalogItem to dictionary for JSON response"""
if hasattr(item, '__dict__'):
# It's a dataclass instance
return {
'spotify_id': item.spotify_id,
'type': item.item_type.value if hasattr(item.item_type, 'value') else str(item.item_type),
'title': item.title,
'artist': item.artist,
'album': item.album,
'duration_ms': item.duration_ms,
'popularity': item.popularity,
'preview_url': item.preview_url,
'image_url': item.image_url,
'release_date': item.release_date,
'explicit': item.explicit,
'data': item.data
}
else:
# It's likely a database model
return {
'spotify_id': getattr(item, 'spotify_id', None),
'type': getattr(item, 'item_type', None),
'title': getattr(item, 'title', None),
'artist': getattr(item, 'artist', None),
'album': getattr(item, 'album', None),
'duration_ms': getattr(item, 'duration_ms', None),
'popularity': getattr(item, 'popularity', None),
'preview_url': getattr(item, 'preview_url', None),
'image_url': getattr(item, 'image_url', None),
'release_date': getattr(item, 'release_date', None),
'explicit': getattr(item, 'explicit', False),
'data': getattr(item, 'data', None)
}
def register_enhanced_search_api(app):
"""Register enhanced search API with Flask app"""
app.register_blueprint(enhanced_search_bp)
logger.info("Enhanced search API registered")
+47
View File
@@ -8,7 +8,9 @@ from swingmusic.lib.lyrics import (
get_lyrics_file,
get_lyrics_from_duplicates,
get_lyrics_from_tags,
Lyrics as Lyrics_class,
)
from swingmusic.plugins.lyrics import Lyrics
bp_tag = Tag(name="Lyrics", description="Get lyrics")
api = APIBlueprint("lyrics", __name__, url_prefix="/lyrics", abp_tags=[bp_tag])
@@ -50,6 +52,51 @@ def send_lyrics(body: SendLyricsBody):
# check lyrics plugins
if not lyrics:
try:
# Get track metadata for plugin search
entry = TrackStore.trackhashmap.get(trackhash, None)
if entry and len(entry.tracks) > 0:
track = entry.tracks[0] # Use first track for metadata
title = getattr(track, 'title', '') or ''
artist = ''
if hasattr(track, 'artists') and track.artists:
artist = track.artists[0].name if hasattr(track.artists[0], 'name') else str(track.artists[0])
album = ''
if hasattr(track, 'album') and track.album:
album = track.album.name if hasattr(track.album, 'name') else str(track.album)
# Only proceed if we have basic metadata
if title and artist:
# Initialize lyrics plugin
lyrics_plugin = Lyrics()
if lyrics_plugin.enabled:
# Search for lyrics using plugin
search_results = lyrics_plugin.search_lyrics_by_title_and_artist(title, artist)
if search_results and len(search_results) > 0:
# Use first result or perfect match
perfect_match = search_results[0]
# Try to find perfect match by comparing title and album
if album:
for result in search_results:
result_title = result.get("title", "").lower()
result_album = result.get("album", "").lower()
if (result_title == title.lower() and
result_album == album.lower()):
perfect_match = result
break
# Download lyrics using track ID
track_id = perfect_match.get("track_id")
if track_id:
lrc_content = lyrics_plugin.download_lyrics(track_id, filepath)
if lrc_content and len(lrc_content.strip()) > 0:
lyrics = Lyrics_class(lrc_content)
except Exception as e:
# Log error but don't break the lyrics fetching process
# In production, you might want to log this error
pass
if not lyrics:
return {"error": "No lyrics found"}
+621
View File
@@ -0,0 +1,621 @@
"""
Mobile Offline Mode API Endpoints
This module provides REST API endpoints for mobile offline functionality,
including device management, sync operations, and offline library access.
"""
import logging
from datetime import datetime
from typing import Dict, List, Optional, Any
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from swingmusic.db import db
from swingmusic.services.mobile_offline_service import mobile_offline_service, OfflineQuality, SyncStatus
from swingmusic.utils.request import APIError, success_response, error_response
from swingmusic.utils.validators import validate_device_info, validate_track_ids
logger = logging.getLogger(__name__)
mobile_offline_bp = Blueprint('mobile_offline', __name__, url_prefix='/api/mobile-offline')
def get_current_user_id() -> int:
"""Get current user ID from Flask-Login"""
return current_user.id if current_user.is_authenticated else None
@mobile_offline_bp.route('/devices/register', methods=['POST'])
@login_required
async def register_device():
"""
Register a new mobile device for offline sync
Request Body:
{
"name": "iPhone 14 Pro",
"type": "ios",
"storage_capacity": 256000000000,
"available_storage": 128000000000,
"preferences": {
"auto_sync": true,
"wifi_only": true,
"quality": "balanced"
}
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate device information
device_info = validate_device_info(data)
# Register device
device = await mobile_offline_service.register_device(user_id, device_info)
return success_response({
'message': 'Device registered successfully',
'device': {
'device_id': device.device_id,
'name': device.device_name,
'type': device.device_type,
'storage_capacity': device.storage_capacity,
'available_storage': device.available_storage,
'offline_quality': device.offline_quality.value,
'auto_sync_enabled': device.auto_sync_enabled,
'sync_status': device.sync_status.value,
'created_at': device.created_at.isoformat()
}
})
except Exception as e:
logger.error(f"Error registering device: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices', methods=['GET'])
@login_required
async def get_user_devices():
"""
Get all registered devices for the current user
"""
try:
user_id = get_current_user_id()
# This would get all devices for the user from database
# For now, return empty list as placeholder
devices = []
return success_response({
'devices': devices,
'total_count': len(devices)
})
except Exception as e:
logger.error(f"Error getting user devices: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>', methods=['GET'])
@login_required
async def get_device_info(device_id: str):
"""
Get specific device information
Path Parameters:
- device_id: Device ID
"""
try:
user_id = get_current_user_id()
device = await mobile_offline_service._get_device(device_id, user_id)
if not device:
return error_response("Device not found", 404)
return success_response({
'device': {
'device_id': device.device_id,
'name': device.device_name,
'type': device.device_type,
'storage_capacity': device.storage_capacity,
'available_storage': device.available_storage,
'last_sync': device.last_sync.isoformat() if device.last_sync else None,
'sync_status': device.sync_status.value,
'offline_quality': device.offline_quality.value,
'auto_sync_enabled': device.auto_sync_enabled,
'sync_preferences': device.sync_preferences,
'created_at': device.created_at.isoformat(),
'updated_at': device.updated_at.isoformat()
}
})
except Exception as e:
logger.error(f"Error getting device info: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/settings', methods=['PUT'])
@login_required
async def update_device_settings(device_id: str):
"""
Update device settings
Path Parameters:
- device_id: Device ID
Request Body:
{
"offline_quality": "high_quality",
"auto_sync_enabled": true,
"sync_preferences": {
"wifi_only": true,
"auto_cleanup": true
},
"available_storage": 120000000000
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate settings
if 'offline_quality' in data:
try:
OfflineQuality(data['offline_quality'])
except ValueError:
return error_response("Invalid offline quality", 400)
# Update settings
success = await mobile_offline_service.update_device_settings(user_id, device_id, data)
if not success:
return error_response("Failed to update device settings", 500)
return success_response({
'message': 'Device settings updated successfully'
})
except Exception as e:
logger.error(f"Error updating device settings: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/offline-library', methods=['GET'])
@login_required
async def get_offline_library(device_id: str):
"""
Get offline library for device
Path Parameters:
- device_id: Device ID
Query Parameters:
- include_tracks: Include track details (default: true)
- include_queue: Include sync queue status (default: true)
- include_storage: Include storage usage (default: true)
"""
try:
user_id = get_current_user_id()
# Parse include flags
include_flags = {
'tracks': request.args.get('include_tracks', 'true').lower() == 'true',
'queue': request.args.get('include_queue', 'true').lower() == 'true',
'storage': request.args.get('include_storage', 'true').lower() == 'true'
}
# Get offline library
library_data = await mobile_offline_service.get_offline_library(user_id, device_id)
# Build response based on include flags
response_data = {
'device': library_data['device'],
'last_sync': library_data['last_sync'],
'sync_status': library_data['sync_status']
}
if include_flags['tracks']:
response_data['offline_tracks'] = library_data['offline_tracks']
if include_flags['queue']:
response_data['sync_queue'] = library_data['sync_queue']
if include_flags['storage']:
response_data['storage_usage'] = library_data['storage_usage']
return success_response({
'offline_library': response_data
})
except Exception as e:
logger.error(f"Error getting offline library: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/add-tracks', methods=['POST'])
@login_required
async def add_tracks_to_offline(device_id: str):
"""
Add tracks to offline library
Path Parameters:
- device_id: Device ID
Request Body:
{
"track_ids": ["track1", "track2", "track3"],
"quality": "high_quality"
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
track_ids = data.get('track_ids', [])
if not track_ids:
return error_response("track_ids are required", 400)
# Validate track IDs
validate_track_ids(track_ids)
# Parse quality
quality = None
if 'quality' in data:
try:
quality = OfflineQuality(data['quality'])
except ValueError:
return error_response("Invalid quality", 400)
# Add tracks to offline library
queue_items = await mobile_offline_service.add_to_offline_library(
user_id, device_id, track_ids, quality
)
return success_response({
'message': f'Added {len(queue_items)} tracks to offline library',
'queue_items': [
{
'queue_id': item.queue_id,
'track_id': item.track_id,
'priority': item.priority,
'quality': item.quality,
'status': item.status,
'added_at': item.added_at.isoformat()
}
for item in queue_items
]
})
except Exception as e:
logger.error(f"Error adding tracks to offline library: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/sync-playlist/<playlist_id>', methods=['POST'])
@login_required
async def sync_playlist_offline(device_id: str, playlist_id: str):
"""
Sync entire playlist for offline playback
Path Parameters:
- device_id: Device ID
- playlist_id: Playlist ID
Request Body:
{
"quality": "balanced"
}
"""
try:
user_id = get_current_user_id()
data = request.get_json() or {}
# Parse quality
quality = None
if 'quality' in data:
try:
quality = OfflineQuality(data['quality'])
except ValueError:
return error_response("Invalid quality", 400)
# Sync playlist
queue_items = await mobile_offline_service.sync_playlist_offline(
user_id, device_id, playlist_id, quality
)
return success_response({
'message': f'Playlist sync started with {len(queue_items)} tracks',
'queue_items': [
{
'queue_id': item.queue_id,
'track_id': item.track_id,
'priority': item.priority,
'quality': item.quality,
'status': item.status,
'added_at': item.added_at.isoformat()
}
for item in queue_items
]
})
except Exception as e:
logger.error(f"Error syncing playlist offline: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/remove-tracks', methods=['POST'])
@login_required
async def remove_tracks_from_offline(device_id: str):
"""
Remove tracks from offline library
Path Parameters:
- device_id: Device ID
Request Body:
{
"track_ids": ["track1", "track2", "track3"]
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
track_ids = data.get('track_ids', [])
if not track_ids:
return error_response("track_ids are required", 400)
# Validate track IDs
validate_track_ids(track_ids)
# Remove tracks
success = await mobile_offline_service.remove_from_offline_library(
user_id, device_id, track_ids
)
if not success:
return error_response("Failed to remove tracks from offline library", 500)
return success_response({
'message': f'Removed {len(track_ids)} tracks from offline library'
})
except Exception as e:
logger.error(f"Error removing tracks from offline library: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/sync-progress', methods=['GET'])
@login_required
async def get_sync_progress(device_id: str):
"""
Get sync progress for device
Path Parameters:
- device_id: Device ID
"""
try:
user_id = get_current_user_id()
progress_data = await mobile_offline_service.get_sync_progress(user_id, device_id)
return success_response({
'sync_progress': progress_data
})
except Exception as e:
logger.error(f"Error getting sync progress: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/force-sync', methods=['POST'])
@login_required
async def force_sync_now(device_id: str):
"""
Force immediate sync for device
Path Parameters:
- device_id: Device ID
"""
try:
user_id = get_current_user_id()
success = await mobile_offline_service.force_sync_now(user_id, device_id)
if not success:
return error_response("Failed to force sync", 500)
return success_response({
'message': 'Sync started successfully'
})
except Exception as e:
logger.error(f"Error forcing sync: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/storage-info', methods=['GET'])
@login_required
async def get_storage_info(device_id: str):
"""
Get detailed storage information for device
Path Parameters:
- device_id: Device ID
"""
try:
user_id = get_current_user_id()
# Get device info
device = await mobile_offline_service._get_device(device_id, user_id)
if not device:
return error_response("Device not found", 404)
# Get storage usage
storage_usage = await mobile_offline_service._get_storage_usage(device_id)
# Calculate additional info
usage_percentage = (storage_usage.used_space / storage_usage.total_capacity * 100) if storage_usage.total_capacity > 0 else 0
return success_response({
'storage_info': {
'total_capacity': storage_usage.total_capacity,
'used_space': storage_usage.used_space,
'available_space': storage_usage.available_space,
'usage_percentage': round(usage_percentage, 2),
'offline_tracks_count': storage_usage.offline_tracks_count,
'offline_tracks_size': storage_usage.offline_tracks_size,
'other_data_size': storage_usage.other_data_size,
'quality_breakdown': storage_usage.quality_breakdown,
'needs_cleanup': usage_percentage > 90,
'recommendations': _get_storage_recommendations(usage_percentage, storage_usage)
}
})
except Exception as e:
logger.error(f"Error getting storage info: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/devices/<device_id>/cleanup', methods=['POST'])
@login_required
async def cleanup_storage(device_id: str):
"""
Cleanup storage by removing old/unused content
Path Parameters:
- device_id: Device ID
Request Body:
{
"strategy": "least_played|oldest|all",
"free_space_bytes": 1000000000
}
"""
try:
user_id = get_current_user_id()
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
strategy = data.get('strategy', 'least_played')
free_space_bytes = data.get('free_space_bytes', 0)
# Validate strategy
valid_strategies = ['least_played', 'oldest', 'all']
if strategy not in valid_strategies:
return error_response(f"Invalid strategy. Must be one of: {valid_strategies}", 400)
# Perform cleanup
# This would implement the actual cleanup logic
freed_space = await mobile_offline_service._cleanup_old_content(device_id, free_space_bytes)
return success_response({
'message': f'Cleanup completed',
'freed_space': freed_space,
'strategy_used': strategy
})
except Exception as e:
logger.error(f"Error during cleanup: {e}")
return error_response("Internal server error", 500)
@mobile_offline_bp.route('/quality-presets', methods=['GET'])
@login_required
async def get_quality_presets():
"""
Get available quality presets for offline downloads
"""
try:
presets = {
'space_saver': {
'name': 'Space Saver',
'description': 'Low quality, maximum storage efficiency',
'estimated_size_per_track': '3MB',
'recommended_for': 'Limited storage, large libraries',
'formats': ['MP3 128kbps', 'AAC 128kbps']
},
'balanced': {
'name': 'Balanced',
'description': 'Medium quality, good balance',
'estimated_size_per_track': '6MB',
'recommended_for': 'Most users, good quality',
'formats': ['MP3 256kbps', 'AAC 256kbps']
},
'high_quality': {
'name': 'High Quality',
'description': 'High quality, more storage usage',
'estimated_size_per_track': '12MB',
'recommended_for': 'Audiophiles, premium headphones',
'formats': ['MP3 320kbps', 'AAC 320kbps', 'OGG Vorbis']
},
'lossless': {
'name': 'Lossless',
'description': 'Lossless quality, maximum storage usage',
'estimated_size_per_track': '30MB',
'recommended_for': 'Critical listening, unlimited storage',
'formats': ['FLAC', 'ALAC', 'WAV']
}
}
return success_response({
'quality_presets': presets
})
except Exception as e:
logger.error(f"Error getting quality presets: {e}")
return error_response("Internal server error", 500)
def _get_storage_recommendations(usage_percentage: float, storage_usage) -> List[str]:
"""Get storage recommendations based on usage"""
recommendations = []
if usage_percentage > 95:
recommendations.extend([
"Critical: Storage almost full",
"Remove least played tracks immediately",
"Consider upgrading to higher capacity device"
])
elif usage_percentage > 90:
recommendations.extend([
"Storage nearly full",
"Enable auto-cleanup settings",
"Remove old or rarely played tracks"
])
elif usage_percentage > 80:
recommendations.extend([
"Storage getting full",
"Consider using space saver quality",
"Review offline library regularly"
])
elif usage_percentage > 70:
recommendations.extend([
"Moderate storage usage",
"Monitor storage regularly",
"Consider quality adjustments"
])
else:
recommendations.extend([
"Storage usage is healthy",
"Continue current settings",
"Consider adding more content if desired"
])
return recommendations
+467
View File
@@ -0,0 +1,467 @@
"""
Music Catalog API for SwingMusic
Provides Spotify-like browsing of global music catalog with download capabilities
"""
from flask import Blueprint, request, jsonify
from typing import Dict, List, Any, Optional
import asyncio
from swingmusic.services.music_catalog import music_catalog_service
from swingmusic import logger
from swingmusic.db.spotify import UserCatalogPreferencesTable
# Create blueprint
music_catalog_bp = Blueprint('music_catalog', __name__, url_prefix='/api/catalog')
@music_catalog_bp.route('/artist/<artist_id>/top-tracks', methods=['GET'])
def get_artist_top_tracks(artist_id: str):
"""
Get artist's most popular tracks
Query parameters:
- limit: Maximum number of tracks (default: 15, max: 50)
- user_id: User ID for preferences
"""
try:
limit = min(request.args.get('limit', 15, type=int), 50)
user_id = request.args.get('user_id', type=int)
# Get user preferences if available
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_top_tracks)
# Run async operation
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
tracks = loop.run_until_complete(
music_catalog_service.get_artist_top_tracks(artist_id, limit)
)
return jsonify({
'tracks': [track.__dict__ for track in tracks],
'total': len(tracks)
})
except Exception as e:
logger.error(f"Error getting artist top tracks: {e}")
return jsonify({'error': 'Failed to get artist top tracks'}), 500
@music_catalog_bp.route('/artist/<artist_id>/albums', methods=['GET'])
def get_artist_discography(artist_id: str):
"""
Get complete artist discography with albums
Query parameters:
- limit: Maximum number of albums (default: 20, max: 50)
- user_id: User ID for preferences
"""
try:
limit = min(request.args.get('limit', 20, type=int), 50)
user_id = request.args.get('user_id', type=int)
# Get user preferences if available
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_albums_per_artist)
# Run async operation
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
albums = loop.run_until_complete(
music_catalog_service.get_artist_discography(artist_id)
)
# Apply limit
albums = albums[:limit]
return jsonify({
'albums': [album.__dict__ for album in albums],
'total': len(albums)
})
except Exception as e:
logger.error(f"Error getting artist discography: {e}")
return jsonify({'error': 'Failed to get artist discography'}), 500
@music_catalog_bp.route('/artist/<artist_id>', methods=['GET'])
def get_artist_info(artist_id: str):
"""
Get comprehensive artist information including top tracks and albums
Query parameters:
- user_id: User ID for preferences
"""
try:
user_id = request.args.get('user_id', type=int)
# Run async operation
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
artist_info = loop.run_until_complete(
music_catalog_service.get_artist_info(artist_id)
)
if not artist_info:
return jsonify({'error': 'Artist not found'}), 404
return jsonify({
'spotify_id': artist_info.spotify_id,
'name': artist_info.name,
'image_url': artist_info.image_url,
'followers': artist_info.followers,
'popularity': artist_info.popularity,
'genres': artist_info.genres or [],
'top_tracks': [track.__dict__ for track in (artist_info.top_tracks or [])],
'albums': [album.__dict__ for album in (artist_info.albums or [])],
'related_artists': artist_info.related_artists or []
})
except Exception as e:
logger.error(f"Error getting artist info: {e}")
return jsonify({'error': 'Failed to get artist info'}), 500
@music_catalog_bp.route('/album/<album_id>', methods=['GET'])
def get_album_details(album_id: str):
"""
Get full album information with tracklist
Query parameters:
- user_id: User ID for preferences
"""
try:
user_id = request.args.get('user_id', type=int)
# Run async operation
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
album = loop.run_until_complete(
music_catalog_service.get_album_details(album_id)
)
if not album:
return jsonify({'error': 'Album not found'}), 404
return jsonify(album.__dict__)
except Exception as e:
logger.error(f"Error getting album details: {e}")
return jsonify({'error': 'Failed to get album details'}), 500
@music_catalog_bp.route('/search', methods=['POST'])
def search_catalog():
"""
Search across global music catalog
Request body:
{
"query": "search query",
"type": "all|tracks|albums|artists|playlists",
"limit": 20,
"user_id": 1
}
"""
try:
data = request.get_json()
if not data or not data.get('query'):
return jsonify({'error': 'Search query is required'}), 400
query = data['query'].strip()
search_type = data.get('type', 'all')
limit = min(data.get('limit', 20), 50) # Cap at 50
user_id = data.get('user_id')
# Validate search type
valid_types = ['all', 'tracks', 'albums', 'artists', 'playlists']
if search_type not in valid_types:
return jsonify({'error': f'Invalid search type. Must be one of: {valid_types}'}), 400
# Get user preferences if available
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_search_results)
# Run async search
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, search_type, limit)
)
return jsonify({
'tracks': [track.__dict__ for track in result.tracks],
'albums': [album.__dict__ for album in result.albums],
'artists': [artist.__dict__ for artist in result.artists],
'playlists': [playlist.__dict__ for playlist in result.playlists],
'total': result.total,
'query': result.query
})
except Exception as e:
logger.error(f"Error searching catalog: {e}")
return jsonify({'error': 'Failed to search catalog'}), 500
@music_catalog_bp.route('/trending', methods=['GET'])
def get_trending_content():
"""
Get trending/popular content from global catalog
Query parameters:
- type: "tracks|albums|artists" (default: "tracks")
- limit: Maximum results (default: 20, max: 50)
- user_id: User ID for preferences
"""
try:
content_type = request.args.get('type', 'tracks')
limit = min(request.args.get('limit', 20, type=int), 50)
user_id = request.args.get('user_id', type=int)
# Validate content type
valid_types = ['tracks', 'albums', 'artists']
if content_type not in valid_types:
return jsonify({'error': f'Invalid type. Must be one of: {valid_types}'}), 400
# Get user preferences if available
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_trending_results)
# For now, search for popular content with generic queries
# This could be enhanced with actual trending data from Spotify API
trending_queries = {
'tracks': 'popular hits 2024',
'albums': 'new releases 2024',
'artists': 'popular artists'
}
query = trending_queries.get(content_type, 'popular')
# Run async search
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(
music_catalog_service.search_global_catalog(query, content_type, limit)
)
# Return only the requested type
response = {
'type': content_type,
'total': len(getattr(result, content_type)),
'query': query
}
if content_type == 'tracks':
response['tracks'] = [track.__dict__ for track in result.tracks]
elif content_type == 'albums':
response['albums'] = [album.__dict__ for album in result.albums]
elif content_type == 'artists':
response['artists'] = [artist.__dict__ for artist in result.artists]
return jsonify(response)
except Exception as e:
logger.error(f"Error getting trending content: {e}")
return jsonify({'error': 'Failed to get trending content'}), 500
@music_catalog_bp.route('/recommendations', methods=['POST'])
def get_recommendations():
"""
Get personalized recommendations based on seeds
Request body:
{
"seed_artists": ["artist_id1", "artist_id2"],
"seed_tracks": ["track_id1", "track_id2"],
"seed_genres": ["rock", "pop"],
"limit": 20,
"user_id": 1
}
"""
try:
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
seed_artists = data.get('seed_artists', [])
seed_tracks = data.get('seed_tracks', [])
seed_genres = data.get('seed_genres', [])
limit = min(data.get('limit', 20), 50)
user_id = data.get('user_id')
# Validate at least one seed type
if not any([seed_artists, seed_tracks, seed_genres]):
return jsonify({'error': 'At least one seed type must be provided'}), 400
# Get user preferences if available
if user_id:
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
limit = min(limit, user_prefs.max_recommendations)
# For now, generate recommendations based on seed artists
# This could be enhanced with Spotify's recommendations API
recommendations = []
if seed_artists:
# Get top tracks from seed artists
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for artist_id in seed_artists[:5]: # Limit to 5 artists
try:
tracks = loop.run_until_complete(
music_catalog_service.get_artist_top_tracks(artist_id, 5)
)
recommendations.extend(tracks)
except Exception as e:
logger.warning(f"Failed to get tracks for artist {artist_id}: {e}")
# Remove duplicates and apply limit
seen_ids = set()
unique_recommendations = []
for track in recommendations:
if track.spotify_id not in seen_ids:
seen_ids.add(track.spotify_id)
unique_recommendations.append(track)
if len(unique_recommendations) >= limit:
break
return jsonify({
'tracks': [track.__dict__ for track in unique_recommendations],
'total': len(unique_recommendations),
'seeds': {
'artists': seed_artists,
'tracks': seed_tracks,
'genres': seed_genres
}
})
except Exception as e:
logger.error(f"Error getting recommendations: {e}")
return jsonify({'error': 'Failed to get recommendations'}), 500
@music_catalog_bp.route('/preferences/<int:user_id>', methods=['GET', 'POST'])
def user_catalog_preferences(user_id: int):
"""
Get or update user's catalog preferences
GET: Returns user preferences
POST: Updates user preferences
POST request body:
{
"max_search_results": 50,
"max_top_tracks": 15,
"max_albums_per_artist": 20,
"max_trending_results": 20,
"max_recommendations": 20,
"show_explicit": true,
"preferred_markets": ["US", "GB", "DE"]
}
"""
try:
if request.method == 'GET':
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
return jsonify({
'user_id': user_id,
'max_search_results': user_prefs.max_search_results,
'max_top_tracks': user_prefs.max_top_tracks,
'max_albums_per_artist': user_prefs.max_albums_per_artist,
'max_trending_results': user_prefs.max_trending_results,
'max_recommendations': user_prefs.max_recommendations,
'show_explicit': user_prefs.show_explicit,
'preferred_markets': user_prefs.preferred_markets or []
})
else: # POST
data = request.get_json()
if not data:
return jsonify({'error': 'Request body is required'}), 400
user_prefs = UserCatalogPreferencesTable.get_or_create(user_id)
# Update preferences
if 'max_search_results' in data:
user_prefs.max_search_results = min(data['max_search_results'], 100)
if 'max_top_tracks' in data:
user_prefs.max_top_tracks = min(data['max_top_tracks'], 50)
if 'max_albums_per_artist' in data:
user_prefs.max_albums_per_artist = min(data['max_albums_per_artist'], 100)
if 'max_trending_results' in data:
user_prefs.max_trending_results = min(data['max_trending_results'], 100)
if 'max_recommendations' in data:
user_prefs.max_recommendations = min(data['max_recommendations'], 100)
if 'show_explicit' in data:
user_prefs.show_explicit = bool(data['show_explicit'])
if 'preferred_markets' in data:
user_prefs.preferred_markets = data['preferred_markets']
user_prefs.save()
return jsonify({'message': 'Preferences updated successfully'})
except Exception as e:
logger.error(f"Error handling catalog preferences: {e}")
return jsonify({'error': 'Failed to handle preferences'}), 500
@music_catalog_bp.route('/cache/cleanup', methods=['POST'])
def cleanup_cache():
"""
Clean up expired cache entries
This is typically called by a background job
"""
try:
music_catalog_service.cleanup_expired_cache()
return jsonify({'message': 'Cache cleanup completed'})
except Exception as e:
logger.error(f"Error cleaning up cache: {e}")
return jsonify({'error': 'Failed to cleanup cache'}), 500
@music_catalog_bp.route('/health', methods=['GET'])
def health_check():
"""
Health check endpoint for the music catalog service
"""
try:
# Check if the service is accessible
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Simple test - try to get a popular artist's top tracks
# Using a well-known artist ID for testing
test_result = loop.run_until_complete(
music_catalog_service.get_artist_top_tracks("4q3ewHC7JlriWjjK2XsvrO", 1) # Daft Punk
)
return jsonify({
'status': 'healthy',
'service': 'music_catalog',
'test_query_success': len(test_result) > 0
})
except Exception as e:
logger.error(f"Health check failed: {e}")
return jsonify({
'status': 'unhealthy',
'service': 'music_catalog',
'error': str(e)
}), 500
+6 -2
View File
@@ -55,6 +55,10 @@ def search_lyrics(body: LyricsSearchBody):
if lrc is not None:
lyrics = Lyrics_class(lrc)
return {"trackhash": trackhash, "lyrics": lyrics.format_synced_lyrics()}, 200
if lyrics.is_synced:
formatted_lyrics = lyrics.format_synced_lyrics()
else:
formatted_lyrics = lyrics.format_unsynced_lyrics()
return {"trackhash": trackhash, "lyrics": formatted_lyrics, "synced": lyrics.is_synced}, 200
return {"trackhash": trackhash, "lyrics": lrc}, 200
return {"trackhash": trackhash, "lyrics": None, "synced": False}, 200
+435
View File
@@ -0,0 +1,435 @@
"""
Year-in-Review API Endpoints
This module provides REST API endpoints for the year-in-review experience,
including recap generation, summary retrieval, and video generation.
"""
import logging
from datetime import datetime
from typing import Dict, List, Optional, Any
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from swingmusic.db import db
from swingmusic.services.recap_service import recap_service, RecapTheme
from swingmusic.utils.request import APIError, success_response, error_response
logger = logging.getLogger(__name__)
recap_bp = Blueprint('recap', __name__, url_prefix='/api/recap')
def get_current_user_id() -> int:
"""Get current user ID from Flask-Login"""
return current_user.id if current_user.is_authenticated else None
@recap_bp.route('/generate/<int:year>', methods=['POST'])
@login_required
async def generate_recap(year: int):
"""
Generate year-in-review for a specific year
Path Parameters:
- year: Year to generate recap for
Query Parameters:
- force: Force regeneration even if recap exists (default: false)
"""
try:
user_id = get_current_user_id()
force_regeneration = request.args.get('force', 'false').lower() == 'true'
# Check if recap already exists
if not force_regeneration:
existing_recap = await recap_service.get_recap_summary(user_id, year)
if existing_recap:
return success_response({
'message': f'Recap for {year} already exists',
'recap': existing_recap
})
# Generate new recap
recap_data = await recap_service.generate_year_recap(user_id, year)
return success_response({
'message': f'Successfully generated recap for {year}',
'recap_id': f"{user_id}_{year}",
'year': recap_data.year,
'stats': {
'total_minutes': recap_data.stats.total_minutes,
'total_tracks': recap_data.stats.total_tracks,
'total_artists': recap_data.stats.total_artists,
'unique_tracks': recap_data.stats.unique_tracks,
'listening_streak': recap_data.stats.listening_streak,
'personality_type': recap_data.personality.personality_type
}
})
except Exception as e:
logger.error(f"Error generating recap: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/summary/<int:year>', methods=['GET'])
@login_required
async def get_recap_summary(year: int):
"""
Get recap summary for a specific year
Path Parameters:
- year: Year to get recap summary for
"""
try:
user_id = get_current_user_id()
recap_summary = await recap_service.get_recap_summary(user_id, year)
if not recap_summary:
return error_response(f"No recap found for year {year}", 404)
return success_response({
'recap': recap_summary
})
except Exception as e:
logger.error(f"Error getting recap summary: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/details/<int:year>', methods=['GET'])
@login_required
async def get_recap_details(year: int):
"""
Get detailed recap data for a specific year
Path Parameters:
- year: Year to get recap details for
Query Parameters:
- include_top_tracks: Include top tracks data (default: true)
- include_top_artists: Include top artists data (default: true)
- include_top_albums: Include top albums data (default: true)
- include_discoveries: Include discoveries data (default: true)
- include_milestones: Include milestones data (default: true)
"""
try:
user_id = get_current_user_id()
# Get recap summary first
recap_summary = await recap_service.get_recap_summary(user_id, year)
if not recap_summary:
return error_response(f"No recap found for year {year}", 404)
# Parse include flags
include_flags = {
'top_tracks': request.args.get('include_top_tracks', 'true').lower() == 'true',
'top_artists': request.args.get('include_top_artists', 'true').lower() == 'true',
'top_albums': request.args.get('include_top_albums', 'true').lower() == 'true',
'discoveries': request.args.get('include_discoveries', 'true').lower() == 'true',
'milestones': request.args.get('include_milestones', 'true').lower() == 'true'
}
# Load full recap data from file
import json
from pathlib import Path
recap_file = Path(recap_service.recap_dir) / f"recap_{user_id}_{year}.json"
if not recap_file.exists():
return error_response(f"Recap data not found for year {year}", 404)
with open(recap_file, 'r') as f:
full_recap_data = json.load(f)
# Build response based on include flags
response_data = {
'year': full_recap_data['year'],
'stats': full_recap_data['stats'],
'personality': full_recap_data['personality'],
'monthly_breakdown': full_recap_data['monthly_breakdown'],
'created_at': full_recap_data['created_at']
}
if include_flags['top_tracks']:
response_data['top_tracks'] = full_recap_data['top_tracks']
if include_flags['top_artists']:
response_data['top_artists'] = full_recap_data['top_artists']
if include_flags['top_albums']:
response_data['top_albums'] = full_recap_data['top_albums']
if include_flags['discoveries']:
response_data['discoveries'] = full_recap_data['discoveries']
if include_flags['milestones']:
response_data['milestones'] = full_recap_data['milestones']
return success_response({
'recap': response_data
})
except Exception as e:
logger.error(f"Error getting recap details: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/video/<int:year>', methods=['POST'])
@login_required
async def generate_recap_video(year: int):
"""
Generate recap video for a specific year
Path Parameters:
- year: Year to generate video for
Request Body:
{
"theme": "modern|retro|minimal|vibrant|dark|light",
"include_audio": true,
"duration_limit": 180 // Optional: max duration in seconds
}
"""
try:
user_id = get_current_user_id()
# Get request data
data = request.get_json() or {}
# Validate theme
theme_name = data.get('theme', 'modern')
try:
theme = RecapTheme(theme_name)
except ValueError:
return error_response(f"Invalid theme: {theme_name}. Must be one of: {[t.value for t in RecapTheme]}", 400)
# Check if recap exists
recap_summary = await recap_service.get_recap_summary(user_id, year)
if not recap_summary:
return error_response(f"No recap found for year {year}. Generate recap first.", 404)
# Generate video (this is a placeholder - would integrate with Remotion service)
video_path = await recap_service.generate_recap_video(
# This would need to load the full recap data
None, # recap_data would be loaded here
theme
)
return success_response({
'message': f'Video generation started for {year}',
'video_path': video_path,
'theme': theme.value,
'estimated_completion': '2-5 minutes'
})
except Exception as e:
logger.error(f"Error generating recap video: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/available-years', methods=['GET'])
@login_required
async def get_available_years():
"""
Get list of years for which recaps are available
"""
try:
user_id = get_current_user_id()
# Scan recap directory for user's recaps
import os
from pathlib import Path
recap_dir = Path(recap_service.recap_dir)
available_years = []
if recap_dir.exists():
for file_path in recap_dir.glob(f"recap_{user_id}_*.json"):
# Extract year from filename
parts = file_path.stem.split('_')
if len(parts) >= 3:
year = parts[2]
try:
year_int = int(year)
available_years.append(year_int)
except ValueError:
continue
# Sort years in descending order
available_years.sort(reverse=True)
return success_response({
'available_years': available_years,
'total_recaps': len(available_years)
})
except Exception as e:
logger.error(f"Error getting available years: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/share/<int:year>', methods=['POST'])
@login_required
async def create_shareable_link(year: int):
"""
Create a shareable link for recap
Path Parameters:
- year: Year to create shareable link for
Request Body:
{
"include_personal_data": false,
"expires_in_days": 30
}
"""
try:
user_id = get_current_user_id()
# Get request data
data = request.get_json() or {}
include_personal_data = data.get('include_personal_data', False)
expires_in_days = data.get('expires_in_days', 30)
# Check if recap exists
recap_summary = await recap_service.get_recap_summary(user_id, year)
if not recap_summary:
return error_response(f"No recap found for year {year}", 404)
# Generate shareable link (this is a placeholder implementation)
import secrets
import hashlib
# Generate unique token
token_data = f"{user_id}_{year}_{datetime.utcnow().timestamp()}"
share_token = hashlib.sha256(token_data.encode()).hexdigest()[:16]
# Create shareable data
shareable_data = {
'year': year,
'stats': {
'total_minutes': recap_summary['total_minutes'],
'total_tracks': recap_summary['total_tracks'],
'personality_type': recap_summary['personality_type']
},
'top_track': recap_summary.get('top_track'),
'top_artist': recap_summary.get('top_artist'),
'created_at': recap_summary['created_at']
}
# Save shareable data (in a real implementation, this would go to database)
share_file = Path(recap_service.recap_dir) / f"share_{share_token}.json"
import json
with open(share_file, 'w') as f:
json.dump({
'user_id': user_id,
'year': year,
'data': shareable_data,
'expires_at': (datetime.utcnow() + datetime.timedelta(days=expires_in_days)).isoformat(),
'created_at': datetime.utcnow().isoformat()
}, f)
share_url = f"/recap/shared/{share_token}"
return success_response({
'share_url': share_url,
'share_token': share_token,
'expires_in_days': expires_in_days,
'includes_personal_data': include_personal_data
})
except Exception as e:
logger.error(f"Error creating shareable link: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/shared/<token>', methods=['GET'])
async def get_shared_recap(token: str):
"""
Get shared recap by token (public endpoint)
Path Parameters:
- token: Share token
"""
try:
# Load share data
share_file = Path(recap_service.recap_dir) / f"share_{token}.json"
if not share_file.exists():
return error_response("Shared recap not found or expired", 404)
import json
with open(share_file, 'r') as f:
share_data = json.load(f)
# Check if expired
expires_at = datetime.fromisoformat(share_data['expires_at'])
if datetime.utcnow() > expires_at:
share_file.unlink() # Clean up expired share
return error_response("Shared recap has expired", 410)
return success_response({
'shared_recap': share_data['data'],
'year': share_data['year'],
'created_at': share_data['created_at']
})
except Exception as e:
logger.error(f"Error getting shared recap: {e}")
return error_response("Internal server error", 500)
@recap_bp.route('/compare/<int:year1>/<int:year2>', methods=['GET'])
@login_required
async def compare_years(year1: int, year2: int):
"""
Compare recaps between two years
Path Parameters:
- year1: First year to compare
- year2: Second year to compare
"""
try:
user_id = get_current_user_id()
# Get both recaps
recap1 = await recap_service.get_recap_summary(user_id, year1)
recap2 = await recap_service.get_recap_summary(user_id, year2)
if not recap1:
return error_response(f"No recap found for year {year1}", 404)
if not recap2:
return error_response(f"No recap found for year {year2}", 404)
# Calculate comparisons
comparison = {
'year1': year1,
'year2': year2,
'listening_time_change': {
'absolute': recap2['total_minutes'] - recap1['total_minutes'],
'percentage': ((recap2['total_minutes'] - recap1['total_minutes']) / recap1['total_minutes'] * 100) if recap1['total_minutes'] > 0 else 0
},
'tracks_change': {
'absolute': recap2['total_tracks'] - recap1['total_tracks'],
'percentage': ((recap2['total_tracks'] - recap1['total_tracks']) / recap1['total_tracks'] * 100) if recap1['total_tracks'] > 0 else 0
},
'personality_change': {
'from': recap1['personality_type'],
'to': recap2['personality_type'],
'changed': recap1['personality_type'] != recap2['personality_type']
}
}
return success_response({
'comparison': comparison,
'recap1': recap1,
'recap2': recap2
})
except Exception as e:
logger.error(f"Error comparing years: {e}")
return error_response("Internal server error", 500)
+426
View File
@@ -0,0 +1,426 @@
"""
Spotify Downloader API endpoints for SwingMusic
Provides REST API for Spotify URL downloading functionality
"""
from flask import Blueprint, request, jsonify
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
import asyncio
from swingmusic.services.spotify_downloader import spotify_downloader, DownloadSource
from swingmusic import logger
from swingmusic.utils import create_valid_filename
spotify_bp = APIBlueprint(
'spotify',
__name__,
url_prefix='/api/spotify',
abp_tag=Tag(name='Spotify', description='Spotify downloader operations')
)
class SpotifyURLRequest(BaseModel):
url: str = Field(..., description='Spotify URL (track, album, or playlist)')
quality: Optional[str] = Field('flac', description='Audio quality (flac, mp3_320, mp3_128)')
output_dir: Optional[str] = Field(None, description='Output directory (optional)')
class SpotifyMetadataResponse(BaseModel):
spotify_id: str
title: str
artist: str
album: str
duration_ms: int
image_url: str
release_date: str
track_number: int
total_tracks: int
is_explicit: bool
preview_url: Optional[str]
class DownloadItemResponse(BaseModel):
id: str
spotify_url: str
spotify_id: str
title: str
artist: str
album: str
duration_ms: int
image_url: str
quality: str
source: str
status: str
progress: int
file_path: Optional[str]
error_message: Optional[str]
created_at: float
started_at: Optional[float]
completed_at: Optional[float]
class QueueStatusResponse(BaseModel):
queue_length: int
active_downloads: int
pending_items: int
queue: List[DownloadItemResponse]
active: List[DownloadItemResponse]
history: List[DownloadItemResponse]
class ActionResponse(BaseModel):
success: bool
message: str
item_id: Optional[str] = None
@spotify_bp.post('/metadata', summary='Get Spotify metadata')
async def get_metadata(body: SpotifyURLRequest):
"""
Extract metadata from a Spotify URL without downloading
- **url**: Spotify URL for track, album, or playlist
- **quality**: Preferred audio quality (optional)
Returns metadata for the Spotify content.
"""
try:
metadata = await spotify_downloader.get_metadata(body.url)
if not metadata:
return jsonify({
'error': 'Invalid Spotify URL or failed to fetch metadata',
'success': False
}), 400
return jsonify({
'success': True,
'metadata': {
'spotify_id': metadata.spotify_id,
'title': metadata.title,
'artist': metadata.artist,
'album': metadata.album,
'duration_ms': metadata.duration_ms,
'image_url': metadata.image_url,
'release_date': metadata.release_date,
'track_number': metadata.track_number,
'total_tracks': metadata.total_tracks,
'is_explicit': metadata.is_explicit,
'preview_url': metadata.preview_url
}
})
except Exception as e:
logger.error(f"Error getting Spotify metadata: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.post('/download', summary='Download from Spotify URL')
async def download_from_url(body: SpotifyURLRequest):
"""
Add a Spotify URL to the download queue
- **url**: Spotify URL for track, album, or playlist
- **quality**: Audio quality preference (flac, mp3_320, mp3_128)
- **output_dir**: Custom output directory (optional)
Adds the item to the download queue and returns the download ID.
"""
try:
# Validate quality
valid_qualities = ['flac', 'mp3_320', 'mp3_128']
if body.quality not in valid_qualities:
return jsonify({
'error': f'Invalid quality. Must be one of: {", ".join(valid_qualities)}',
'success': False
}), 400
# Add to download queue
item_id = spotify_downloader.add_download(
spotify_url=body.url,
output_dir=body.output_dir,
quality=body.quality
)
if not item_id:
return jsonify({
'error': 'Failed to add download. Invalid URL or duplicate.',
'success': False
}), 400
return jsonify({
'success': True,
'message': 'Download added to queue',
'item_id': item_id
})
except Exception as e:
logger.error(f"Error adding download: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.get('/queue', summary='Get download queue status')
def get_queue_status():
"""
Get current status of the download queue
Returns information about queued items, active downloads, and history.
"""
try:
status = spotify_downloader.get_queue_status()
return jsonify({
'success': True,
'data': status
})
except Exception as e:
logger.error(f"Error getting queue status: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.post('/cancel/<item_id>', summary='Cancel download')
def cancel_download(item_id: str):
"""
Cancel a pending or active download
- **item_id**: ID of the download item to cancel
Returns success status of the cancellation.
"""
try:
success = spotify_downloader.cancel_download(item_id)
if success:
return jsonify({
'success': True,
'message': 'Download cancelled successfully'
})
else:
return jsonify({
'success': False,
'message': 'Download not found or cannot be cancelled'
}), 404
except Exception as e:
logger.error(f"Error cancelling download: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.post('/retry/<item_id>', summary='Retry failed download')
def retry_download(item_id: str):
"""
Retry a failed download
- **item_id**: ID of the failed download item to retry
Returns success status of the retry operation.
"""
try:
success = spotify_downloader.retry_download(item_id)
if success:
return jsonify({
'success': True,
'message': 'Download added to queue for retry'
})
else:
return jsonify({
'success': False,
'message': 'Download not found or cannot be retried'
}), 404
except Exception as e:
logger.error(f"Error retrying download: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.get('/sources', summary='Get available download sources')
def get_download_sources():
"""
Get list of available download sources and their status
Returns information about available download sources (Tidal, Qobuz, Amazon).
"""
try:
sources = []
for source in DownloadSource:
sources.append({
'name': source.value,
'display_name': source.value.title(),
'enabled': True, # In real implementation, check availability
'priority': list(DownloadSource).index(source)
})
return jsonify({
'success': True,
'sources': sources
})
except Exception as e:
logger.error(f"Error getting download sources: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.get('/qualities', summary='Get available audio qualities')
def get_audio_qualities():
"""
Get list of available audio qualities
Returns supported audio formats and quality options.
"""
try:
qualities = [
{
'id': 'flac',
'name': 'FLAC',
'description': 'Lossless audio quality',
'extension': 'flac',
'bitrate': 'Lossless'
},
{
'id': 'mp3_320',
'name': 'MP3 320kbps',
'description': 'High quality MP3',
'extension': 'mp3',
'bitrate': '320 kbps'
},
{
'id': 'mp3_128',
'name': 'MP3 128kbps',
'description': 'Standard quality MP3',
'extension': 'mp3',
'bitrate': '128 kbps'
}
]
return jsonify({
'success': True,
'qualities': qualities
})
except Exception as e:
logger.error(f"Error getting audio qualities: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.get('/history', summary='Get download history')
def get_download_history():
"""
Get download history
Returns paginated download history.
"""
try:
# Get query parameters
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 50))
status_filter = request.args.get('status', None)
# Get history from downloader
status = spotify_downloader.get_queue_status()
history = status.get('history', [])
# Apply status filter
if status_filter:
history = [item for item in history if item.get('status') == status_filter]
# Paginate
total = len(history)
start = (page - 1) * limit
end = start + limit
paginated_history = history[start:end]
return jsonify({
'success': True,
'data': {
'items': paginated_history,
'pagination': {
'page': page,
'limit': limit,
'total': total,
'pages': (total + limit - 1) // limit
}
}
})
except Exception as e:
logger.error(f"Error getting download history: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
@spotify_bp.delete('/clear-history', summary='Clear download history')
def clear_download_history():
"""
Clear download history
Removes all completed and failed downloads from history.
"""
try:
# Clear history in downloader
spotify_downloader.download_history.clear()
return jsonify({
'success': True,
'message': 'Download history cleared'
})
except Exception as e:
logger.error(f"Error clearing download history: {e}")
return jsonify({
'error': str(e),
'success': False
}), 500
# Error handlers
@spotify_bp.errorhandler(400)
def bad_request(error):
return jsonify({
'error': 'Bad request',
'message': str(error),
'success': False
}), 400
@spotify_bp.errorhandler(404)
def not_found(error):
return jsonify({
'error': 'Not found',
'message': str(error),
'success': False
}), 404
@spotify_bp.errorhandler(500)
def internal_error(error):
return jsonify({
'error': 'Internal server error',
'message': str(error),
'success': False
}), 500
+372
View File
@@ -0,0 +1,372 @@
"""
Spotify Downloader Settings API endpoints
"""
from flask import Blueprint, request, jsonify
from flask_openapi3 import APIBlueprint, Tag
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any
from swingmusic import logger
from swingmusic.config import UserConfig
spotify_settings_bp = APIBlueprint(
'spotify_settings',
__name__,
url_prefix='/api/settings/spotify',
abp_tag=Tag(name='Spotify Settings', description='Spotify downloader settings operations')
)
class SpotifySettingsRequest(BaseModel):
defaultQuality: str = Field('flac', description='Default download quality')
downloadFolder: Optional[str] = Field(None, description='Download folder path')
autoAddToLibrary: bool = Field(True, description='Auto-add downloads to library')
maxConcurrentDownloads: int = Field(3, description='Max concurrent downloads')
sources: Optional[list] = Field(None, description='Download sources configuration')
maxRetryAttempts: int = Field(3, description='Max retry attempts')
cleanupHistoryDays: int = Field(30, description='Auto-cleanup history days')
showExplicitWarning: bool = Field(True, description='Show explicit content warning')
class SpotifySettingsResponse(BaseModel):
success: bool
settings: Optional[Dict[str, Any]] = None
message: Optional[str] = None
# Default settings
DEFAULT_SETTINGS = {
'defaultQuality': 'flac',
'downloadFolder': '',
'autoAddToLibrary': True,
'maxConcurrentDownloads': 3,
'sources': [
{
'name': 'tidal',
'display_name': 'Tidal',
'enabled': True,
'priority': 1,
'config': {
'quality_preference': ['lossless', 'high', 'normal'],
'formats': ['flac', 'mp3']
}
},
{
'name': 'qobuz',
'display_name': 'Qobuz',
'enabled': True,
'priority': 2,
'config': {
'quality_preference': ['lossless', 'high', 'normal'],
'formats': ['flac', 'mp3']
}
},
{
'name': 'amazon',
'display_name': 'Amazon Music',
'enabled': False,
'priority': 3,
'config': {
'quality_preference': ['high', 'normal'],
'formats': ['mp3', 'aac']
}
}
],
'maxRetryAttempts': 3,
'cleanupHistoryDays': 30,
'showExplicitWarning': True
}
def get_spotify_settings():
"""Get Spotify downloader settings from config"""
try:
config = UserConfig()
spotify_settings = config.spotify_downloads if hasattr(config, 'spotify_downloads') else {}
# Merge with defaults
settings = {**DEFAULT_SETTINGS}
settings.update(spotify_settings)
return settings
except Exception as e:
logger.error(f"Error loading Spotify settings: {e}")
return DEFAULT_SETTINGS
def save_spotify_settings(settings_data: dict):
"""Save Spotify downloader settings to config"""
try:
config = UserConfig()
# Update only provided settings
current_settings = get_spotify_settings()
current_settings.update(settings_data)
# Save to config
config.spotify_downloads = current_settings
config.save()
logger.info("Spotify settings saved successfully")
return True
except Exception as e:
logger.error(f"Error saving Spotify settings: {e}")
return False
@spotify_settings_bp.get('/', summary='Get Spotify downloader settings')
def get_settings():
"""
Get current Spotify downloader settings
Returns all Spotify downloader configuration including:
- Default quality settings
- Download folder configuration
- Source priorities and enablement
- Advanced options
"""
try:
settings = get_spotify_settings()
return jsonify({
'success': True,
'settings': settings
})
except Exception as e:
logger.error(f"Error getting Spotify settings: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
@spotify_settings_bp.post('/', summary='Update Spotify downloader settings')
def update_settings(body: SpotifySettingsRequest):
"""
Update Spotify downloader settings
- **defaultQuality**: Default download quality (flac, mp3_320, mp3_128)
- **downloadFolder**: Custom download folder path
- **autoAddToLibrary**: Whether to auto-add downloads to library
- **maxConcurrentDownloads**: Maximum concurrent downloads (1-10)
- **sources**: Download sources configuration
- **maxRetryAttempts**: Maximum retry attempts for failed downloads
- **cleanupHistoryDays**: Days to keep download history (0 = disabled)
- **showExplicitWarning**: Show warning for explicit content
Updates the Spotify downloader configuration and saves to user settings.
"""
try:
# Validate inputs
if body.defaultQuality not in ['flac', 'mp3_320', 'mp3_128']:
return jsonify({
'success': False,
'message': 'Invalid quality setting'
}), 400
if not 1 <= body.maxConcurrentDownloads <= 10:
return jsonify({
'success': False,
'message': 'Max concurrent downloads must be between 1 and 10'
}), 400
if not 0 <= body.maxRetryAttempts <= 10:
return jsonify({
'success': False,
'message': 'Max retry attempts must be between 0 and 10'
}), 400
if not 0 <= body.cleanupHistoryDays <= 365:
return jsonify({
'success': False,
'message': 'Cleanup days must be between 0 and 365'
}), 400
# Prepare settings data
settings_data = {
'defaultQuality': body.defaultQuality,
'downloadFolder': body.downloadFolder,
'autoAddToLibrary': body.autoAddToLibrary,
'maxConcurrentDownloads': body.maxConcurrentDownloads,
'sources': body.sources,
'maxRetryAttempts': body.maxRetryAttempts,
'cleanupHistoryDays': body.cleanupHistoryDays,
'showExplicitWarning': body.showExplicitWarning
}
# Remove None values
settings_data = {k: v for k, v in settings_data.items() if v is not None}
# Save settings
if save_spotify_settings(settings_data):
return jsonify({
'success': True,
'message': 'Settings saved successfully'
})
else:
return jsonify({
'success': False,
'message': 'Failed to save settings'
}), 500
except Exception as e:
logger.error(f"Error updating Spotify settings: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
@spotify_settings_bp.post('/reset', summary='Reset Spotify settings to defaults')
def reset_settings():
"""
Reset all Spotify downloader settings to default values
Resets all Spotify downloader configuration to factory defaults.
"""
try:
if save_spotify_settings(DEFAULT_SETTINGS):
return jsonify({
'success': True,
'message': 'Settings reset to defaults',
'settings': DEFAULT_SETTINGS
})
else:
return jsonify({
'success': False,
'message': 'Failed to reset settings'
}), 500
except Exception as e:
logger.error(f"Error resetting Spotify settings: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
@spotify_settings_bp.delete('/queue', summary='Clear download queue')
def clear_queue():
"""
Clear the entire download queue
Removes all pending and active downloads from the queue.
"""
try:
from swingmusic.services.spotify_downloader import spotify_downloader
# Clear queue
spotify_downloader.download_queue.clear()
return jsonify({
'success': True,
'message': 'Download queue cleared'
})
except Exception as e:
logger.error(f"Error clearing download queue: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
@spotify_settings_bp.delete('/history', summary='Clear download history')
def clear_history():
"""
Clear the download history
Removes all completed and failed downloads from history.
"""
try:
from swingmusic.services.spotify_downloader import spotify_downloader
# Clear history
spotify_downloader.download_history.clear()
return jsonify({
'success': True,
'message': 'Download history cleared'
})
except Exception as e:
logger.error(f"Error clearing download history: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
@spotify_settings_bp.get('/sources', summary='Get available download sources')
def get_available_sources():
"""
Get list of available download sources
Returns information about supported download sources and their capabilities.
"""
try:
sources = [
{
'name': 'tidal',
'display_name': 'Tidal',
'description': 'High-quality FLAC downloads from Tidal',
'quality_options': ['lossless', 'high', 'normal'],
'formats': ['flac', 'mp3'],
'available': True,
'requires_auth': False,
'max_quality': 'lossless'
},
{
'name': 'qobuz',
'display_name': 'Qobuz',
'description': 'Alternative high-quality source with extensive catalog',
'quality_options': ['lossless', 'high', 'normal'],
'formats': ['flac', 'mp3'],
'available': True,
'requires_auth': True,
'max_quality': 'lossless'
},
{
'name': 'amazon',
'display_name': 'Amazon Music',
'description': 'Fallback source with wide availability',
'quality_options': ['high', 'normal'],
'formats': ['mp3', 'aac'],
'available': False, # Disabled by default
'requires_auth': True,
'max_quality': 'high'
}
]
return jsonify({
'success': True,
'sources': sources
})
except Exception as e:
logger.error(f"Error getting available sources: {e}")
return jsonify({
'success': False,
'message': str(e)
}), 500
# Error handlers
@spotify_settings_bp.errorhandler(400)
def bad_request(error):
return jsonify({
'error': 'Bad request',
'message': str(error),
'success': False
}), 400
@spotify_settings_bp.errorhandler(500)
def internal_error(error):
return jsonify({
'error': 'Internal server error',
'message': str(error),
'success': False
}), 500
+160 -7
View File
@@ -1,5 +1,5 @@
"""
Contains all the track routes.
Contains all the track routes with iOS compatibility enhancements.
"""
import os
@@ -18,6 +18,7 @@ from swingmusic.lib.trackslib import get_silence_paddings
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.files import guess_mime_type
from swingmusic.services.ios_audio_compatibility import ios_audio_manager
bp_tag = Tag(name="File", description="Audio files")
api = APIBlueprint("track", __name__, url_prefix="/file", abp_tags=[bp_tag])
@@ -54,11 +55,12 @@ class SendTrackFileQuery(BaseModel):
@api.get("/<trackhash>/legacy")
def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery):
"""
Get a playable audio file without Range support
Get a playable audio file without Range support (iOS compatible)
Returns a playable audio file that corresponds to the given filepath. Falls back to track hash if filepath is not found.
Automatically handles iOS compatibility by transcoding to supported formats when needed.
NOTE: Does not support range requests or transcoding.
NOTE: Does not support range requests or transcoding beyond iOS compatibility.
"""
requested_trackhash = path.trackhash.strip()
filepath = query.filepath.strip()
@@ -106,14 +108,165 @@ def send_track_file_legacy(path: TrackHashSchema, query: SendTrackFileQuery):
break
if track is not None:
audio_type = guess_mime_type(track.filepath)
return send_from_directory(
Path(track.filepath).parent,
Path(track.filepath).name,
# Detect iOS capabilities and handle compatibility
user_agent = request.headers.get('User-Agent', '')
ios_capabilities = ios_audio_manager.detect_ios_capabilities(user_agent)
# Create iOS-compatible audio source
audio_source = ios_audio_manager.create_ios_audio_source(
track.filepath,
ios_capabilities,
quality="high"
)
# Use the potentially transcoded file path
final_file_path = audio_source['file_path']
audio_type = audio_source['mime_type']
# Add iOS compatibility headers
response = send_from_directory(
Path(final_file_path).parent,
Path(final_file_path).name,
mimetype=audio_type,
conditional=True,
as_attachment=True,
)
# Add iOS-specific headers
if ios_capabilities.is_ios:
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Cache-Control'] = 'public, max-age=3600'
# Add transcoding info if applicable
if audio_source['needs_transcoding']:
response.headers['X-iOS-Transcoded'] = 'true'
response.headers['X-iOS-Original-Format'] = guess_mime_type(track.filepath)
response.headers['X-iOS-Target-Format'] = audio_source['format']
return response
return msg, 404
@api.get("/<trackhash>/ios")
def send_track_file_ios(path: TrackHashSchema, query: SendTrackFileQuery):
"""
Get a playable audio file optimized for iOS devices
Returns a playable audio file optimized for iOS compatibility with automatic transcoding.
Supports FLAC to ALAC/AAC conversion and proper MIME types for iOS Safari and other browsers.
iOS Features:
- Automatic FLAC to ALAC/AAC transcoding
- Proper MP4 container formatting
- iOS-compatible MIME types
- Optimized bitrate for mobile streaming
- Caching for transcoded files
"""
requested_trackhash = path.trackhash.strip()
filepath = query.filepath.strip()
msg = {"msg": "File Not Found"}
# prevent path traversal
if "/../" in filepath:
return {"msg": "Invalid filepath", "error": "Path traversal detected"}, 400
requested_filepath = Path(filepath).resolve()
# check if filepath is a child of any of the root dirs
for root_dir in UserConfig().rootDirs:
if root_dir == "$home":
root_dir = Path.home()
else:
root_dir = Path(root_dir).resolve()
if root_dir not in requested_filepath.parents:
return {
"msg": "Invalid filepath",
"error": "File not inside root directories",
}, 400
track = None
tracks = TrackStore.get_tracks_by_filepaths([filepath])
if len(tracks) > 0 and os.path.exists(tracks[0].filepath):
for t in tracks:
if os.path.exists(t.filepath) and t.trackhash == requested_trackhash:
track = t
break
else:
group = TrackStore.trackhashmap.get(requested_trackhash)
# When finding by trackhash, sort by bitrate
# and get the first track that exists
if group is not None:
tracks = sorted(group.tracks, key=lambda x: x.bitrate, reverse=True)
for t in tracks:
if os.path.exists(t.filepath):
track = t
break
if track is not None:
# Detect iOS capabilities
user_agent = request.headers.get('User-Agent', '')
ios_capabilities = ios_audio_manager.detect_ios_capabilities(user_agent)
# Determine quality based on query parameter or device capabilities
quality_map = {
'original': 'lossless',
'1411': 'lossless',
'1024': 'lossless',
'512': 'high',
'320': 'high',
'256': 'high',
'128': 'medium',
'96': 'low'
}
quality = quality_map.get(query.quality, 'high')
# Create iOS-optimized audio source
audio_source = ios_audio_manager.create_ios_audio_source(
track.filepath,
ios_capabilities,
quality=quality
)
# Use the potentially transcoded file path
final_file_path = audio_source['file_path']
audio_type = audio_source['mime_type']
# Create response with iOS-specific optimizations
response = send_from_directory(
Path(final_file_path).parent,
Path(final_file_path).name,
mimetype=audio_type,
conditional=True,
as_attachment=False, # Stream inline for iOS
)
# iOS-specific headers for optimal playback
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Cache-Control'] = 'public, max-age=7200' # 2 hours
response.headers['X-Content-Type-Options'] = 'nosniff'
# Add iOS compatibility information
if ios_capabilities.is_ios:
response.headers['X-iOS-Optimized'] = 'true'
response.headers['X-iOS-Device'] = 'iPhone' if 'iPhone' in user_agent else 'iPad' if 'iPad' in user_agent else 'iPod'
# Add transcoding information
if audio_source['needs_transcoding']:
response.headers['X-iOS-Transcoded'] = 'true'
response.headers['X-iOS-Original-Format'] = guess_mime_type(track.filepath)
response.headers['X-iOS-Target-Format'] = audio_source['format']
response.headers['X-iOS-Quality'] = quality
else:
response.headers['X-iOS-Transcoded'] = 'false'
response.headers['X-iOS-Native-Format'] = 'true'
return response
return msg, 404
+439
View File
@@ -0,0 +1,439 @@
"""
Universal Music Downloader API for SwingMusic
Supports multiple music streaming services for universal downloading
"""
from flask import Blueprint, request, jsonify
from typing import Dict, List, Any, Optional
import asyncio
from swingmusic.services.universal_music_downloader import universal_music_downloader, DownloadQuality
from swingmusic.services.universal_url_parser import universal_url_parser, MusicService
from swingmusic import logger
# Create blueprint
universal_downloader_bp = Blueprint('universal_downloader', __name__, url_prefix='/api/universal')
@universal_downloader_bp.route('/download', methods=['POST'])
def add_download():
"""
Add a download from any supported music service URL
Request body:
{
"url": "music service URL",
"quality": "lossless|high|medium|low",
"output_dir": "/path/to/output"
}
"""
try:
data = request.get_json()
if not data or not data.get('url'):
return jsonify({'error': 'URL is required'}), 400
url = data['url'].strip()
quality_str = data.get('quality', 'high')
output_dir = data.get('output_dir')
# Validate quality
try:
quality = DownloadQuality(quality_str)
except ValueError:
return jsonify({'error': f'Invalid quality: {quality_str}'}), 400
# Parse URL
parsed_url = universal_music_downloader.parse_url(url)
if not parsed_url:
return jsonify({'error': 'Unsupported URL format'}), 400
# Add to download queue
item_id = universal_music_downloader.add_download(url, quality, output_dir)
if item_id:
return jsonify({
'success': True,
'item_id': item_id,
'service': parsed_url.service.value,
'item_type': parsed_url.item_type,
'message': f'Added to download queue from {parsed_url.service.value}'
})
else:
return jsonify({'error': 'Failed to add download'}), 500
except Exception as e:
logger.error(f"Error adding download: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/metadata', methods=['POST'])
def get_metadata():
"""
Get metadata for any supported music service URL
Request body:
{
"url": "music service URL"
}
"""
try:
data = request.get_json()
if not data or not data.get('url'):
return jsonify({'error': 'URL is required'}), 400
url = data['url'].strip()
# Parse URL
parsed_url = universal_music_downloader.parse_url(url)
if not parsed_url:
return jsonify({'error': 'Unsupported URL format'}), 400
# Get metadata
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
metadata = loop.run_until_complete(universal_music_downloader.get_metadata(url))
finally:
loop.close()
if metadata:
return jsonify({
'success': True,
'service': metadata.service.value,
'service_id': metadata.service_id,
'item_type': parsed_url.item_type,
'title': metadata.title,
'artist': metadata.artist,
'album': metadata.album,
'duration_ms': metadata.duration_ms,
'image_url': metadata.image_url,
'release_date': metadata.release_date,
'explicit': metadata.explicit,
'preview_url': metadata.preview_url,
'genre': metadata.genre,
'original_url': metadata.original_url,
'download_urls': metadata.download_urls
})
else:
return jsonify({'error': 'Failed to get metadata'}), 404
except Exception as e:
logger.error(f"Error getting metadata: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/queue', methods=['GET'])
def get_queue_status():
"""Get current download queue status"""
try:
status = universal_music_downloader.get_queue_status()
return jsonify(status)
except Exception as e:
logger.error(f"Error getting queue status: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/queue/<item_id>/cancel', methods=['POST'])
def cancel_download(item_id: str):
"""Cancel a download"""
try:
success = universal_music_downloader.cancel_download(item_id)
if success:
return jsonify({'success': True, 'message': 'Download cancelled'})
else:
return jsonify({'error': 'Download not found or cannot be cancelled'}), 404
except Exception as e:
logger.error(f"Error cancelling download: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/queue/<item_id>/retry', methods=['POST'])
def retry_download(item_id: str):
"""Retry a failed download"""
try:
success = universal_music_downloader.retry_download(item_id)
if success:
return jsonify({'success': True, 'message': 'Download retry added to queue'})
else:
return jsonify({'error': 'Download not found or cannot be retried'}), 404
except Exception as e:
logger.error(f"Error retrying download: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/history', methods=['GET'])
def get_download_history():
"""
Get download history
Query parameters:
- limit: number of items (default 100)
- offset: offset for pagination (default 0)
- user_id: user ID for filtering (optional)
"""
try:
limit = min(int(request.args.get('limit', 100)), 500)
offset = int(request.args.get('offset', 0))
user_id = request.args.get('user_id')
if user_id:
user_id = int(user_id)
# Get history from universal downloader
# This would need to be implemented in the service
return jsonify({
'downloads': [],
'total': 0,
'limit': limit,
'offset': offset
})
except ValueError:
return jsonify({'error': 'Invalid parameters'}), 400
except Exception as e:
logger.error(f"Error getting download history: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/services', methods=['GET'])
def get_supported_services():
"""Get list of supported music services"""
try:
services = universal_music_downloader.get_supported_services()
return jsonify({
'services': services,
'total': len(services)
})
except Exception as e:
logger.error(f"Error getting supported services: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/services/<service_name>/enable', methods=['POST'])
def enable_service(service_name: str):
"""Enable a music service"""
try:
from swingmusic.db.spotify import UniversalDownloadSourceTable
# Update service in database
UniversalDownloadSourceTable.update_source(service_name, enabled=True)
return jsonify({
'success': True,
'message': f'{service_name} service enabled'
})
except Exception as e:
logger.error(f"Error enabling service: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/services/<service_name>/disable', methods=['POST'])
def disable_service(service_name: str):
"""Disable a music service"""
try:
from swingmusic.db.spotify import UniversalDownloadSourceTable
# Update service in database
UniversalDownloadSourceTable.update_source(service_name, enabled=False)
return jsonify({
'success': True,
'message': f'{service_name} service disabled'
})
except Exception as e:
logger.error(f"Error disabling service: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/services/<service_name>/config', methods=['GET', 'POST'])
def service_config(service_name: str):
"""Get or update service configuration"""
try:
from swingmusic.db.spotify import UniversalDownloadSourceTable
if request.method == 'GET':
source = UniversalDownloadSourceTable.get_by_service(service_name)
if not source:
return jsonify({'error': 'Service not found'}), 404
return jsonify({
'service': source.service,
'display_name': source.display_name,
'enabled': source.enabled,
'priority': source.priority,
'supported_types': source.supported_types,
'features': source.features,
'config': source.config
})
elif request.method == 'POST':
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
# Update only allowed fields
update_data = {}
allowed_fields = ['enabled', 'priority', 'supported_types', 'features', 'config']
for field in allowed_fields:
if field in data:
update_data[field] = data[field]
if update_data:
UniversalDownloadSourceTable.update_source(service_name, **update_data)
return jsonify({'success': True, 'message': 'Service configuration updated'})
except Exception as e:
logger.error(f"Error handling service config: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/validate-url', methods=['POST'])
def validate_url():
"""
Validate and parse a music service URL
Request body:
{
"url": "music service URL"
}
"""
try:
data = request.get_json()
if not data or not data.get('url'):
return jsonify({'error': 'URL is required'}), 400
url = data['url'].strip()
# Parse URL
parsed_url = universal_music_downloader.parse_url(url)
if parsed_url:
return jsonify({
'valid': True,
'service': parsed_url.service.value,
'item_type': parsed_url.item_type,
'id': parsed_url.id,
'metadata': parsed_url.metadata
})
else:
return jsonify({
'valid': False,
'error': 'Unsupported URL format'
})
except Exception as e:
logger.error(f"Error validating URL: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/statistics', methods=['GET'])
def get_statistics():
"""Get download statistics by service"""
try:
from swingmusic.db.spotify import UniversalDownloadTable
stats = UniversalDownloadTable.get_statistics()
return jsonify({
'statistics': stats,
'generated_at': logger.info(f"Statistics generated")
})
except Exception as e:
logger.error(f"Error getting statistics: {e}")
return jsonify({'error': 'Internal server error'}), 500
@universal_downloader_bp.route('/batch', methods=['POST'])
def batch_download():
"""
Add multiple URLs to download queue
Request body:
{
"urls": ["url1", "url2", "url3"],
"quality": "high",
"output_dir": "/path/to/output"
}
"""
try:
data = request.get_json()
if not data or not data.get('urls'):
return jsonify({'error': 'URLs array is required'}), 400
urls = data['urls']
quality_str = data.get('quality', 'high')
output_dir = data.get('output_dir')
if not isinstance(urls, list):
return jsonify({'error': 'URLs must be an array'}), 400
# Validate quality
try:
quality = DownloadQuality(quality_str)
except ValueError:
return jsonify({'error': f'Invalid quality: {quality_str}'}), 400
# Process each URL
results = []
for url in urls:
url = url.strip()
if not url:
continue
try:
# Parse URL
parsed_url = universal_music_downloader.parse_url(url)
if not parsed_url:
results.append({
'url': url,
'success': False,
'error': 'Unsupported URL format'
})
continue
# Add to download queue
item_id = universal_music_downloader.add_download(url, quality, output_dir)
if item_id:
results.append({
'url': url,
'success': True,
'item_id': item_id,
'service': parsed_url.service.value,
'item_type': parsed_url.item_type
})
else:
results.append({
'url': url,
'success': False,
'error': 'Failed to add to queue'
})
except Exception as e:
logger.error(f"Error processing URL {url}: {e}")
results.append({
'url': url,
'success': False,
'error': 'Processing error'
})
successful = sum(1 for r in results if r['success'])
failed = len(results) - successful
return jsonify({
'total': len(results),
'successful': successful,
'failed': failed,
'results': results
})
except Exception as e:
logger.error(f"Error in batch download: {e}")
return jsonify({'error': 'Internal server error'}), 500
def register_universal_downloader_api(app):
"""Register universal downloader API with Flask app"""
app.register_blueprint(universal_downloader_bp)
logger.info("Universal music downloader API registered")
+601
View File
@@ -0,0 +1,601 @@
"""
Update Tracking API Endpoints
This module provides REST API endpoints for the artist update tracking system,
including following artists, managing preferences, and getting updates.
"""
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Any
from flask import Blueprint, request, jsonify
from flask_login import login_required, current_user
from swingmusic.db import db
from swingmusic.services.update_tracker import update_tracker, FollowLevel, ReleaseType
from swingmusic.utils.request import APIError, success_response, error_response
from swingmusic.utils.validators import validate_spotify_id, validate_email
logger = logging.getLogger(__name__)
update_tracking_bp = Blueprint('update_tracking', __name__, url_prefix='/api/updates')
def get_current_user_id() -> int:
"""Get current user ID from Flask-Login"""
return current_user.id if current_user.is_authenticated else None
@update_tracking_bp.route('/follow-artist', methods=['POST'])
@login_required
async def follow_artist():
"""
Follow an artist for update tracking
Request Body:
{
"artist_id": "spotify_artist_id",
"artist_name": "Artist Name",
"follow_level": "followed|favorite|casual",
"auto_download": false,
"preferred_quality": "flac"
}
"""
try:
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate required fields
artist_id = data.get('artist_id')
artist_name = data.get('artist_name')
if not artist_id or not artist_name:
return error_response("artist_id and artist_name are required", 400)
if not validate_spotify_id(artist_id):
return error_response("Invalid artist ID format", 400)
# Validate follow level
follow_level = data.get('follow_level', 'followed')
if follow_level not in ['casual', 'followed', 'favorite']:
return error_response("Invalid follow level. Must be: casual, followed, or favorite", 400)
# Validate quality preference
preferred_quality = data.get('preferred_quality', 'flac')
if preferred_quality not in ['flac', 'mp3_320', 'mp3_256', 'aac']:
return error_response("Invalid quality preference", 400)
follow_data = {
'user_id': get_current_user_id(),
'artist_id': artist_id,
'artist_name': artist_name,
'follow_level': follow_level,
'auto_download': data.get('auto_download', False),
'preferred_quality': preferred_quality
}
success = await update_tracker.follow_artist(follow_data)
if success:
return success_response({
'message': f'Now following {artist_name}',
'artist_id': artist_id,
'follow_level': follow_level
})
else:
return error_response("Failed to follow artist", 500)
except Exception as e:
logger.error(f"Error following artist: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/unfollow-artist', methods=['POST'])
@login_required
async def unfollow_artist():
"""
Unfollow an artist
Request Body:
{
"artist_id": "spotify_artist_id"
}
"""
try:
data = request.get_json()
if not data or not data.get('artist_id'):
return error_response("artist_id is required", 400)
artist_id = data['artist_id']
if not validate_spotify_id(artist_id):
return error_response("Invalid artist ID format", 400)
success = await update_tracker.unfollow_artist(get_current_user_id(), artist_id)
if success:
return success_response({
'message': 'Artist unfollowed successfully',
'artist_id': artist_id
})
else:
return error_response("Failed to unfollow artist", 500)
except Exception as e:
logger.error(f"Error unfollowing artist: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/recent', methods=['GET'])
@login_required
async def get_recent_updates():
"""
Get recent updates for followed artists
Query Parameters:
- limit: Number of updates to return (default: 20, max: 100)
- offset: Offset for pagination (default: 0)
- release_type: Filter by release type (album, single, ep, compilation)
- unread_only: Only return unread updates (true/false)
"""
try:
limit = min(request.args.get('limit', 20, type=int), 100)
offset = request.args.get('offset', 0, type=int)
release_type = request.args.get('release_type')
unread_only = request.args.get('unread_only', 'false').lower() == 'true'
# Validate release type
if release_type and release_type not in ['album', 'single', 'ep', 'compilation']:
return error_response("Invalid release type", 400)
updates = await update_tracker.get_user_updates(
get_current_user_id(),
limit=limit,
offset=offset,
release_type=release_type,
unread_only=unread_only
)
return success_response({
'updates': updates,
'limit': limit,
'offset': offset,
'total': len(updates)
})
except Exception as e:
logger.error(f"Error getting recent updates: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/settings', methods=['GET'])
@login_required
async def get_settings():
"""
Get user's update tracking settings
"""
try:
settings = await update_tracker.get_user_settings(get_current_user_id())
return success_response(settings)
except Exception as e:
logger.error(f"Error getting settings: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/settings', methods=['POST'])
@login_required
async def update_settings():
"""
Update user's update tracking settings
Request Body:
{
"enable_artist_monitoring": true,
"check_frequency": "daily",
"auto_download_favorites": false,
"auto_download_followed": false,
"max_auto_downloads_per_week": 5,
"quality_preference": "flac",
"storage_limit_mb": 10240,
"notification_channels": {
"in_app": true,
"push": false,
"email": false,
"discord": false
},
"exclude_explicit": false,
"preferred_release_types": ["album", "ep", "single"]
}
"""
try:
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate settings
if 'check_frequency' in data and data['check_frequency'] not in ['hourly', 'daily', 'weekly']:
return error_response("Invalid check frequency", 400)
if 'quality_preference' in data and data['quality_preference'] not in ['flac', 'mp3_320', 'mp3_256', 'aac']:
return error_response("Invalid quality preference", 400)
if 'max_auto_downloads_per_week' in data:
max_downloads = data['max_auto_downloads_per_week']
if not isinstance(max_downloads, int) or max_downloads < 0 or max_downloads > 50:
return error_response("Invalid max auto downloads value", 400)
if 'storage_limit_mb' in data:
storage_limit = data['storage_limit_mb']
if not isinstance(storage_limit, int) or storage_limit < 100 or storage_limit > 102400:
return error_response("Invalid storage limit", 400)
success = await update_tracker.update_user_settings(get_current_user_id(), data)
if success:
return success_response({
'message': 'Settings updated successfully',
'settings': data
})
else:
return error_response("Failed to update settings", 500)
except Exception as e:
logger.error(f"Error updating settings: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/auto-download/<release_id>', methods=['POST'])
@login_required
async def auto_download_release(release_id):
"""
Trigger auto-download for a specific release
Path Parameters:
- release_id: Spotify release ID
"""
try:
if not validate_spotify_id(release_id):
return error_response("Invalid release ID format", 400)
success = await update_tracker.auto_download_release(get_current_user_id(), release_id)
if success:
return success_response({
'message': 'Download queued successfully',
'release_id': release_id
})
else:
return error_response("Failed to queue download", 500)
except Exception as e:
logger.error(f"Error auto-downloading release: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/stats', methods=['GET'])
@login_required
async def get_update_stats():
"""
Get user's update tracking statistics
"""
try:
stats = await update_tracker.get_user_stats(get_current_user_id())
return success_response(stats)
except Exception as e:
logger.error(f"Error getting stats: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/followed-artists', methods=['GET'])
@login_required
async def get_followed_artists():
"""
Get list of followed artists
Query Parameters:
- limit: Number of artists to return (default: 50, max: 200)
- offset: Offset for pagination (default: 0)
- follow_level: Filter by follow level (casual, followed, favorite)
"""
try:
limit = min(request.args.get('limit', 50, type=int), 200)
offset = request.args.get('offset', 0, type=int)
follow_level = request.args.get('follow_level')
# Validate follow level
if follow_level and follow_level not in ['casual', 'followed', 'favorite']:
return error_response("Invalid follow level", 400)
artists = await update_tracker.get_followed_artists(
get_current_user_id(),
limit=limit,
offset=offset,
follow_level=follow_level
)
return success_response({
'artists': artists,
'limit': limit,
'offset': offset,
'total': len(artists)
})
except Exception as e:
logger.error(f"Error getting followed artists: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/artist/<artist_id>/follow-status', methods=['GET'])
@login_required
async def get_artist_follow_status(artist_id):
"""
Get follow status for a specific artist
Path Parameters:
- artist_id: Spotify artist ID
"""
try:
if not validate_spotify_id(artist_id):
return error_response("Invalid artist ID format", 400)
status = await update_tracker.get_artist_follow_status(get_current_user_id(), artist_id)
if status:
return success_response(status)
else:
return success_response({
'is_following': False,
'artist_id': artist_id
})
except Exception as e:
logger.error(f"Error getting artist follow status: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/artist/<artist_id>', methods=['PUT'])
@login_required
async def update_artist_follow(artist_id):
"""
Update follow settings for an artist
Path Parameters:
- artist_id: Spotify artist ID
Request Body:
{
"follow_level": "followed|favorite|casual",
"auto_download": true,
"preferred_quality": "flac",
"notification_preferences": {
"in_app": true,
"push": false,
"email": false,
"discord": false
}
}
"""
try:
if not validate_spotify_id(artist_id):
return error_response("Invalid artist ID format", 400)
data = request.get_json()
if not data:
return error_response("Request body is required", 400)
# Validate follow level
if 'follow_level' in data and data['follow_level'] not in ['casual', 'followed', 'favorite']:
return error_response("Invalid follow level", 400)
# Validate quality preference
if 'preferred_quality' in data and data['preferred_quality'] not in ['flac', 'mp3_320', 'mp3_256', 'aac']:
return error_response("Invalid quality preference", 400)
success = await update_tracker.update_artist_follow(
get_current_user_id(),
artist_id,
data
)
if success:
return success_response({
'message': 'Artist follow settings updated',
'artist_id': artist_id,
'settings': data
})
else:
return error_response("Failed to update artist follow settings", 500)
except Exception as e:
logger.error(f"Error updating artist follow: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/release/<release_id>', methods=['GET'])
@login_required
async def get_release_details(release_id):
"""
Get details for a specific release update
Path Parameters:
- release_id: Spotify release ID
"""
try:
if not validate_spotify_id(release_id):
return error_response("Invalid release ID format", 400)
release = await update_tracker.get_release_details(get_current_user_id(), release_id)
if release:
return success_response(release)
else:
return error_response("Release not found", 404)
except Exception as e:
logger.error(f"Error getting release details: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/release/<release_id>/mark-read', methods=['POST'])
@login_required
async def mark_release_read(release_id):
"""
Mark a release update as read
Path Parameters:
- release_id: Spotify release ID
"""
try:
if not validate_spotify_id(release_id):
return error_response("Invalid release ID format", 400)
success = await update_tracker.mark_release_read(get_current_user_id(), release_id)
if success:
return success_response({
'message': 'Release marked as read',
'release_id': release_id
})
else:
return error_response("Failed to mark release as read", 500)
except Exception as e:
logger.error(f"Error marking release as read: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/notifications', methods=['GET'])
@login_required
async def get_notifications():
"""
Get user's update notifications
Query Parameters:
- limit: Number of notifications to return (default: 20, max: 100)
- offset: Offset for pagination (default: 0)
- unread_only: Only return unread notifications (true/false)
"""
try:
limit = min(request.args.get('limit', 20, type=int), 100)
offset = request.args.get('offset', 0, type=int)
unread_only = request.args.get('unread_only', 'false').lower() == 'true'
notifications = await update_tracker.get_notifications(
get_current_user_id(),
limit=limit,
offset=offset,
unread_only=unread_only
)
return success_response({
'notifications': notifications,
'limit': limit,
'offset': offset,
'total': len(notifications)
})
except Exception as e:
logger.error(f"Error getting notifications: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/notifications/mark-all-read', methods=['POST'])
@login_required
async def mark_all_notifications_read():
"""
Mark all notifications as read for the user
"""
try:
success = await update_tracker.mark_all_notifications_read(get_current_user_id())
if success:
return success_response({
'message': 'All notifications marked as read'
})
else:
return error_response("Failed to mark notifications as read", 500)
except Exception as e:
logger.error(f"Error marking all notifications as read: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/search/artists', methods=['GET'])
@login_required
async def search_artists_to_follow():
"""
Search for artists to follow
Query Parameters:
- q: Search query
- limit: Number of results to return (default: 10, max: 50)
"""
try:
query = request.args.get('q')
if not query:
return error_response("Search query is required", 400)
limit = min(request.args.get('limit', 10, type=int), 50)
artists = await update_tracker.search_artists(query, limit)
return success_response({
'artists': artists,
'query': query,
'limit': limit,
'total': len(artists)
})
except Exception as e:
logger.error(f"Error searching artists: {e}")
return error_response("Internal server error", 500)
@update_tracking_bp.route('/export/followed-artists', methods=['GET'])
@login_required
async def export_followed_artists():
"""
Export followed artists as JSON or CSV
Query Parameters:
- format: Export format (json|csv) - default: json
"""
try:
export_format = request.args.get('format', 'json').lower()
if export_format not in ['json', 'csv']:
return error_response("Invalid export format. Must be json or csv", 400)
data = await update_tracker.export_followed_artists(get_current_user_id(), export_format)
if export_format == 'csv':
from flask import Response
return Response(
data,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=followed_artists.csv'}
)
else:
return success_response({'followed_artists': data})
except Exception as e:
logger.error(f"Error exporting followed artists: {e}")
return error_response("Internal server error", 500)
# Error handlers
@update_tracking_bp.errorhandler(404)
def not_found(error):
return error_response("Endpoint not found", 404)
@update_tracking_bp.errorhandler(500)
def internal_error(error):
return error_response("Internal server error", 500)
+392
View File
@@ -0,0 +1,392 @@
"""
Contains all the file upload routes for manual music upload functionality.
"""
import os
import shutil
import pathlib
from pathlib import Path
from datetime import datetime
from typing import List, Optional
import tempfile
import mimetypes
from flask import request, jsonify
from flask_openapi3 import Tag
from pydantic import BaseModel, Field
from flask_openapi3 import APIBlueprint
from werkzeug.utils import secure_filename
from swingmusic import settings
from swingmusic.config import UserConfig
from swingmusic.db.libdata import TrackTable
from swingmusic.api.auth import admin_required
from swingmusic.store.tracks import TrackStore
from swingmusic.utils.metadata import extract_metadata
from swingmusic.serializers.track import serialize_track
tag = Tag(name="Upload", description="Manual music file upload functionality")
api = APIBlueprint("upload", __name__, url_prefix="/upload", abp_tags=[tag])
# Allowed audio file extensions
ALLOWED_EXTENSIONS = {
'mp3', 'flac', 'wav', 'aac', 'm4a', 'ogg', 'wma', 'opus',
'aiff', 'au', 'ra', '3gp', 'amr', 'awb', 'dct', 'dvf',
'm4p', 'mmf', 'mpc', 'msv', 'nmf', 'nsf', 'ogg', 'qcp',
'ra', 'rm', 'sln', 'vox', 'wma', 'wv'
}
# Maximum file size (100MB)
MAX_FILE_SIZE = 100 * 1024 * 1024
def is_allowed_file(filename: str) -> bool:
"""Check if file has an allowed audio extension."""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def is_path_within_root_dirs(filepath: str) -> bool:
"""
Check if a filepath is within one of the configured root directories.
Prevents directory traversal attacks.
"""
config = UserConfig()
resolved_path = Path(filepath).resolve()
for root_dir in config.rootDirs:
if root_dir == "$home":
root_path = Path.home().resolve()
else:
root_path = Path(root_dir).resolve()
# Check if resolved_path is the root or a child of root
if resolved_path == root_path or root_path in resolved_path.parents:
return True
return False
class UploadResponse(BaseModel):
success: bool = Field(description="Whether the upload was successful")
message: str = Field(description="Status message")
track_id: Optional[str] = Field(None, description="ID of the added track")
filename: Optional[str] = Field(None, description="Name of the uploaded file")
class BatchUploadResponse(BaseModel):
success: bool = Field(description="Whether the batch upload was successful")
message: str = Field(description="Status message")
uploaded_files: List[UploadResponse] = Field(description="List of upload results")
failed_files: List[str] = Field(description="List of failed files")
@api.post("/single")
@admin_required()
def upload_single_file():
"""
Upload a single music file
Uploads a single music file to the configured music folder and adds it to the library.
Supports drag-and-drop and file selection.
"""
try:
if 'file' not in request.files:
return jsonify({
"success": False,
"message": "No file provided"
}), 400
file = request.files['file']
if file.filename == '':
return jsonify({
"success": False,
"message": "No file selected"
}), 400
# Check file extension
if not is_allowed_file(file.filename):
return jsonify({
"success": False,
"message": f"File type not allowed. Supported formats: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
}), 400
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
return jsonify({
"success": False,
"message": f"File too large. Maximum size is {MAX_FILE_SIZE // (1024*1024)}MB"
}), 400
# Get upload directory from settings or use first root directory
config = UserConfig()
upload_dir = None
# Check if there's a specific upload directory configured
if hasattr(config, 'uploadDir') and config.uploadDir:
upload_dir = Path(config.uploadDir)
else:
# Use the first root directory as default
if config.rootDirs:
first_root = config.rootDirs[0]
if first_root == "$home":
upload_dir = Path.home() / "Music"
else:
upload_dir = Path(first_root)
else:
# Fallback to user's Music directory
upload_dir = Path.home() / "Music"
# Ensure upload directory exists
upload_dir.mkdir(parents=True, exist_ok=True)
# Secure the filename and create full path
filename = secure_filename(file.filename)
file_path = upload_dir / filename
# Handle filename conflicts
counter = 1
original_filename = filename
while file_path.exists():
name, ext = os.path.splitext(original_filename)
filename = f"{name}_{counter}{ext}"
file_path = upload_dir / filename
counter += 1
# Save the file
file.save(file_path)
# Extract metadata and add to library
try:
# This would trigger a library rescan for the specific file
# For now, we'll return the file info and let the frontend handle the refresh
track_info = {
"filepath": str(file_path),
"filename": filename,
"size": file_size
}
return jsonify({
"success": True,
"message": f"File '{filename}' uploaded successfully",
"filename": filename,
"filepath": str(file_path),
"track_info": track_info
})
except Exception as e:
# If metadata extraction fails, still return success for the upload
return jsonify({
"success": True,
"message": f"File '{filename}' uploaded successfully (metadata extraction failed)",
"filename": filename,
"filepath": str(file_path),
"warning": f"Metadata extraction failed: {str(e)}"
})
except Exception as e:
return jsonify({
"success": False,
"message": f"Upload failed: {str(e)}"
}), 500
@api.post("/batch")
@admin_required()
def upload_multiple_files():
"""
Upload multiple music files
Uploads multiple music files to the configured music folder and adds them to the library.
Supports drag-and-drop of multiple files.
"""
try:
if 'files' not in request.files:
return jsonify({
"success": False,
"message": "No files provided"
}), 400
files = request.files.getlist('files')
if not files:
return jsonify({
"success": False,
"message": "No files selected"
}), 400
uploaded_files = []
failed_files = []
# Get upload directory (same logic as single upload)
config = UserConfig()
upload_dir = None
if hasattr(config, 'uploadDir') and config.uploadDir:
upload_dir = Path(config.uploadDir)
else:
if config.rootDirs:
first_root = config.rootDirs[0]
if first_root == "$home":
upload_dir = Path.home() / "Music"
else:
upload_dir = Path(first_root)
else:
upload_dir = Path.home() / "Music"
upload_dir.mkdir(parents=True, exist_ok=True)
for file in files:
if file.filename == '':
continue
try:
# Check file extension
if not is_allowed_file(file.filename):
failed_files.append(f"{file.filename} - File type not allowed")
continue
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
failed_files.append(f"{file.filename} - File too large")
continue
# Secure filename and handle conflicts
filename = secure_filename(file.filename)
file_path = upload_dir / filename
counter = 1
original_filename = filename
while file_path.exists():
name, ext = os.path.splitext(original_filename)
filename = f"{name}_{counter}{ext}"
file_path = upload_dir / filename
counter += 1
# Save the file
file.save(file_path)
uploaded_files.append({
"success": True,
"message": f"File '{filename}' uploaded successfully",
"filename": filename,
"filepath": str(file_path),
"size": file_size
})
except Exception as e:
failed_files.append(f"{file.filename} - {str(e)}")
total_files = len(uploaded_files) + len(failed_files)
success_count = len(uploaded_files)
return jsonify({
"success": len(uploaded_files) > 0,
"message": f"Uploaded {success_count} of {total_files} files",
"uploaded_files": uploaded_files,
"failed_files": failed_files
})
except Exception as e:
return jsonify({
"success": False,
"message": f"Batch upload failed: {str(e)}"
}), 500
@api.get("/config")
def get_upload_config():
"""
Get upload configuration
Returns the current upload configuration including allowed file types,
maximum file size, and upload directory.
"""
config = UserConfig()
# Determine upload directory
upload_dir = None
if hasattr(config, 'uploadDir') and config.uploadDir:
upload_dir = config.uploadDir
elif config.rootDirs:
first_root = config.rootDirs[0]
if first_root == "$home":
upload_dir = str(Path.home() / "Music")
else:
upload_dir = first_root
else:
upload_dir = str(Path.home() / "Music")
return jsonify({
"allowed_extensions": sorted(list(ALLOWED_EXTENSIONS)),
"max_file_size": MAX_FILE_SIZE,
"max_file_size_mb": MAX_FILE_SIZE // (1024 * 1024),
"upload_directory": upload_dir,
"supported_formats": [
{"ext": ext, "description": get_format_description(ext)}
for ext in sorted(ALLOWED_EXTENSIONS)
]
})
def get_format_description(extension: str) -> str:
"""Get a user-friendly description for a file format."""
descriptions = {
'mp3': 'MP3 Audio',
'flac': 'FLAC Lossless Audio',
'wav': 'WAV Audio',
'aac': 'AAC Audio',
'm4a': 'M4A Audio',
'ogg': 'OGG Vorbis Audio',
'wma': 'WMA Audio',
'opus': 'Opus Audio',
'aiff': 'AIFF Audio',
'au': 'AU Audio',
'ra': 'RealAudio',
'3gp': '3GP Audio',
'amr': 'AMR Audio',
'awb': 'AWB Audio',
'dct': 'DCT Audio',
'dvf': 'DVF Audio',
'm4p': 'M4P Audio',
'mmf': 'MMF Audio',
'mpc': 'MPC Audio',
'msv': 'MSV Audio',
'nmf': 'NMF Audio',
'nsf': 'NSF Audio',
'qcp': 'QCP Audio',
'rm': 'RealMedia Audio',
'sln': 'SLN Audio',
'vox': 'VOX Audio',
'wv': 'WavPack Audio'
}
return descriptions.get(extension.lower(), f'{extension.upper()} Audio')
@api.post("/rescan")
@admin_required()
def trigger_library_rescan():
"""
Trigger library rescan
Triggers a library rescan to detect newly uploaded files.
"""
try:
# This would integrate with the existing library scanning system
# For now, return a success response
return jsonify({
"success": True,
"message": "Library rescan triggered successfully"
})
except Exception as e:
return jsonify({
"success": False,
"message": f"Failed to trigger library rescan: {str(e)}"
}), 500
+27
View File
@@ -86,6 +86,33 @@ def load_endpoints(web: OpenAPI):
# Auth
web.register_api(swing_api.auth.api)
# Spotify Downloader
web.register_api(swing_api.spotify.api)
web.register_api(swing_api.spotify_settings.api)
# Enhanced Search
from swingmusic.api.enhanced_search import register_enhanced_search_api
register_enhanced_search_api(web)
# Universal Music Downloader
from swingmusic.api.universal_downloader import register_universal_downloader_api
register_universal_downloader_api(web)
# Update Tracking
web.register_blueprint(swing_api.update_tracking.update_tracking_bp)
# Audio Quality Management
web.register_blueprint(swing_api.audio_quality.audio_quality_bp)
# Music Catalog Service
web.register_blueprint(swing_api.music_catalog.music_catalog_bp)
# Advanced UX Service
web.register_blueprint(swing_api.advanced_ux.advanced_ux_bp)
# Mobile Offline Service
web.register_blueprint(swing_api.mobile_offline.mobile_offline_bp)
def load_plugins(web: OpenAPI):
# TODO: rework plugin support
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:48:01.898787+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"}
{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:48:34.157364+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"}
{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:48:45.836758+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"}
{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:48:55.986103+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"}
{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:49:07.424983+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"}
{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:49:30.754157+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"}
{"level": "WARNING", "message": "Spotify client credentials not configured, using demo mode", "timestamp": "2026-03-17T12:49:52.059294+00:00", "logger": "swingmusic.logger", "module": "spotify_metadata_client", "function": "__init__", "line": 92, "args": [], "who": "swingmusic.logger"}
@@ -0,0 +1,344 @@
"""
Migration for Update Tracking System Tables
This migration creates all the necessary tables for the artist update
tracking system, including follows, releases, notifications, and preferences.
"""
import logging
from swingmusic.db import db
from swingmusic.migrations.base import Migration
logger = logging.getLogger(__name__)
class Migration001UpdateTracking(Migration):
"""
Create tables for the update tracking system
"""
@staticmethod
def migrate():
"""
Create all update tracking tables
"""
logger.info("Starting update tracking migration")
try:
# Create artist_follows table
logger.info("Creating artist_follows table")
db.session.execute("""
CREATE TABLE IF NOT EXISTS artist_follows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
artist_id TEXT NOT NULL UNIQUE,
artist_name TEXT NOT NULL,
follow_level TEXT NOT NULL DEFAULT 'followed',
auto_download_new_releases BOOLEAN DEFAULT FALSE,
preferred_quality TEXT DEFAULT 'flac',
notification_preferences TEXT DEFAULT '{}',
follow_date DATETIME DEFAULT CURRENT_TIMESTAMP,
last_check_date DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users (id)
)
""")
# Create release_updates table
logger.info("Creating release_updates table")
db.session.execute("""
CREATE TABLE IF NOT EXISTS release_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
release_id TEXT NOT NULL UNIQUE,
artist_id TEXT NOT NULL,
artist_name TEXT NOT NULL,
release_title TEXT NOT NULL,
release_type TEXT NOT NULL,
release_date DATE NOT NULL,
spotify_url TEXT NOT NULL,
cover_image_url TEXT NULL,
total_tracks INTEGER NOT NULL,
popularity INTEGER DEFAULT 0,
explicit BOOLEAN DEFAULT FALSE,
discovered_at DATETIME DEFAULT CURRENT_TIMESTAMP,
processed_at DATETIME NULL,
download_status TEXT DEFAULT 'pending',
auto_downloaded BOOLEAN DEFAULT FALSE,
notification_sent BOOLEAN DEFAULT FALSE
)
""")
# Create update_notifications table
logger.info("Creating update_notifications table")
db.session.execute("""
CREATE TABLE IF NOT EXISTS update_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
release_id TEXT NOT NULL,
notification_type TEXT NOT NULL,
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
opened_at DATETIME NULL,
action_taken TEXT NULL,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (release_id) REFERENCES release_updates (release_id)
)
""")
# Create update_monitoring_preferences table
logger.info("Creating update_monitoring_preferences table")
db.session.execute("""
CREATE TABLE IF NOT EXISTS update_monitoring_preferences (
user_id INTEGER PRIMARY KEY,
enable_artist_monitoring BOOLEAN DEFAULT TRUE,
check_frequency TEXT DEFAULT 'daily',
auto_download_favorites BOOLEAN DEFAULT FALSE,
auto_download_followed BOOLEAN DEFAULT FALSE,
max_auto_downloads_per_week INTEGER DEFAULT 5,
quality_preference TEXT DEFAULT 'flac',
storage_limit_mb INTEGER DEFAULT 10240,
notification_channels TEXT DEFAULT '{}',
exclude_explicit BOOLEAN DEFAULT FALSE,
preferred_release_types TEXT DEFAULT '["album", "ep", "single"]',
FOREIGN KEY (user_id) REFERENCES users (id)
)
""")
# Create download_tasks table
logger.info("Creating download_tasks table")
db.session.execute("""
CREATE TABLE IF NOT EXISTS download_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
release_id TEXT NOT NULL,
track_id TEXT NOT NULL,
track_title TEXT NOT NULL,
artist_name TEXT NOT NULL,
album_name TEXT NOT NULL,
spotify_url TEXT NOT NULL,
quality_preference TEXT DEFAULT 'flac',
status TEXT DEFAULT 'pending',
priority TEXT DEFAULT 'normal',
progress INTEGER DEFAULT 0,
file_path TEXT NULL,
error_message TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
started_at DATETIME NULL,
completed_at DATETIME NULL,
auto_downloaded BOOLEAN DEFAULT FALSE,
added_to_library BOOLEAN DEFAULT FALSE,
FOREIGN KEY (release_id) REFERENCES release_updates (release_id)
)
""")
# Create artist_follow_history table
logger.info("Creating artist_follow_history table")
db.session.execute("""
CREATE TABLE IF NOT EXISTS artist_follow_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
artist_id TEXT NOT NULL,
artist_name TEXT NOT NULL,
action TEXT NOT NULL,
old_level TEXT NULL,
new_level TEXT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
)
""")
# Create release_update_history table
logger.info("Creating release_update_history table")
db.session.execute("""
CREATE TABLE IF NOT EXISTS release_update_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
release_id TEXT NOT NULL,
artist_id TEXT NOT NULL,
artist_name TEXT NOT NULL,
release_title TEXT NOT NULL,
release_type TEXT NOT NULL,
action TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
metadata TEXT NULL
)
""")
# Create update_tracking_stats table
logger.info("Creating update_tracking_stats table")
db.session.execute("""
CREATE TABLE IF NOT EXISTS update_tracking_stats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
stat_date DATE NOT NULL,
total_followed_artists INTEGER DEFAULT 0,
new_releases_discovered INTEGER DEFAULT 0,
auto_downloads_completed INTEGER DEFAULT 0,
manual_downloads_completed INTEGER DEFAULT 0,
notifications_sent INTEGER DEFAULT 0,
notifications_opened INTEGER DEFAULT 0,
storage_used_mb INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users (id),
UNIQUE(user_id, stat_date)
)
""")
# Create indexes for better performance
logger.info("Creating indexes")
# Indexes for artist_follows
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_artist_follows_user_id ON artist_follows(user_id)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_artist_follows_artist_id ON artist_follows(artist_id)
""")
# Indexes for release_updates
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_release_updates_artist_id ON release_updates(artist_id)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_release_updates_release_date ON release_updates(release_date)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_release_updates_discovered_at ON release_updates(discovered_at)
""")
# Indexes for update_notifications
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_update_notifications_user_id ON update_notifications(user_id)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_update_notifications_release_id ON update_notifications(release_id)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_update_notifications_sent_at ON update_notifications(sent_at)
""")
# Indexes for download_tasks
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_download_tasks_release_id ON download_tasks(release_id)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_download_tasks_status ON download_tasks(status)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_download_tasks_priority ON download_tasks(priority)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_download_tasks_created_at ON download_tasks(created_at)
""")
# Indexes for history tables
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_artist_follow_history_user_id ON artist_follow_history(user_id)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_artist_follow_history_timestamp ON artist_follow_history(timestamp)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_release_update_history_release_id ON release_update_history(release_id)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_release_update_history_timestamp ON release_update_history(timestamp)
""")
# Indexes for stats
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_update_tracking_stats_user_id ON update_tracking_stats(user_id)
""")
db.session.execute("""
CREATE INDEX IF NOT EXISTS idx_update_tracking_stats_stat_date ON update_tracking_stats(stat_date)
""")
# Commit the transaction
db.session.commit()
logger.info("Update tracking migration completed successfully")
except Exception as e:
logger.error(f"Error during update tracking migration: {e}")
db.session.rollback()
raise
class Migration002UpdateTrackingTriggers(Migration):
"""
Create triggers for update tracking system
"""
@staticmethod
def migrate():
"""
Create triggers for automatic history tracking
"""
logger.info("Creating update tracking triggers")
try:
# Trigger for artist follow history
db.session.execute("""
CREATE TRIGGER IF NOT EXISTS artist_follow_history_insert
AFTER INSERT ON artist_follows
BEGIN
INSERT INTO artist_follow_history
(user_id, artist_id, artist_name, action, new_level, timestamp)
VALUES
(NEW.user_id, NEW.artist_id, NEW.artist_name, 'follow', NEW.follow_level, CURRENT_TIMESTAMP);
END
""")
# Trigger for artist unfollow history
db.session.execute("""
CREATE TRIGGER IF NOT EXISTS artist_follow_history_delete
AFTER DELETE ON artist_follows
BEGIN
INSERT INTO artist_follow_history
(user_id, artist_id, artist_name, action, old_level, timestamp)
VALUES
(OLD.user_id, OLD.artist_id, OLD.artist_name, 'unfollow', OLD.follow_level, CURRENT_TIMESTAMP);
END
""")
# Trigger for artist follow level change
db.session.execute("""
CREATE TRIGGER IF NOT EXISTS artist_follow_history_update
AFTER UPDATE ON artist_follows
WHEN OLD.follow_level != NEW.follow_level
BEGIN
INSERT INTO artist_follow_history
(user_id, artist_id, artist_name, action, old_level, new_level, timestamp)
VALUES
(NEW.user_id, NEW.artist_id, NEW.artist_name, 'level_change', OLD.follow_level, NEW.follow_level, CURRENT_TIMESTAMP);
END
""")
# Trigger for release update discovery
db.session.execute("""
CREATE TRIGGER IF NOT EXISTS release_update_discovered
AFTER INSERT ON release_updates
BEGIN
INSERT INTO release_update_history
(release_id, artist_id, artist_name, release_title, release_type, action, timestamp)
VALUES
(NEW.release_id, NEW.artist_id, NEW.artist_name, NEW.release_title, NEW.release_type, 'discovered', CURRENT_TIMESTAMP);
END
""")
# Trigger for release update download completion
db.session.execute("""
CREATE TRIGGER IF NOT EXISTS release_update_downloaded
AFTER UPDATE ON release_updates
WHEN OLD.download_status != 'completed' AND NEW.download_status = 'completed'
BEGIN
INSERT INTO release_update_history
(release_id, artist_id, artist_name, release_title, release_type, action, timestamp, metadata)
VALUES
(NEW.release_id, NEW.artist_id, NEW.artist_name, NEW.release_title, NEW.release_type, 'downloaded', CURRENT_TIMESTAMP,
json_object('auto_downloaded', NEW.auto_downloaded));
END
""")
db.session.commit()
logger.info("Update tracking triggers created successfully")
except Exception as e:
logger.error(f"Error creating update tracking triggers: {e}")
db.session.rollback()
raise
+230
View File
@@ -0,0 +1,230 @@
"""
Update Tracking Database Models
This module contains the database models for the artist update tracking system,
including artist follows, release updates, notifications, and user preferences.
"""
import datetime
from typing import Optional, Dict, Any
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, ForeignKey, Date, JSON, DECIMAL
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from swingmusic.db.base import Base
class ArtistFollow(Base):
"""
Represents a user following an artist for update tracking
"""
__tablename__ = 'artist_follows'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
artist_id = Column(String(100), nullable=False, unique=True) # Spotify artist ID
artist_name = Column(String(255), nullable=False)
follow_level = Column(String(20), nullable=False, default='followed') # 'favorite', 'followed', 'casual'
auto_download_new_releases = Column(Boolean, default=False)
preferred_quality = Column(String(20), default='flac')
notification_preferences = Column(JSON, default=dict) # {in_app: true, push: false, email: false}
follow_date = Column(DateTime, default=datetime.datetime.utcnow)
last_check_date = Column(DateTime, nullable=True)
# Relationships
user = relationship("User", back_populates="artist_follows")
release_updates = relationship("ReleaseUpdate", back_populates="artist_follow")
def __repr__(self):
return f"<ArtistFollow(user_id={self.user_id}, artist='{self.artist_name}')>"
class ReleaseUpdate(Base):
"""
Represents a new release discovered from a followed artist
"""
__tablename__ = 'release_updates'
id = Column(Integer, primary_key=True)
release_id = Column(String(100), nullable=False, unique=True) # Spotify release ID
artist_id = Column(String(100), nullable=False) # Spotify artist ID
artist_name = Column(String(255), nullable=False)
release_title = Column(String(255), nullable=False)
release_type = Column(String(20), nullable=False) # 'album', 'single', 'ep', 'compilation'
release_date = Column(Date, nullable=False)
spotify_url = Column(Text, nullable=False)
cover_image_url = Column(Text, nullable=True)
total_tracks = Column(Integer, nullable=False)
popularity = Column(Integer, default=0)
explicit = Column(Boolean, default=False)
discovered_at = Column(DateTime, default=datetime.datetime.utcnow)
processed_at = Column(DateTime, nullable=True)
download_status = Column(String(20), default='pending') # 'pending', 'queued', 'downloading', 'completed', 'failed'
auto_downloaded = Column(Boolean, default=False)
notification_sent = Column(Boolean, default=False)
# Relationships
artist_follow = relationship("ArtistFollow", back_populates="release_updates")
download_tasks = relationship("DownloadTask", back_populates="release_update")
notifications = relationship("UpdateNotification", back_populates="release_update")
def __repr__(self):
return f"<ReleaseUpdate(title='{self.release_title}', artist='{self.artist_name}')>"
class UpdateNotification(Base):
"""
Represents notifications sent to users about new releases
"""
__tablename__ = 'update_notifications'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
release_id = Column(String(100), ForeignKey('release_updates.release_id'), nullable=False)
notification_type = Column(String(50), nullable=False) # 'new_release', 'artist_update', 'back_in_stock'
sent_at = Column(DateTime, default=datetime.datetime.utcnow)
opened_at = Column(DateTime, nullable=True)
action_taken = Column(String(50), nullable=True) # 'downloaded', 'played', 'dismissed'
# Relationships
user = relationship("User")
release_update = relationship("ReleaseUpdate", back_populates="notifications")
def __repr__(self):
return f"<UpdateNotification(user_id={self.user_id}, type='{self.notification_type}')>"
class UpdateMonitoringPreferences(Base):
"""
User preferences for update monitoring
"""
__tablename__ = 'update_monitoring_preferences'
user_id = Column(Integer, ForeignKey('users.id'), primary_key=True)
enable_artist_monitoring = Column(Boolean, default=True)
check_frequency = Column(String(20), default='daily') # 'hourly', 'daily', 'weekly'
auto_download_favorites = Column(Boolean, default=False)
auto_download_followed = Column(Boolean, default=False)
max_auto_downloads_per_week = Column(Integer, default=5)
quality_preference = Column(String(20), default='flac')
storage_limit_mb = Column(Integer, default=10240)
notification_channels = Column(JSON, default=dict) # {in_app: true, push: false, email: false, discord: false}
exclude_explicit = Column(Boolean, default=False)
preferred_release_types = Column(JSON, default=list) # ['album', 'ep', 'single']
# Relationships
user = relationship("User", back_populates="update_preferences")
def __repr__(self):
return f"<UpdateMonitoringPreferences(user_id={self.user_id})>"
class DownloadTask(Base):
"""
Represents download tasks created from release updates
"""
__tablename__ = 'download_tasks'
id = Column(Integer, primary_key=True)
release_id = Column(String(100), ForeignKey('release_updates.release_id'), nullable=False)
track_id = Column(String(100), nullable=False) # Spotify track ID
track_title = Column(String(255), nullable=False)
artist_name = Column(String(255), nullable=False)
album_name = Column(String(255), nullable=False)
spotify_url = Column(Text, nullable=False)
quality_preference = Column(String(20), default='flac')
status = Column(String(20), default='pending') # 'pending', 'queued', 'downloading', 'completed', 'failed'
priority = Column(String(20), default='normal') # 'low', 'normal', 'high', 'urgent'
progress = Column(Integer, default=0) # 0-100
file_path = Column(Text, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
auto_downloaded = Column(Boolean, default=False)
added_to_library = Column(Boolean, default=False)
# Relationships
release_update = relationship("ReleaseUpdate", back_populates="download_tasks")
def __repr__(self):
return f"<DownloadTask(track='{self.track_title}', status='{self.status}')>"
class ArtistFollowHistory(Base):
"""
Historical tracking of artist follows for analytics
"""
__tablename__ = 'artist_follow_history'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
artist_id = Column(String(100), nullable=False)
artist_name = Column(String(255), nullable=False)
action = Column(String(20), nullable=False) # 'follow', 'unfollow', 'level_change'
old_level = Column(String(20), nullable=True)
new_level = Column(String(20), nullable=True)
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
# Relationships
user = relationship("User")
def __repr__(self):
return f"<ArtistFollowHistory(user_id={self.user_id}, action='{self.action}')>"
class ReleaseUpdateHistory(Base):
"""
Historical tracking of release updates for analytics
"""
__tablename__ = 'release_update_history'
id = Column(Integer, primary_key=True)
release_id = Column(String(100), nullable=False)
artist_id = Column(String(100), nullable=False)
artist_name = Column(String(255), nullable=False)
release_title = Column(String(255), nullable=False)
release_type = Column(String(20), nullable=False)
action = Column(String(20), nullable=False) # 'discovered', 'downloaded', 'notification_sent', 'completed'
timestamp = Column(DateTime, default=datetime.datetime.utcnow)
metadata = Column(JSON, nullable=True) # Additional data about the action
def __repr__(self):
return f"<ReleaseUpdateHistory(release='{self.release_title}', action='{self.action}')>"
class UpdateTrackingStats(Base):
"""
Aggregated statistics for update tracking
"""
__tablename__ = 'update_tracking_stats'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
stat_date = Column(Date, nullable=False)
total_followed_artists = Column(Integer, default=0)
new_releases_discovered = Column(Integer, default=0)
auto_downloads_completed = Column(Integer, default=0)
manual_downloads_completed = Column(Integer, default=0)
notifications_sent = Column(Integer, default=0)
notifications_opened = Column(Integer, default=0)
storage_used_mb = Column(Integer, default=0)
# Relationships
user = relationship("User")
def __repr__(self):
return f"<UpdateTrackingStats(user_id={self.user_id}, date={self.stat_date})>"
# Update the User model to include the new relationships
# This would need to be added to the User model in user.py:
#
# from swingmusic.models.update_tracking import ArtistFollow, UpdateMonitoringPreferences
#
# class User(Base):
# # ... existing fields ...
#
# # Update tracking relationships
# artist_follows = relationship("ArtistFollow", back_populates="user")
# update_preferences = relationship("UpdateMonitoringPreferences", back_populates="user", uselist=False)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,928 @@
"""
Advanced Audio Quality Management Service
This service provides comprehensive audio quality control including:
- Adaptive quality streaming based on network conditions
- Multi-format support with intelligent transcoding
- Audio enhancement features (EQ, spatial audio, loudness normalization)
- Quality comparison and analysis tools
- Device-specific optimization
"""
import asyncio
import logging
import json
import os
import subprocess
import tempfile
from typing import Dict, List, Optional, Tuple, Any, Union
from dataclasses import dataclass, asdict
from enum import Enum
from pathlib import Path
import aiofiles
from sqlalchemy.orm import Session
from swingmusic.db import db
from swingmusic.config import USER_DATA_DIR
from swingmusic.utils.network_monitor import NetworkMonitor
from swingmusic.utils.device_detector import DeviceDetector
logger = logging.getLogger(__name__)
class AudioFormat(Enum):
"""Supported audio formats"""
FLAC = "flac"
ALAC = "alac"
WAV = "wav"
AIFF = "aiff"
MP3_320 = "mp3_320"
MP3_256 = "mp3_256"
MP3_192 = "mp3_192"
MP3_128 = "mp3_128"
AAC_256 = "aac_256"
AAC_192 = "aac_192"
AAC_128 = "aac_128"
OGG_VORBIS = "ogg_vorbis"
OGG_OPUS = "ogg_opus"
class QualityLevel(Enum):
"""Audio quality levels"""
LOSSLESS = "lossless"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
DATA_SAVER = "data_saver"
class SampleRate(Enum):
"""Supported sample rates"""
RATE_44_1 = "44.1kHz"
RATE_48 = "48kHz"
RATE_96 = "96kHz"
RATE_192 = "192kHz"
class BitDepth(Enum):
"""Supported bit depths"""
BIT_16 = "16bit"
BIT_24 = "24bit"
BIT_32 = "32bit"
class SpatialAudioFormat(Enum):
"""Spatial audio formats"""
NONE = "none"
STEREO = "stereo"
BINAURAL = "binaural"
DOLBY_ATMOS = "dolby_atmos"
SONY_360 = "sony_360"
AMBISONIC = "ambisonic"
@dataclass
class AudioQualitySettings:
"""Comprehensive audio quality settings"""
# Streaming quality
streaming_quality: QualityLevel = QualityLevel.HIGH
adaptive_quality: bool = True
network_aware_quality: bool = True
device_specific_quality: bool = True
# Download quality
download_format: AudioFormat = AudioFormat.FLAC
download_bitrate: Optional[int] = None # For lossy formats
download_sample_rate: SampleRate = SampleRate.RATE_44_1
download_bit_depth: BitDepth = BitDepth.BIT_16
# Advanced audio settings
enable_dolby_atmos: bool = False
enable_360_audio: bool = False
spatial_audio_format: SpatialAudioFormat = SpatialAudioFormat.STEREO
# Audio enhancements
enable_adaptive_eq: bool = True
enable_spatial_audio_processing: bool = False
enable_loudness_normalization: bool = True
target_loudness: float = -14.0 # LUFS
# Processing settings
enable_crossfade: bool = False
crossfade_duration: float = 2.0
enable_gapless_playback: bool = True
enable_replaygain: bool = True
# Quality preferences
prioritize_fidelity: bool = True
prioritize_file_size: bool = False
prioritize_compatibility: bool = False
# Advanced options
custom_ffmpeg_params: Dict[str, Any] = None
enable_experimental_codecs: bool = False
cache_transcoded_files: bool = True
@dataclass
class AudioAnalysis:
"""Audio analysis results"""
file_path: str
format: str
duration: float
sample_rate: int
bit_depth: int
bitrate: int
channels: int
codec: str
# Audio characteristics
dynamic_range: float # dB
peak_level: float # dB
rms_level: float # dB
loudness: float # LUFS
# Frequency analysis
frequency_response: Dict[str, float]
spectral_centroid: float
spectral_rolloff: float
# Quality metrics
signal_to_noise_ratio: float
total_harmonic_distortion: float
# Metadata
detected_genre: Optional[str] = None
acoustic_features: Dict[str, float] = None
@dataclass
class QualityComparison:
"""Quality comparison between different formats"""
original_file: str
formats: Dict[str, Dict[str, Any]]
# Comparison metrics
size_difference: Dict[str, float] # Percentage
quality_score: Dict[str, float] # 0-100
transparency_score: Dict[str, float] # 0-100
# Recommendations
recommended_format: str
recommended_reason: str
class AudioTranscoder:
"""Audio transcoding with FFmpeg"""
def __init__(self):
self.ffmpeg_path = self._find_ffmpeg()
self.temp_dir = Path(tempfile.gettempdir()) / "swingmusic_transcode"
self.temp_dir.mkdir(exist_ok=True)
def _find_ffmpeg(self) -> str:
"""Find FFmpeg executable"""
# Try common paths
ffmpeg_paths = [
"ffmpeg",
"/usr/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg"
]
for path in ffmpeg_paths:
try:
result = subprocess.run([path, "-version"],
capture_output=True, text=True)
if result.returncode == 0:
return path
except (subprocess.SubprocessError, FileNotFoundError):
continue
raise RuntimeError("FFmpeg not found. Please install FFmpeg.")
async def transcode(self, input_path: str, output_path: str,
settings: AudioQualitySettings) -> bool:
"""Transcode audio file according to settings"""
try:
# Build FFmpeg command
cmd = self._build_transcode_command(input_path, output_path, settings)
# Execute transcoding
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.error(f"FFmpeg error: {stderr.decode()}")
return False
return True
except Exception as e:
logger.error(f"Transcoding error: {e}")
return False
def _build_transcode_command(self, input_path: str, output_path: str,
settings: AudioQualitySettings) -> List[str]:
"""Build FFmpeg command for transcoding"""
cmd = [self.ffmpeg_path, "-i", input_path]
# Audio codec settings
if settings.download_format == AudioFormat.FLAC:
cmd.extend(["-c:a", "flac", "-compression_level", "8"])
elif settings.download_format == AudioFormat.MP3_320:
cmd.extend(["-c:a", "libmp3lame", "-b:a", "320k"])
elif settings.download_format == AudioFormat.MP3_256:
cmd.extend(["-c:a", "libmp3lame", "-b:a", "256k"])
elif settings.download_format == AudioFormat.AAC_256:
cmd.extend(["-c:a", "aac", "-b:a", "256k"])
elif settings.download_format == AudioFormat.OGG_VORBIS:
cmd.extend(["-c:a", "libvorbis", "-b:a", "256k"])
else:
# Default to FLAC
cmd.extend(["-c:a", "flac"])
# Sample rate
if settings.download_sample_rate == SampleRate.RATE_48:
cmd.extend(["-ar", "48000"])
elif settings.download_sample_rate == SampleRate.RATE_96:
cmd.extend(["-ar", "96000"])
elif settings.download_sample_rate == SampleRate.RATE_192:
cmd.extend(["-ar", "192000"])
# Bit depth
if settings.download_bit_depth == BitDepth.BIT_24:
cmd.extend(["-sample_format", "s24"])
elif settings.download_bit_depth == BitDepth.BIT_32:
cmd.extend(["-sample_format", "s32"])
# Custom FFmpeg parameters
if settings.custom_ffmpeg_params:
for key, value in settings.custom_ffmpeg_params.items():
if isinstance(value, bool):
if value:
cmd.extend([key])
else:
cmd.extend([key, str(value)])
# Output settings
cmd.extend(["-y", output_path]) # -y to overwrite
return cmd
class AudioAnalyzer:
"""Audio analysis using FFmpeg and audio processing libraries"""
def __init__(self):
self.ffmpeg_path = self._find_ffmpeg()
def _find_ffmpeg(self) -> str:
"""Find FFmpeg executable"""
ffmpeg_paths = [
"ffmpeg",
"/usr/bin/ffmpeg",
"/usr/local/bin/ffmpeg",
"/opt/homebrew/bin/ffmpeg"
]
for path in ffmpeg_paths:
try:
result = subprocess.run([path, "-version"],
capture_output=True, text=True)
if result.returncode == 0:
return path
except (subprocess.SubprocessError, FileNotFoundError):
continue
raise RuntimeError("FFmpeg not found")
async def analyze_file(self, file_path: str) -> AudioAnalysis:
"""Comprehensive audio file analysis"""
try:
# Get basic info with FFprobe
probe_cmd = [
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", file_path
]
process = await asyncio.create_subprocess_exec(
*probe_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise RuntimeError(f"FFprobe error: {stderr.decode()}")
probe_data = json.loads(stdout.decode())
# Extract audio stream info
audio_stream = None
for stream in probe_data.get("streams", []):
if stream.get("codec_type") == "audio":
audio_stream = stream
break
if not audio_stream:
raise ValueError("No audio stream found")
format_info = probe_data.get("format", {})
# Create analysis object
analysis = AudioAnalysis(
file_path=file_path,
format=format_info.get("format_name", "unknown"),
duration=float(format_info.get("duration", 0)),
sample_rate=int(audio_stream.get("sample_rate", 44100)),
bit_depth=self._extract_bit_depth(audio_stream),
bitrate=int(format_info.get("bit_rate", 0)),
channels=int(audio_stream.get("channels", 2)),
codec=audio_stream.get("codec_name", "unknown"),
# Audio characteristics (simplified for now)
dynamic_range=0.0,
peak_level=0.0,
rms_level=0.0,
loudness=0.0,
frequency_response={},
spectral_centroid=0.0,
spectral_rolloff=0.0,
signal_to_noise_ratio=0.0,
total_harmonic_distortion=0.0
)
# Perform advanced analysis
await self._perform_advanced_analysis(analysis)
return analysis
except Exception as e:
logger.error(f"Audio analysis error: {e}")
raise
def _extract_bit_depth(self, stream: Dict) -> int:
"""Extract bit depth from stream info"""
bits_per_sample = stream.get("bits_per_sample")
if bits_per_sample:
return int(bits_per_sample)
# Try to determine from codec
codec_name = stream.get("codec_name", "").lower()
if "flac" in codec_name or "pcm" in codec_name:
return 16 # Default assumption
return 16
async def _perform_advanced_analysis(self, analysis: AudioAnalysis):
"""Perform advanced audio analysis"""
try:
# This would integrate with audio processing libraries
# For now, we'll provide placeholder values
# In a real implementation, you would use:
# - librosa for audio analysis
# - pydub for basic processing
# - numpy for mathematical operations
analysis.dynamic_range = 15.0 # Placeholder
analysis.peak_level = -1.0 # Placeholder
analysis.rms_level = -12.0 # Placeholder
analysis.loudness = -14.0 # Placeholder (LUFS target)
# Frequency bands (Hz)
analysis.frequency_response = {
"20": 0.0,
"60": 0.0,
"250": 0.0,
"1000": 0.0,
"4000": 0.0,
"16000": 0.0,
"20000": 0.0
}
analysis.spectral_centroid = 2000.0
analysis.spectral_rolloff = 18000.0
analysis.signal_to_noise_ratio = 60.0
analysis.total_harmonic_distortion = 0.01
except Exception as e:
logger.error(f"Advanced analysis error: {e}")
class AdaptiveQualityManager:
"""Adaptive quality management based on conditions"""
def __init__(self):
self.network_monitor = NetworkMonitor()
self.device_detector = DeviceDetector()
self.quality_profiles = self._load_quality_profiles()
def _load_quality_profiles(self) -> Dict[str, Dict]:
"""Load quality profiles for different conditions"""
return {
"excellent_network": {
"streaming": QualityLevel.LOSSLESS,
"download": AudioFormat.FLAC,
"bitrate": None
},
"good_network": {
"streaming": QualityLevel.HIGH,
"download": AudioFormat.MP3_320,
"bitrate": 320
},
"fair_network": {
"streaming": QualityLevel.MEDIUM,
"download": AudioFormat.MP3_256,
"bitrate": 256
},
"poor_network": {
"streaming": QualityLevel.LOW,
"download": AudioFormat.MP3_128,
"bitrate": 128
},
"data_saver": {
"streaming": QualityLevel.DATA_SAVER,
"download": AudioFormat.MP3_128,
"bitrate": 128
},
"mobile_device": {
"streaming": QualityLevel.MEDIUM,
"download": AudioFormat.AAC_256,
"bitrate": 256
},
"high_end_device": {
"streaming": QualityLevel.LOSSLESS,
"download": AudioFormat.FLAC,
"bitrate": None
},
"battery_saver": {
"streaming": QualityLevel.LOW,
"download": AudioFormat.MP3_192,
"bitrate": 192
}
}
async def get_optimal_quality(self, user_settings: AudioQualitySettings,
context: Dict[str, Any] = None) -> Dict[str, Any]:
"""Get optimal quality settings based on current conditions"""
context = context or {}
# Get current conditions
network_speed = await self.network_monitor.get_current_speed()
device_info = self.device_detector.get_device_info()
battery_level = device_info.get("battery_level", 100)
# Determine quality profile
profile = self._determine_quality_profile(
network_speed, device_info, battery_level, user_settings, context
)
return profile
def _determine_quality_profile(self, network_speed: float, device_info: Dict,
battery_level: float, user_settings: AudioQualitySettings,
context: Dict) -> Dict[str, Any]:
"""Determine the best quality profile"""
# Network-based selection
if user_settings.network_aware_quality:
if network_speed > 10.0: # Mbps
network_profile = "excellent_network"
elif network_speed > 5.0:
network_profile = "good_network"
elif network_speed > 2.0:
network_profile = "fair_network"
elif network_speed > 0.5:
network_profile = "poor_network"
else:
network_profile = "data_saver"
else:
network_profile = "good_network" # Default
# Device-based selection
if user_settings.device_specific_quality:
device_type = device_info.get("type", "desktop")
if device_type == "mobile":
device_profile = "mobile_device"
elif device_type == "high_end":
device_profile = "high_end_device"
else:
device_profile = "good_network"
else:
device_profile = "good_network"
# Battery-based selection
if battery_level < 20 and context.get("battery_saver", False):
battery_profile = "battery_saver"
else:
battery_profile = "good_network"
# Select the most restrictive profile
profiles = [network_profile, device_profile, battery_profile]
selected_profile = self.quality_profiles["good_network"] # Default
for profile_name in profiles:
profile = self.quality_profiles.get(profile_name)
if profile:
# Compare and select the most appropriate
if self._is_more_restrictive(profile, selected_profile):
selected_profile = profile
return selected_profile.copy()
def _is_more_restrictive(self, profile1: Dict, profile2: Dict) -> bool:
"""Check if profile1 is more restrictive than profile2"""
quality_order = {
QualityLevel.LOSSLESS: 4,
QualityLevel.HIGH: 3,
QualityLevel.MEDIUM: 2,
QualityLevel.LOW: 1,
QualityLevel.DATA_SAVER: 0
}
q1 = quality_order.get(profile1.get("streaming"), 2)
q2 = quality_order.get(profile2.get("streaming"), 2)
return q1 < q2
class AudioEnhancementService:
"""Audio enhancement processing"""
def __init__(self):
self.transcoder = AudioTranscoder()
self.analyzer = AudioAnalyzer()
async def apply_enhancements(self, input_path: str, output_path: str,
settings: AudioQualitySettings) -> bool:
"""Apply audio enhancements to a file"""
try:
# Analyze the input file
analysis = await self.analyzer.analyze_file(input_path)
# Build enhancement command
cmd = self._build_enhancement_command(input_path, output_path,
settings, analysis)
# Apply enhancements
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
logger.error(f"Enhancement error: {stderr.decode()}")
return False
return True
except Exception as e:
logger.error(f"Audio enhancement error: {e}")
return False
def _build_enhancement_command(self, input_path: str, output_path: str,
settings: AudioQualitySettings,
analysis: AudioAnalysis) -> List[str]:
"""Build FFmpeg command for audio enhancements"""
cmd = [self.transcoder.ffmpeg_path, "-i", input_path]
# Audio filters
filters = []
# Loudness normalization
if settings.enable_loudness_normalization:
filters.append(f"loudnorm=I={settings.target_loudness}")
# Adaptive EQ (simplified)
if settings.enable_adaptive_eq:
# This would be more sophisticated in a real implementation
# analyzing the frequency response and applying appropriate EQ
filters.append("equalizer=f=1000:width_type=h:width=100:g=2")
# Spatial audio processing
if settings.enable_spatial_audio_processing:
if settings.spatial_audio_format == SpatialAudioFormat.BINAURAL:
filters.append("bs2b")
elif settings.spatial_audio_format == SpatialAudioFormat.AMBISONIC:
filters.append("surround")
# Combine filters
if filters:
filter_string = ",".join(filters)
cmd.extend(["-af", filter_string])
# Output codec (preserve quality)
cmd.extend(["-c:a", "pcm_s16le"])
# Output
cmd.extend(["-y", output_path])
return cmd
class AudioQualityManager:
"""
Main audio quality management service
This service coordinates all audio quality operations including:
- Adaptive quality streaming
- Audio transcoding and enhancement
- Quality analysis and comparison
- User preference management
"""
def __init__(self):
self.transcoder = AudioTranscoder()
self.analyzer = AudioAnalyzer()
self.adaptive_manager = AdaptiveQualityManager()
self.enhancement_service = AudioEnhancementService()
# Cache for analysis results
self._analysis_cache = {}
self._quality_cache = {}
async def get_optimal_streaming_quality(self, user_id: int,
context: Dict[str, Any] = None) -> Dict[str, Any]:
"""Get optimal streaming quality for user"""
try:
# Get user settings
user_settings = await self._get_user_settings(user_id)
# Get optimal quality based on conditions
optimal = await self.adaptive_manager.get_optimal_quality(
user_settings, context
)
return optimal
except Exception as e:
logger.error(f"Error getting optimal quality: {e}")
return {"streaming": "medium", "download": "mp3_256", "bitrate": 256}
async def transcode_for_streaming(self, input_path: str, user_id: int,
context: Dict[str, Any] = None) -> Optional[str]:
"""Transcode file for optimal streaming"""
try:
# Get optimal quality
quality_settings = await self.get_optimal_streaming_quality(user_id, context)
# Create output path
output_dir = Path(USER_DATA_DIR) / "transcoded"
output_dir.mkdir(exist_ok=True)
input_file = Path(input_path)
output_file = output_dir / f"{input_file.stem}_transcoded.mp3"
# Build settings for transcoding
settings = AudioQualitySettings()
if quality_settings.get("download") == AudioFormat.FLAC:
settings.download_format = AudioFormat.FLAC
elif quality_settings.get("download") == AudioFormat.MP3_320:
settings.download_format = AudioFormat.MP3_320
else:
settings.download_format = AudioFormat.MP3_256
# Transcode
success = await self.transcoder.transcode(
str(input_file), str(output_file), settings
)
if success:
return str(output_file)
else:
return None
except Exception as e:
logger.error(f"Transcoding error: {e}")
return None
async def analyze_audio_file(self, file_path: str) -> AudioAnalysis:
"""Analyze audio file"""
# Check cache first
if file_path in self._analysis_cache:
return self._analysis_cache[file_path]
try:
analysis = await self.analyzer.analyze_file(file_path)
self._analysis_cache[file_path] = analysis
return analysis
except Exception as e:
logger.error(f"Analysis error: {e}")
raise
async def compare_quality_formats(self, original_path: str,
formats: List[AudioFormat]) -> QualityComparison:
"""Compare quality across different formats"""
try:
original_analysis = await self.analyze_audio_file(original_path)
comparison = QualityComparison(
original_file=original_path,
formats={},
size_difference={},
quality_score={},
transparency_score={},
recommended_format="flac",
recommended_reason="Best quality for archival"
)
original_size = Path(original_path).stat().st_size
for format_type in formats:
try:
# Transcode to format
temp_file = await self._transcode_to_format(original_path, format_type)
if temp_file:
# Analyze transcoded file
transcoded_analysis = await self.analyze_audio_file(temp_file)
# Calculate metrics
transcoded_size = Path(temp_file).stat().st_size
size_diff = ((transcoded_size - original_size) / original_size) * 100
quality_score = self._calculate_quality_score(
original_analysis, transcoded_analysis
)
transparency_score = self._calculate_transparency_score(
original_analysis, transcoded_analysis
)
comparison.formats[format_type.value] = {
"analysis": asdict(transcoded_analysis),
"file_size": transcoded_size,
"file_path": temp_file
}
comparison.size_difference[format_type.value] = size_diff
comparison.quality_score[format_type.value] = quality_score
comparison.transparency_score[format_type.value] = transparency_score
# Clean up temp file
os.unlink(temp_file)
except Exception as e:
logger.error(f"Error comparing format {format_type}: {e}")
continue
# Determine recommendation
comparison.recommended_format, comparison.recommended_reason = \
self._determine_best_format(comparison)
return comparison
except Exception as e:
logger.error(f"Quality comparison error: {e}")
raise
async def _transcode_to_format(self, input_path: str,
format_type: AudioFormat) -> Optional[str]:
"""Transcode file to specific format for comparison"""
try:
temp_dir = Path(tempfile.gettempdir()) / "swingmusic_compare"
temp_dir.mkdir(exist_ok=True)
input_file = Path(input_path)
output_file = temp_dir / f"{input_file.stem}_compare.{format_type.value}"
settings = AudioQualitySettings()
settings.download_format = format_type
success = await self.transcoder.transcode(
str(input_file), str(output_file), settings
)
if success:
return str(output_file)
else:
return None
except Exception as e:
logger.error(f"Format transcoding error: {e}")
return None
def _calculate_quality_score(self, original: AudioAnalysis,
transcoded: AudioAnalysis) -> float:
"""Calculate quality score (0-100)"""
try:
# Simplified quality calculation
# In a real implementation, this would be more sophisticated
score = 100.0
# Penalize quality loss
if transcoded.bitrate < original.bitrate:
score -= (original.bitrate - transcoded.bitrate) / original.bitrate * 30
# Penalize sample rate reduction
if transcoded.sample_rate < original.sample_rate:
score -= (original.sample_rate - transcoded.sample_rate) / original.sample_rate * 20
# Penalize bit depth reduction
if transcoded.bit_depth < original.bit_depth:
score -= (original.bit_depth - transcoded.bit_depth) / original.bit_depth * 10
return max(0, min(100, score))
except Exception:
return 50.0 # Default score
def _calculate_transparency_score(self, original: AudioAnalysis,
transcoded: AudioAnalysis) -> float:
"""Calculate transparency score (0-100)"""
try:
# Simplified transparency calculation
# In a real implementation, this would use ABX testing or perceptual models
if transcoded.format == original.format:
return 100.0
# Lossless formats get high transparency
if transcoded.format in ["flac", "alac", "wav"]:
return 95.0
# High bitrate lossy formats
if transcoded.bitrate >= 320:
return 85.0
elif transcoded.bitrate >= 256:
return 75.0
elif transcoded.bitrate >= 192:
return 60.0
else:
return 40.0
except Exception:
return 50.0
def _determine_best_format(self, comparison: QualityComparison) -> Tuple[str, str]:
"""Determine the best format recommendation"""
try:
best_format = "flac"
best_reason = "Best quality for archival purposes"
# Consider user priorities
scores = comparison.quality_score
if scores:
# Find format with best balance of quality and size
best_score = 0
for format_name, score in scores.items():
size_penalty = abs(comparison.size_difference.get(format_name, 0)) / 100
combined_score = score - size_penalty * 10
if combined_score > best_score:
best_score = combined_score
best_format = format_name
best_reason = f"Best balance of quality ({score:.1f}) and file size"
return best_format, best_reason
except Exception:
return "flac", "Best quality for archival purposes"
async def _get_user_settings(self, user_id: int) -> AudioQualitySettings:
"""Get user's audio quality settings"""
try:
with db.session() as session:
# This would query user_settings table
# For now, return defaults
return AudioQualitySettings()
except Exception as e:
logger.error(f"Error getting user settings: {e}")
return AudioQualitySettings()
async def update_user_settings(self, user_id: int,
settings: AudioQualitySettings) -> bool:
"""Update user's audio quality settings"""
try:
with db.session() as session:
# This would update user_settings table
logger.info(f"Updated audio settings for user {user_id}")
return True
except Exception as e:
logger.error(f"Error updating user settings: {e}")
return False
def clear_cache(self):
"""Clear analysis and quality cache"""
self._analysis_cache.clear()
self._quality_cache.clear()
# Singleton instance
audio_quality_manager = AudioQualityManager()
@@ -0,0 +1,445 @@
"""
Enhanced Album Grouper for SwingMusic
Handles proper album grouping with various artists, compilations, and metadata normalization
"""
import re
import unicodedata
from typing import Dict, List, Optional, Set, Tuple
from dataclasses import dataclass
from difflib import SequenceMatcher
import sqlite3
from swingmusic import logger
from swingmusic.db.sqlite.utils import get_db_connection
@dataclass
class AlbumGroupingKey:
"""Key for album grouping with normalization"""
normalized_artist: str
normalized_album: str
year: Optional[str]
is_compilation: bool
album_type: str # album, single, compilation, etc.
@dataclass
class AlbumInfo:
"""Enhanced album information"""
album_id: str
title: str
artists: List[str]
primary_artist: str
year: Optional[str]
album_type: str
is_compilation: bool
track_count: int
total_duration: int
image_url: Optional[str]
folder_path: str
grouping_key: str
class MetadataNormalizer:
"""Normalizes metadata for consistent grouping"""
# Common variations that should be normalized
ARTIST_VARIATIONS = {
'various artists': ['various artists', 'va', 'various', 'multiple artists'],
'soundtrack': ['soundtrack', 'ost', 'original soundtrack', 'original sound track'],
'various': ['various', 'various artists', 'va'],
}
# Words to remove for better matching
STOP_WORDS = {
'the', 'a', 'an', 'and', 'or', 'but', 'for', 'nor', 'so', 'yet',
'to', 'of', 'in', 'on', 'at', 'by', 'for', 'with', 'about', 'as'
}
# Patterns to clean up
CLEANUP_PATTERNS = [
r'\[.*?\]', # Remove brackets and content
r'\(.*?\)', # Remove parentheses and content
r'\{.*?\}', # Remove braces and content
r'<.*?>', # Remove angle brackets and content
r' feat\. .*', # Remove featuring info
r' ft\. .*', # Remove featuring info
r' featuring .*', # Remove featuring info
]
@classmethod
def normalize_string(cls, text: str) -> str:
"""Normalize string for comparison"""
if not text:
return ""
# Convert to lowercase and normalize unicode
text = unicodedata.normalize('NFKD', text.lower())
# Remove accents and diacritics
text = ''.join(c for c in text if not unicodedata.combining(c))
# Apply cleanup patterns
for pattern in cls.CLEANUP_PATTERNS:
text = re.sub(pattern, '', text, flags=re.IGNORECASE)
# Remove extra whitespace and punctuation
text = re.sub(r'[^\w\s]', ' ', text)
text = re.sub(r'\s+', ' ', text).strip()
# Remove stop words (optional for album names)
# words = text.split()
# text = ' '.join(word for word in words if word not in cls.STOP_WORDS)
return text
@classmethod
def normalize_artist(cls, artist: str) -> str:
"""Normalize artist name for grouping"""
if not artist:
return ""
normalized = cls.normalize_string(artist)
# Handle common variations
for standard, variations in cls.ARTIST_VARIATIONS.items():
if normalized in variations:
return standard
return normalized
@classmethod
def normalize_album(cls, album: str) -> str:
"""Normalize album name for grouping"""
return cls.normalize_string(album)
@classmethod
def extract_year(cls, date_str: str) -> Optional[str]:
"""Extract year from date string"""
if not date_str:
return None
# Look for 4-digit year patterns
year_match = re.search(r'\b(19|20)\d{2}\b', date_str)
if year_match:
return year_match.group()
return None
@classmethod
def is_compilation(cls, artists: List[str], albumartist: str = None) -> bool:
"""Determine if album is a compilation"""
if not artists:
return False
# Check if albumartist is "Various Artists"
if albumartist:
normalized_albumartist = cls.normalize_artist(albumartist)
if normalized_albumartist in ['various artists', 'va', 'various']:
return True
# Check if there are many different artists
unique_artists = set(cls.normalize_artist(artist) for artist in artists)
# If more than 3 unique artists, likely a compilation
if len(unique_artists) > 3:
return True
# Check for common compilation indicators
album_lower = ' '.join(artists).lower()
compilation_indicators = [
'various artists', 'soundtrack', 'ost', 'compilation',
'various', 'multiple artists', 'collection', 'greatest hits'
]
return any(indicator in album_lower for indicator in compilation_indicators)
class ArtistAliasResolver:
"""Resolves artist aliases to canonical names"""
def __init__(self):
self.aliases: Dict[str, str] = {}
self._load_common_aliases()
def _load_common_aliases(self):
"""Load common artist aliases"""
# Common artist name variations
common_aliases = {
'taylor swift': ['t. swift', 'taylor', 'swift'],
'the beatles': ['beatles', 'the fab four'],
'led zeppelin': ['zeppelin', 'led zep'],
'pink floyd': ['floyd'],
'the rolling stones': ['rolling stones', 'stones'],
'bob dylan': ['dylan', 'bobby dylan'],
'david bowie': ['bowie', 'ziggy stardust'],
# Add more as needed
}
for canonical, aliases in common_aliases.items():
for alias in aliases:
self.aliases[MetadataNormalizer.normalize_string(alias)] = canonical
def resolve_alias(self, artist: str) -> str:
"""Resolve artist alias to canonical name"""
normalized = MetadataNormalizer.normalize_string(artist)
return self.aliases.get(normalized, artist)
def add_alias(self, canonical: str, alias: str):
"""Add a new artist alias"""
normalized_alias = MetadataNormalizer.normalize_string(alias)
self.aliases[normalized_alias] = canonical
class AlbumGrouper:
"""Enhanced album grouping with proper normalization"""
def __init__(self):
self.metadata_normalizer = MetadataNormalizer()
self.alias_resolver = ArtistAliasResolver()
self.grouping_cache: Dict[str, AlbumGroupingKey] = {}
def normalize_album_artist(self, track_metadata: Dict[str, any]) -> str:
"""Normalize album artist for proper grouping"""
# Try albumartist first
albumartist = track_metadata.get('albumartist')
if albumartist:
normalized = self.metadata_normalizer.normalize_artist(albumartist)
resolved = self.alias_resolver.resolve_alias(normalized)
return resolved
# Fall back to artist
artist = track_metadata.get('artist')
if artist:
normalized = self.metadata_normalizer.normalize_artist(artist)
resolved = self.alias_resolver.resolve_alias(normalized)
return resolved
return "Unknown Artist"
def create_grouping_key(self, track_metadata: Dict[str, any]) -> AlbumGroupingKey:
"""Create consistent grouping key for albums"""
# Extract and normalize artist
artists = self._extract_artists(track_metadata)
primary_artist = self.normalize_album_artist(track_metadata)
# Normalize album name
album_name = track_metadata.get('album', '')
normalized_album = self.metadata_normalizer.normalize_album(album_name)
# Extract year
release_date = track_metadata.get('date') or track_metadata.get('year')
year = self.metadata_normalizer.extract_year(str(release_date)) if release_date else None
# Determine if compilation
is_compilation = self.metadata_normalizer.is_compilation(
artists, track_metadata.get('albumartist')
)
# Determine album type
album_type = track_metadata.get('albumtype', 'album')
if is_compilation:
album_type = 'compilation'
return AlbumGroupingKey(
normalized_artist=primary_artist,
normalized_album=normalized_album,
year=year,
is_compilation=is_compilation,
album_type=album_type
)
def create_grouping_key_string(self, track_metadata: Dict[str, any]) -> str:
"""Create string-based grouping key for database storage"""
key = self.create_grouping_key(track_metadata)
# Include year for different editions but allow fallback
year_part = f"::{key.year}" if key.year else ""
# Mark compilations specially
compilation_part = "::COMP" if key.is_compilation else ""
return f"{key.normalized_artist}::{key.normalized_album}{year_part}{compilation_part}"
def _extract_artists(self, track_metadata: Dict[str, any]) -> List[str]:
"""Extract list of artists from track metadata"""
artists = []
# Try artists field (array)
if 'artists' in track_metadata:
if isinstance(track_metadata['artists'], list):
artists.extend(track_metadata['artists'])
else:
artists.append(str(track_metadata['artists']))
# Try artist field
if 'artist' in track_metadata:
artist_str = track_metadata['artist']
if isinstance(artist_str, list):
artists.extend(artist_str)
else:
# Split common separators
for sep in [',', ';', '&', ' and ', ' ft ', ' feat ']:
if sep in artist_str:
artists.extend([a.strip() for a in artist_str.split(sep)])
break
else:
artists.append(artist_str)
# Remove duplicates and empty strings
return list(set(filter(None, artists)))
def calculate_similarity(self, str1: str, str2: str) -> float:
"""Calculate similarity between two strings"""
return SequenceMatcher(None, str1, str2).ratio()
def should_group_together(self, key1: AlbumGroupingKey, key2: AlbumGroupingKey) -> bool:
"""Determine if two albums should be grouped together"""
# Different artists - don't group unless both are compilations
if key1.normalized_artist != key2.normalized_artist:
if not (key1.is_compilation and key2.is_compilation):
return False
# Check album name similarity
album_similarity = self.calculate_similarity(key1.normalized_album, key2.normalized_album)
if album_similarity < 0.8: # 80% similarity threshold
return False
# If years are available, they should be close or identical
if key1.year and key2.year:
if key1.year != key2.year:
# Allow grouping if years are close (e.g., reissues)
year_diff = abs(int(key1.year) - int(key2.year))
if year_diff > 5: # More than 5 years difference
return False
return True
def group_albums_from_database(self) -> Dict[str, List[Dict[str, any]]]:
"""Group albums from database tracks"""
try:
with get_db_connection() as conn:
# Get all tracks with album information
query = """
SELECT
t.trackhash,
t.title,
t.artist,
t.albumartist,
t.album,
t.date,
t.year,
t.albumtype,
t.image,
t.folderpath,
t.duration
FROM tracks t
WHERE t.album IS NOT NULL AND t.album != ''
ORDER BY t.albumartist, t.album, t.date, t.tracknumber
"""
cursor = conn.execute(query)
tracks = cursor.fetchall()
# Group tracks by album key
album_groups: Dict[str, List[Dict[str, any]]] = {}
for track in tracks:
track_dict = dict(track)
# Create grouping key
grouping_key = self.create_grouping_key_string(track_dict)
# Add to group
if grouping_key not in album_groups:
album_groups[grouping_key] = []
album_groups[grouping_key].append(track_dict)
return album_groups
except Exception as e:
logger.error(f"Error grouping albums from database: {e}")
return {}
def create_album_info(self, grouping_key: str, tracks: List[Dict[str, any]]) -> AlbumInfo:
"""Create album info from grouped tracks"""
if not tracks:
raise ValueError("No tracks provided")
first_track = tracks[0]
key = self.create_grouping_key(first_track)
# Extract unique artists
all_artists = set()
for track in tracks:
artists = self._extract_artists(track)
all_artists.update(artists)
# Calculate total duration
total_duration = sum(track.get('duration', 0) for track in tracks)
# Get image from first track (could be enhanced to find best image)
image_url = first_track.get('image')
return AlbumInfo(
album_id=grouping_key,
title=first_track.get('album', ''),
artists=list(all_artists),
primary_artist=key.normalized_artist,
year=key.year,
album_type=key.album_type,
is_compilation=key.is_compilation,
track_count=len(tracks),
total_duration=total_duration,
image_url=image_url,
folder_path=first_track.get('folderpath', ''),
grouping_key=grouping_key
)
def fix_album_grouping_in_database(self) -> int:
"""Fix album grouping in database and return number of fixes"""
fixes_made = 0
try:
with get_db_connection() as conn:
# Get all tracks
cursor = conn.execute("""
SELECT trackhash, artist, albumartist, album, date, year, albumtype
FROM tracks
WHERE album IS NOT NULL AND album != ''
""")
tracks = cursor.fetchall()
for track in tracks:
track_dict = dict(track)
# Create proper grouping key
new_key = self.create_grouping_key_string(track_dict)
# Check if we need to update albumartist
proper_albumartist = self.normalize_album_artist(track_dict)
current_albumartist = track_dict.get('albumartist', '')
if proper_albumartist != current_albumartist:
cursor = conn.execute("""
UPDATE tracks
SET albumartist = ?
WHERE trackhash = ?
""", (proper_albumartist, track_dict['trackhash']))
fixes_made += 1
logger.info(f"Fixed albumartist for {track_dict['trackhash']}: '{current_albumartist}' -> '{proper_albumartist}'")
conn.commit()
except Exception as e:
logger.error(f"Error fixing album grouping: {e}")
return fixes_made
# Global album grouper instance
album_grouper = AlbumGrouper()
@@ -0,0 +1,452 @@
"""
Enhanced Directory Scanner for SwingMusic
Handles multiple music directories with parallel scanning, permission validation, and error handling
"""
import os
import asyncio
import time
from typing import Dict, List, Optional, Set, Tuple, Any
from pathlib import Path
from dataclasses import dataclass
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from swingmusic import logger
from swingmusic.db.sqlite.utils import get_db_connection
@dataclass
class ScanResult:
"""Result of directory scanning operation"""
directory: str
success: bool
files_found: int
folders_found: int
errors: List[str]
scan_time: float
permissions_ok: bool
@dataclass
class FileInfo:
"""Information about a scanned file"""
path: str
size: int
modified_time: float
is_audio: bool
extension: str
@dataclass
class DirectoryStats:
"""Statistics for a scanned directory"""
total_files: int
audio_files: int
total_size: int
last_scan_time: float
scan_duration: float
errors: List[str]
class PermissionValidator:
"""Validates directory permissions for scanning"""
@staticmethod
async def validate_directory(directory: str) -> Tuple[bool, List[str]]:
"""Validate if directory can be accessed and scanned"""
errors = []
try:
path = Path(directory)
# Check if directory exists
if not path.exists():
errors.append(f"Directory does not exist: {directory}")
return False, errors
# Check if it's actually a directory
if not path.is_dir():
errors.append(f"Path is not a directory: {directory}")
return False, errors
# Check read permissions
if not os.access(directory, os.R_OK):
errors.append(f"No read permission for directory: {directory}")
return False, errors
# Check execute permissions (needed for directory traversal)
if not os.access(directory, os.X_OK):
errors.append(f"No execute permission for directory: {directory}")
return False, errors
# Try to list directory contents
try:
list(path.iterdir())
except PermissionError as e:
errors.append(f"Cannot list directory contents: {directory} - {str(e)}")
return False, errors
# Check a subdirectory to ensure traversal works
try:
subdirs = [p for p in path.iterdir() if p.is_dir()]
if subdirs:
test_subdir = subdirs[0]
if os.access(test_subdir, os.R_OK | os.X_OK):
return True, errors
else:
errors.append(f"Cannot access subdirectories in: {directory}")
return False, errors
except Exception as e:
errors.append(f"Error checking subdirectory access: {directory} - {str(e)}")
return False, errors
return True, errors
except Exception as e:
errors.append(f"Unexpected error validating directory {directory}: {str(e)}")
return False, errors
class ParallelScanner:
"""Parallel directory scanner with performance optimization"""
def __init__(self, max_workers: int = 4):
self.max_workers = max_workers
self.audio_extensions = {
'.flac', '.mp3', '.wav', '.aac', '.m4a', '.ogg', '.wma',
'.alac', '.aiff', '.aif', '.dsd', '.dsf', '.dff'
}
async def scan_with_progress(self, directory: str,
progress_callback=None) -> ScanResult:
"""Scan directory with progress reporting"""
start_time = time.time()
errors = []
files_found = 0
folders_found = 0
try:
path = Path(directory)
# Use ThreadPoolExecutor for parallel file processing
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Collect all files and directories
all_items = list(path.rglob('*'))
total_items = len(all_items)
# Process items in batches
batch_size = 100
processed = 0
for i in range(0, total_items, batch_size):
batch = all_items[i:i + batch_size]
# Process batch in parallel
futures = []
for item in batch:
future = executor.submit(self._process_item, item)
futures.append((future, item))
# Collect results
for future, item in futures:
try:
is_audio, is_dir = future.result(timeout=5)
if is_dir:
folders_found += 1
elif is_audio:
files_found += 1
except Exception as e:
errors.append(f"Error processing {item}: {str(e)}")
processed += len(batch)
# Report progress
if progress_callback:
progress = (processed / total_items) * 100
progress_callback(directory, progress, processed, total_items)
scan_time = time.time() - start_time
return ScanResult(
directory=directory,
success=len(errors) == 0,
files_found=files_found,
folders_found=folders_found,
errors=errors,
scan_time=scan_time,
permissions_ok=True
)
except Exception as e:
scan_time = time.time() - start_time
errors.append(f"Failed to scan directory {directory}: {str(e)}")
return ScanResult(
directory=directory,
success=False,
files_found=0,
folders_found=0,
errors=errors,
scan_time=scan_time,
permissions_ok=False
)
def _process_item(self, item: Path) -> Tuple[bool, bool]:
"""Process a single file or directory"""
try:
if item.is_dir():
return False, True
elif item.is_file():
is_audio = item.suffix.lower() in self.audio_extensions
return is_audio, False
else:
return False, False
except Exception:
return False, False
class DirectoryCache:
"""Caches directory scan results to improve performance"""
def __init__(self, cache_ttl: int = 3600): # 1 hour TTL
self.cache = {}
self.cache_ttl = cache_ttl
def get(self, directory: str) -> Optional[DirectoryStats]:
"""Get cached directory stats"""
cached = self.cache.get(directory)
if cached and (time.time() - cached.last_scan_time) < self.cache_ttl:
return cached
return None
def set(self, directory: str, stats: DirectoryStats):
"""Cache directory stats"""
self.cache[directory] = stats
def invalidate(self, directory: str):
"""Invalidate cache for specific directory"""
self.cache.pop(directory, None)
def clear(self):
"""Clear all cache"""
self.cache.clear()
class DirectoryWatcher(FileSystemEventHandler):
"""Watches directory changes for automatic rescanning"""
def __init__(self, directory: str, callback):
self.directory = directory
self.callback = callback
self.debounce_timer = None
self.debounce_delay = 5 # 5 seconds debounce
def on_created(self, event):
"""Handle file/directory creation"""
if not event.is_directory:
self._schedule_rescan()
def on_deleted(self, event):
"""Handle file/directory deletion"""
self._schedule_rescan()
def on_moved(self, event):
"""Handle file/directory moves"""
self._schedule_rescan()
def _schedule_rescan(self):
"""Schedule a rescan with debouncing"""
if self.debounce_timer:
self.debounce_timer.cancel()
self.debounce_timer = threading.Timer(
self.debounce_delay,
self._trigger_rescan
)
self.debounce_timer.start()
def _trigger_rescan(self):
"""Trigger the rescan callback"""
try:
self.callback(self.directory)
except Exception as e:
logger.error(f"Error in directory watcher callback: {e}")
class EnhancedDirectoryScanner:
"""Enhanced directory scanner with multiple improvements"""
def __init__(self, max_workers: int = 4):
self.permission_validator = PermissionValidator()
self.parallel_scanner = ParallelScanner(max_workers)
self.cache = DirectoryCache()
self.watchers = {} # directory -> observer
self.scan_history = {}
async def scan_multiple_directories(self, directories: List[str],
progress_callback=None) -> Dict[str, ScanResult]:
"""Efficiently scan multiple music directories in parallel"""
logger.info(f"Starting scan of {len(directories)} directories")
# Validate permissions first
validation_tasks = []
for directory in directories:
task = self.permission_validator.validate_directory(directory)
validation_tasks.append((directory, task))
# Collect validation results
valid_directories = []
validation_results = {}
for directory, task in validation_tasks:
permissions_ok, errors = await task
validation_results[directory] = (permissions_ok, errors)
if permissions_ok:
valid_directories.append(directory)
else:
logger.error(f"Directory validation failed for {directory}: {errors}")
# Scan valid directories in parallel
scan_tasks = []
for directory in valid_directories:
task = self.parallel_scanner.scan_with_progress(
directory, progress_callback
)
scan_tasks.append((directory, task))
# Collect scan results
results = {}
for directory, task in scan_tasks:
result = await task
results[directory] = result
# Cache successful results
if result.success:
stats = DirectoryStats(
total_files=result.files_found + result.folders_found,
audio_files=result.files_found,
total_size=0, # Would need additional calculation
last_scan_time=time.time(),
scan_duration=result.scan_time,
errors=result.errors
)
self.cache.set(directory, stats)
# Store in history
self.scan_history[directory] = {
'last_scan': time.time(),
'result': result
}
# Add validation failures to results
for directory, (permissions_ok, errors) in validation_results.items():
if not permissions_ok:
results[directory] = ScanResult(
directory=directory,
success=False,
files_found=0,
folders_found=0,
errors=errors,
scan_time=0,
permissions_ok=False
)
logger.info(f"Completed scan of {len(results)} directories")
return results
async def scan_directory_async(self, directory: str,
progress_callback=None) -> ScanResult:
"""Async directory scanning with progress tracking"""
# Check cache first
cached_stats = self.cache.get(directory)
if cached_stats:
logger.info(f"Using cached results for {directory}")
return ScanResult(
directory=directory,
success=True,
files_found=cached_stats.audio_files,
folders_found=cached_stats.total_files - cached_stats.audio_files,
errors=cached_stats.errors,
scan_time=cached_stats.scan_duration,
permissions_ok=True
)
# Validate permissions
permissions_ok, errors = await self.permission_validator.validate_directory(directory)
if not permissions_ok:
return ScanResult(
directory=directory,
success=False,
files_found=0,
folders_found=0,
errors=errors,
scan_time=0,
permissions_ok=False
)
# Perform scan
result = await self.parallel_scanner.scan_with_progress(
directory, progress_callback
)
# Cache successful results
if result.success:
stats = DirectoryStats(
total_files=result.files_found + result.folders_found,
audio_files=result.files_found,
total_size=0,
last_scan_time=time.time(),
scan_duration=result.scan_time,
errors=result.errors
)
self.cache.set(directory, stats)
return result
def start_watching(self, directory: str, callback):
"""Start watching a directory for changes"""
if directory in self.watchers:
return # Already watching
try:
observer = Observer()
handler = DirectoryWatcher(directory, callback)
observer.schedule(handler, directory, recursive=True)
observer.start()
self.watchers[directory] = observer
logger.info(f"Started watching directory: {directory}")
except Exception as e:
logger.error(f"Failed to start watching {directory}: {e}")
def stop_watching(self, directory: str):
"""Stop watching a directory"""
if directory in self.watchers:
observer = self.watchers.pop(directory)
observer.stop()
observer.join()
logger.info(f"Stopped watching directory: {directory}")
def stop_all_watching(self):
"""Stop watching all directories"""
for directory in list(self.watchers.keys()):
self.stop_watching(directory)
def get_scan_stats(self) -> Dict[str, Any]:
"""Get scanning statistics"""
return {
'cached_directories': len(self.cache.cache),
'watched_directories': len(self.watchers),
'scan_history': len(self.scan_history),
'last_scans': {
directory: history['last_scan']
for directory, history in self.scan_history.items()
}
}
# Global enhanced directory scanner instance
enhanced_directory_scanner = EnhancedDirectoryScanner()
@@ -0,0 +1,455 @@
"""
Enhanced UI Performance Service for SwingMusic
Provides virtual scrolling, lazy loading, and performance optimizations for large libraries
"""
import asyncio
import time
from typing import Dict, List, Optional, Any, Callable, Tuple
from dataclasses import dataclass
from enum import Enum
import json
from pathlib import Path
from swingmusic import logger
from swingmusic.db.sqlite.utils import get_db_connection
class ItemType(Enum):
TRACK = "track"
ALBUM = "album"
ARTIST = "artist"
PLAYLIST = "playlist"
FOLDER = "folder"
@dataclass
class VirtualItem:
"""Item in a virtual list"""
id: str
item_type: ItemType
title: str
subtitle: str
image_url: Optional[str]
data: Dict[str, Any]
index: int
height: int = 60
loaded: bool = False
visible: bool = False
@dataclass
class ViewportConfig:
"""Viewport configuration for virtual scrolling"""
item_height: int = 60
viewport_height: int = 600
buffer_size: int = 10
overscan: int = 5
@dataclass
class PerformanceMetrics:
"""Performance metrics for UI operations"""
render_time: float
item_count: int
visible_items: int
memory_usage: int
scroll_fps: float
class VirtualScrollManager:
"""Manages virtual scrolling for large lists"""
def __init__(self, config: ViewportConfig):
self.config = config
self.items: List[VirtualItem] = []
self.visible_start = 0
self.visible_end = 0
self.scroll_top = 0
self.last_render_time = 0
self.render_callbacks: List[Callable] = []
def set_items(self, items: List[VirtualItem]):
"""Set the items for virtual scrolling"""
self.items = items
self._update_visible_range()
def update_scroll_position(self, scroll_top: int):
"""Update scroll position and recalculate visible items"""
self.scroll_top = scroll_top
self._update_visible_range()
def _update_visible_range(self):
"""Calculate which items should be visible"""
if not self.items:
self.visible_start = 0
self.visible_end = 0
return
start_index = max(0, self.scroll_top // self.config.item_height - self.config.overscan)
end_index = min(
len(self.items),
((self.scroll_top + self.config.viewport_height) // self.config.item_height) + self.config.overscan
)
self.visible_start = start_index
self.visible_end = end_index
# Update item visibility
for i, item in enumerate(self.items):
item.visible = start_index <= i < end_index
def get_visible_items(self) -> List[VirtualItem]:
"""Get currently visible items"""
return self.items[self.visible_start:self.visible_end]
def get_total_height(self) -> int:
"""Get total height of all items"""
return len(self.items) * self.config.item_height
def get_offset_y(self) -> int:
"""Get Y offset for visible items"""
return self.visible_start * self.config.item_height
def add_render_callback(self, callback: Callable):
"""Add callback for render events"""
self.render_callbacks.append(callback)
def trigger_render(self):
"""Trigger render with performance tracking"""
start_time = time.time()
# Notify callbacks
for callback in self.render_callbacks:
try:
callback()
except Exception as e:
logger.error(f"Error in render callback: {e}")
self.last_render_time = time.time() - start_time
class LazyImageLoader:
"""Manages lazy loading of images with intersection observer simulation"""
def __init__(self, max_concurrent: int = 6):
self.max_concurrent = max_concurrent
self.loading_queue: List[Tuple[str, Callable]] = []
self.loading_images: Set[str] = set()
self.loaded_images: Dict[str, str] = {}
self.failed_images: Set[str] = set()
def load_image(self, image_url: str, callback: Callable[[str], None]):
"""Load an image with callback"""
if image_url in self.loaded_images:
callback(self.loaded_images[image_url])
return
if image_url in self.failed_images:
callback("") # Return empty string for failed images
return
if image_url in self.loading_images:
# Already loading, add to queue
self.loading_queue.append((image_url, callback))
return
self._start_loading(image_url, callback)
def _start_loading(self, image_url: str, callback: Callable[[str], None]):
"""Start loading an image"""
if len(self.loading_images) >= self.max_concurrent:
self.loading_queue.append((image_url, callback))
return
self.loading_images.add(image_url)
# Simulate image loading (in real implementation, use actual image loading)
asyncio.create_task(self._load_image_async(image_url, callback))
async def _load_image_async(self, image_url: str, callback: Callable[[str], None]):
"""Async image loading simulation"""
try:
# Simulate network delay
await asyncio.sleep(0.1)
# In real implementation, load actual image data
# For now, just return the URL as "loaded"
self.loaded_images[image_url] = image_url
# Remove from loading set
self.loading_images.discard(image_url)
# Call callback
callback(image_url)
# Process next in queue
if self.loading_queue:
next_url, next_callback = self.loading_queue.pop(0)
self._start_loading(next_url, next_callback)
except Exception as e:
logger.error(f"Error loading image {image_url}: {e}")
self.loading_images.discard(image_url)
self.failed_images.add(image_url)
callback("")
def preload_images(self, image_urls: List[str]):
"""Preload a list of images"""
for url in image_urls:
if url not in self.loaded_images and url not in self.failed_images:
self.load_image(url, lambda _: None)
class PerformanceOptimizer:
"""Optimizes UI performance for large datasets"""
def __init__(self):
self.metrics: List[PerformanceMetrics] = []
self.debounce_timers: Dict[str, float] = {}
self.throttle_intervals: Dict[str, float] = {}
def debounce(self, key: str, func: Callable, delay: float = 0.1):
"""Debounce function calls"""
current_time = time.time()
if key in self.debounce_timers:
if current_time - self.debounce_timers[key] < delay:
return
self.debounce_timers[key] = current_time
asyncio.create_task(self._debounce_async(key, func, delay))
async def _debounce_async(self, key: str, func: Callable, delay: float):
"""Async debounce implementation"""
await asyncio.sleep(delay)
# Check if still the latest call
if key in self.debounce_timers:
try:
func()
except Exception as e:
logger.error(f"Error in debounced function: {e}")
def throttle(self, key: str, func: Callable, interval: float = 0.016): # 60fps
"""Throttle function calls"""
current_time = time.time()
if key in self.throttle_intervals:
if current_time - self.throttle_intervals[key] < interval:
return
self.throttle_intervals[key] = current_time
try:
func()
except Exception as e:
logger.error(f"Error in throttled function: {e}")
def measure_performance(self, operation: str, func: Callable) -> Any:
"""Measure performance of an operation"""
start_time = time.time()
start_memory = self._get_memory_usage()
try:
result = func()
end_time = time.time()
end_memory = self._get_memory_usage()
metrics = PerformanceMetrics(
render_time=end_time - start_time,
item_count=0, # Would be context-specific
visible_items=0,
memory_usage=end_memory - start_memory,
scroll_fps=1.0 / (end_time - start_time) if end_time > start_time else 0
)
self.metrics.append(metrics)
logger.debug(f"Performance metrics for {operation}: {metrics.render_time:.3f}s")
return result
except Exception as e:
logger.error(f"Error in performance measurement for {operation}: {e}")
raise
def _get_memory_usage(self) -> int:
"""Get current memory usage (simplified)"""
try:
import psutil
return psutil.Process().memory_info().rss
except ImportError:
return 0
def get_average_performance(self) -> Optional[PerformanceMetrics]:
"""Get average performance metrics"""
if not self.metrics:
return None
avg_render_time = sum(m.render_time for m in self.metrics) / len(self.metrics)
avg_memory = sum(m.memory_usage for m in self.metrics) / len(self.metrics)
avg_fps = sum(m.scroll_fps for m in self.metrics) / len(self.metrics)
return PerformanceMetrics(
render_time=avg_render_time,
item_count=sum(m.item_count for m in self.metrics),
visible_items=sum(m.visible_items for m in self.metrics),
memory_usage=int(avg_memory),
scroll_fps=avg_fps
)
class EnhancedUIManager:
"""Enhanced UI manager with performance optimizations"""
def __init__(self):
self.virtual_scroll = VirtualScrollManager(ViewportConfig())
self.image_loader = LazyImageLoader()
self.performance_optimizer = PerformanceOptimizer()
self.cached_data: Dict[str, Any] = {}
self.cache_ttl = 300 # 5 minutes
async def get_tracks_paginated(self, offset: int = 0, limit: int = 50,
filters: Dict[str, Any] = None) -> Dict[str, Any]:
"""Get tracks with pagination and caching"""
cache_key = f"tracks_{offset}_{limit}_{json.dumps(filters or {})}"
# Check cache
if cache_key in self.cached_data:
cached_time, cached_data = self.cached_data[cache_key]
if time.time() - cached_time < self.cache_ttl:
return cached_data
# Fetch from database
try:
with get_db_connection() as conn:
query = """
SELECT t.trackhash, t.title, t.artists, t.album, t.duration,
t.bitrate, t.image, t.folderpath, t.filename
FROM tracks t
"""
conditions = []
params = []
if filters:
if 'artist' in filters:
conditions.append("t.artists LIKE ?")
params.append(f"%{filters['artist']}%")
if 'album' in filters:
conditions.append("t.album LIKE ?")
params.append(f"%{filters['album']}%")
if 'genre' in filters:
# Would need genre table join
pass
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY t.artists, t.album, t.tracknumber LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor = conn.execute(query, params)
tracks = cursor.fetchall()
# Get total count
count_query = "SELECT COUNT(*) FROM tracks t"
if conditions:
count_query += " WHERE " + " AND ".join(conditions)
cursor = conn.execute(count_query, params[:-2]) # Exclude limit/offset
total_count = cursor.fetchone()[0]
result = {
'tracks': [dict(track) for track in tracks],
'total': total_count,
'offset': offset,
'limit': limit
}
# Cache result
self.cached_data[cache_key] = (time.time(), result)
return result
except Exception as e:
logger.error(f"Error fetching tracks: {e}")
return {'tracks': [], 'total': 0, 'offset': offset, 'limit': limit}
def create_virtual_items(self, tracks: List[Dict[str, Any]]) -> List[VirtualItem]:
"""Create virtual items from track data"""
items = []
for i, track in enumerate(tracks):
item = VirtualItem(
id=track['trackhash'],
item_type=ItemType.TRACK,
title=track['title'],
subtitle=f"{track['artists']}{track['album']}",
image_url=track.get('image'),
data=track,
index=i
)
items.append(item)
return items
def optimize_scroll_performance(self, scroll_callback: Callable):
"""Optimize scroll performance with throttling"""
def optimized_scroll(scroll_top: int):
self.performance_optimizer.throttle(
'scroll',
lambda: self._handle_scroll(scroll_top, scroll_callback),
0.016 # 60fps
)
return optimized_scroll
def _handle_scroll(self, scroll_top: int, callback: Callable):
"""Handle scroll with virtual scrolling"""
self.virtual_scroll.update_scroll_position(scroll_top)
callback()
def preload_nearby_images(self, visible_items: List[VirtualItem]):
"""Preload images for visible and nearby items"""
image_urls = []
for item in visible_items:
if item.image_url:
image_urls.append(item.image_url)
# Add nearby items for smoother scrolling
start = max(0, self.virtual_scroll.visible_start - 5)
end = min(len(self.virtual_scroll.items), self.virtual_scroll.visible_end + 5)
for item in self.virtual_scroll.items[start:end]:
if item.image_url and item.image_url not in image_urls:
image_urls.append(item.image_url)
self.image_loader.preload_images(image_urls)
def clear_cache(self):
"""Clear all caches"""
self.cached_data.clear()
self.image_loader.loaded_images.clear()
self.image_loader.failed_images.clear()
def get_performance_report(self) -> Dict[str, Any]:
"""Get performance report"""
avg_metrics = self.performance_optimizer.get_average_performance()
return {
'average_render_time': avg_metrics.render_time if avg_metrics else 0,
'average_fps': avg_metrics.scroll_fps if avg_metrics else 0,
'memory_usage': avg_metrics.memory_usage if avg_metrics else 0,
'cached_items': len(self.cached_data),
'loaded_images': len(self.image_loader.loaded_images),
'failed_images': len(self.image_loader.failed_images),
'virtual_items': len(self.virtual_scroll.items),
'visible_items': len(self.virtual_scroll.get_visible_items())
}
# Global enhanced UI manager instance
enhanced_ui_manager = EnhancedUIManager()
@@ -0,0 +1,228 @@
"""
iOS Audio Compatibility Service for SwingMusic
Handles iOS-specific audio playback issues and format compatibility
"""
import os
import re
import subprocess
import tempfile
from typing import Optional, Dict, Any, Tuple
from pathlib import Path
from dataclasses import dataclass
from swingmusic import logger
from swingmusic.utils.files import guess_mime_type
@dataclass
class IOSAudioCapabilities:
"""iOS device audio capabilities"""
is_safari: bool
is_ios: bool
supports_flac: bool
supports_webm: bool
supports_alac: bool
supports_aac: bool
user_agent: str
optimal_format: str
optimal_codec: str
class IOSAudioManager:
"""Manages iOS audio compatibility and transcoding"""
def __init__(self):
self.temp_dir = tempfile.gettempdir()
self.transcode_cache = {}
def detect_ios_capabilities(self, user_agent: str = "") -> IOSAudioCapabilities:
"""Detect iOS device capabilities from user agent"""
is_safari = 'Safari' in user_agent and 'Chrome' not in user_agent
is_ios = bool(re.search(r'iPad|iPhone|iPod', user_agent))
# iOS format support matrix
supports_flac = False # iOS doesn't support FLAC natively
supports_webm = False # Limited WebM support on iOS
supports_alac = True # Apple Lossless supported on iOS
supports_aac = True # AAC widely supported
# Determine optimal format for iOS
if is_ios:
if supports_alac:
optimal_format = 'mp4' # ALAC in MP4 container
optimal_codec = 'alac'
else:
optimal_format = 'mp4' # AAC in MP4 container
optimal_codec = 'aac'
else:
optimal_format = 'flac' # Use original format for non-iOS
optimal_codec = 'flac'
return IOSAudioCapabilities(
is_safari=is_safari,
is_ios=is_ios,
supports_flac=supports_flac,
supports_webm=supports_webm,
supports_alac=supports_alac,
supports_aac=supports_aac,
user_agent=user_agent,
optimal_format=optimal_format,
optimal_codec=optimal_codec
)
def needs_transcoding(self, file_path: str, capabilities: IOSAudioCapabilities) -> bool:
"""Check if file needs transcoding for iOS compatibility"""
if not capabilities.is_ios:
return False
original_mime = guess_mime_type(file_path)
# iOS doesn't support FLAC, need transcoding
if original_mime == 'audio/flac' and not capabilities.supports_flac:
return True
# iOS has limited WebM support
if original_mime == 'audio/webm' and not capabilities.supports_webm:
return True
return False
def get_optimal_audio_format(self, file_path: str, capabilities: IOSAudioCapabilities) -> Tuple[str, str]:
"""Get optimal audio format and codec for the device"""
if not capabilities.is_ios:
# Return original format for non-iOS devices
original_mime = guess_mime_type(file_path)
if original_mime == 'audio/flac':
return 'flac', 'flac'
elif original_mime == 'audio/mpeg':
return 'mp3', 'mp3'
else:
return 'mp4', 'aac'
# Return iOS-optimized format
return capabilities.optimal_format, capabilities.optimal_codec
def transcode_for_ios(self, file_path: str, capabilities: IOSAudioCapabilities,
quality: str = "high") -> Optional[str]:
"""Transcode audio file for iOS compatibility"""
try:
# Check if already transcoded
cache_key = f"{file_path}_{capabilities.optimal_format}_{quality}"
if cache_key in self.transcode_cache:
cached_file = self.transcode_cache[cache_key]
if os.path.exists(cached_file):
return cached_file
# Create output file path
input_path = Path(file_path)
output_filename = f"{input_path.stem}_ios_{capabilities.optimal_format}.{capabilities.optimal_format}"
output_path = os.path.join(self.temp_dir, output_filename)
# Prepare FFmpeg command based on target format
if capabilities.optimal_codec == 'alac':
# Apple Lossless Audio Codec
cmd = [
'ffmpeg', '-i', file_path,
'-c:a', 'alac',
'-ar', '44100', # Sample rate
'-ac', '2', # Stereo
'-y', output_path
]
elif capabilities.optimal_codec == 'aac':
# AAC codec with quality settings
bitrate_map = {
'low': '96k',
'medium': '128k',
'high': '256k',
'lossless': '320k'
}
bitrate = bitrate_map.get(quality, '256k')
cmd = [
'ffmpeg', '-i', file_path,
'-c:a', 'aac',
'-b:a', bitrate,
'-ar', '44100',
'-ac', '2',
'-y', output_path
]
else:
# Default to AAC
cmd = [
'ffmpeg', '-i', file_path,
'-c:a', 'aac',
'-b:a', '256k',
'-ar', '44100',
'-ac', '2',
'-y', output_path
]
# Execute transcoding
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0 and os.path.exists(output_path):
# Cache the transcoded file
self.transcode_cache[cache_key] = output_path
logger.info(f"Successfully transcoded {file_path} for iOS: {output_path}")
return output_path
else:
logger.error(f"FFmpeg transcoding failed: {result.stderr}")
return None
except Exception as e:
logger.error(f"Error transcoding for iOS: {e}")
return None
def get_ios_compatible_mime_type(self, file_path: str, capabilities: IOSAudioCapabilities) -> str:
"""Get iOS-compatible MIME type"""
if not capabilities.is_ios:
return guess_mime_type(file_path)
if capabilities.optimal_format == 'mp4':
if capabilities.optimal_codec == 'alac':
return 'audio/mp4' # ALAC in MP4 container
else:
return 'audio/mp4' # AAC in MP4 container
elif capabilities.optimal_format == 'mp3':
return 'audio/mpeg'
else:
return 'audio/mp4' # Default to MP4 container for iOS
def create_ios_audio_source(self, file_path: str, capabilities: IOSAudioCapabilities,
quality: str = "high") -> Dict[str, Any]:
"""Create iOS-compatible audio source configuration"""
source_config = {
'file_path': file_path,
'needs_transcoding': self.needs_transcoding(file_path, capabilities),
'mime_type': self.get_ios_compatible_mime_type(file_path, capabilities),
'format': capabilities.optimal_format,
'codec': capabilities.optimal_codec
}
if source_config['needs_transcoding']:
transcoded_path = self.transcode_for_ios(file_path, capabilities, quality)
if transcoded_path:
source_config['transcoded_path'] = transcoded_path
source_config['file_path'] = transcoded_path
else:
# Fallback to original file if transcoding fails
logger.warning(f"Transcoding failed, using original file: {file_path}")
source_config['needs_transcoding'] = False
source_config['mime_type'] = guess_mime_type(file_path)
return source_config
def cleanup_transcoded_files(self):
"""Clean up temporary transcoded files"""
try:
for cached_file in self.transcode_cache.values():
if os.path.exists(cached_file):
os.remove(cached_file)
self.transcode_cache.clear()
except Exception as e:
logger.error(f"Error cleaning up transcoded files: {e}")
# Global iOS audio manager instance
ios_audio_manager = IOSAudioManager()
@@ -0,0 +1,283 @@
"""
Library integration service for Spotify downloads
Handles automatic addition of downloaded tracks to SwingMusic library
"""
import os
import hashlib
from pathlib import Path
from typing import Optional, Dict, Any
from datetime import datetime
from swingmusic.db.libdata import TrackTable
from swingmusic.db.engine import DbEngine
from swingmusic.config import UserConfig
from swingmusic.utils import create_valid_filename
from swingmusic import logger
class LibraryIntegrator:
"""Handles integration of downloaded tracks into SwingMusic library"""
def __init__(self):
self.config = UserConfig()
self.music_dirs = self.config.rootDirs
def add_downloaded_track(self, download_item: Dict[str, Any]) -> bool:
"""
Add a downloaded track to the SwingMusic library
Args:
download_item: Dictionary containing download information
Returns:
bool: True if successfully added, False otherwise
"""
try:
if not download_item.get('file_path') or not os.path.exists(download_item['file_path']):
logger.error(f"Downloaded file not found: {download_item.get('file_path')}")
return False
# Check if track already exists in library
if self._track_exists(download_item['file_path']):
logger.info(f"Track already exists in library: {download_item['file_path']}")
return True
# Create track record
track_data = self._create_track_data(download_item)
# Insert into database
self._insert_track(track_data)
logger.info(f"Added track to library: {track_data['title']} by {track_data['artists']}")
return True
except Exception as e:
logger.error(f"Error adding track to library: {e}")
return False
def add_downloaded_album(self, download_item: Dict[str, Any], track_files: list[str]) -> int:
"""
Add all tracks from a downloaded album to the library
Args:
download_item: Album download information
track_files: List of downloaded track file paths
Returns:
int: Number of tracks successfully added
"""
added_count = 0
try:
for track_file in track_files:
if not os.path.exists(track_file):
logger.warning(f"Track file not found: {track_file}")
continue
# Check if track already exists
if self._track_exists(track_file):
logger.info(f"Track already exists in library: {track_file}")
added_count += 1
continue
# Create track data for album track
track_data = self._create_album_track_data(download_item, track_file)
# Insert into database
self._insert_track(track_data)
added_count += 1
logger.info(f"Added {added_count} tracks from album to library")
return added_count
except Exception as e:
logger.error(f"Error adding album to library: {e}")
return added_count
def _track_exists(self, filepath: str) -> bool:
"""Check if track already exists in library"""
try:
with DbEngine.manager() as conn:
result = conn.execute(
TrackTable.select().where(TrackTable.filepath == filepath)
)
return result.scalar() is not None
except Exception as e:
logger.error(f"Error checking if track exists: {e}")
return False
def _create_track_data(self, download_item: Dict[str, Any]) -> Dict[str, Any]:
"""Create track data dictionary from download item"""
filepath = download_item['file_path']
file_stat = os.stat(filepath)
# Extract metadata from download item
title = download_item.get('title', 'Unknown Title')
artist = download_item.get('artist', 'Unknown Artist')
album = download_item.get('album', 'Unknown Album')
# Generate hashes
trackhash = self._generate_track_hash(filepath, title, artist)
albumhash = self._generate_album_hash(album, artist)
# Extract file information
folder = os.path.basename(os.path.dirname(filepath))
return {
'title': title,
'artists': artist,
'albumartists': artist,
'album': album,
'albumhash': albumhash,
'trackhash': trackhash,
'filepath': filepath,
'folder': folder,
'duration': download_item.get('duration_ms', 0) // 1000, # Convert to seconds
'bitrate': self._get_bitrate_from_quality(download_item.get('quality', 'flac')),
'date': self._parse_date(download_item.get('release_date')),
'track': download_item.get('track_number', 1),
'disc': 1,
'last_mod': int(file_stat.st_mtime),
'extra': {
'spotify_id': download_item.get('spotify_id'),
'source': download_item.get('source', 'spotify'),
'download_date': datetime.now().isoformat()
}
}
def _create_album_track_data(self, download_item: Dict[str, Any], track_file: str) -> Dict[str, Any]:
"""Create track data for album track"""
file_stat = os.stat(track_file)
# Extract filename for title (if metadata not available)
filename = os.path.splitext(os.path.basename(track_file))[0]
# Use download item metadata as base
title = download_item.get('title', filename)
artist = download_item.get('artist', 'Unknown Artist')
album = download_item.get('album', 'Unknown Album')
# Generate hashes
trackhash = self._generate_track_hash(track_file, title, artist)
albumhash = self._generate_album_hash(album, artist)
# Extract file information
folder = os.path.basename(os.path.dirname(track_file))
return {
'title': title,
'artists': artist,
'albumartists': artist,
'album': album,
'albumhash': albumhash,
'trackhash': trackhash,
'filepath': track_file,
'folder': folder,
'duration': download_item.get('duration_ms', 0) // 1000,
'bitrate': self._get_bitrate_from_quality(download_item.get('quality', 'flac')),
'date': self._parse_date(download_item.get('release_date')),
'track': download_item.get('track_number', 1),
'disc': 1,
'last_mod': int(file_stat.st_mtime),
'extra': {
'spotify_id': download_item.get('spotify_id'),
'source': download_item.get('source', 'spotify'),
'download_date': datetime.now().isoformat(),
'album_download': True
}
}
def _insert_track(self, track_data: Dict[str, Any]):
"""Insert track into database"""
try:
with DbEngine.manager(commit=True) as conn:
conn.execute(TrackTable.insert().values(track_data))
except Exception as e:
logger.error(f"Error inserting track: {e}")
raise
def _generate_track_hash(self, filepath: str, title: str, artist: str) -> str:
"""Generate unique track hash"""
content = f"{filepath}:{title}:{artist}"
return hashlib.md5(content.encode()).hexdigest()
def _generate_album_hash(self, album: str, artist: str) -> str:
"""Generate album hash"""
content = f"{album}:{artist}"
return hashlib.md5(content.encode()).hexdigest()
def _get_bitrate_from_quality(self, quality: str) -> int:
"""Get approximate bitrate based on quality"""
quality_bitrates = {
'flac': 1411, # Approximate FLAC bitrate
'mp3_320': 320,
'mp3_128': 128
}
return quality_bitrates.get(quality, 320)
def _parse_date(self, date_str: Optional[str]) -> Optional[int]:
"""Parse date string to timestamp"""
if not date_str:
return None
try:
# Try various date formats
formats = ['%Y-%m-%d', '%Y', '%Y-%m']
for fmt in formats:
try:
dt = datetime.strptime(date_str, fmt)
return int(dt.timestamp())
except ValueError:
continue
return None
except Exception:
return None
def remove_downloaded_track(self, filepath: str) -> bool:
"""
Remove a downloaded track from the library
Args:
filepath: Path to the track file
Returns:
bool: True if successfully removed
"""
try:
with DbEngine.manager(commit=True) as conn:
result = conn.execute(
TrackTable.delete().where(TrackTable.filepath == filepath)
)
return result.rowcount > 0
except Exception as e:
logger.error(f"Error removing track from library: {e}")
return False
def update_track_metadata(self, filepath: str, metadata: Dict[str, Any]) -> bool:
"""
Update metadata for a track in the library
Args:
filepath: Path to the track file
metadata: New metadata to apply
Returns:
bool: True if successfully updated
"""
try:
with DbEngine.manager(commit=True) as conn:
result = conn.execute(
TrackTable.update()
.where(TrackTable.filepath == filepath)
.values(metadata)
)
return result.rowcount > 0
except Exception as e:
logger.error(f"Error updating track metadata: {e}")
return False
# Global instance
library_integrator = LibraryIntegrator()
@@ -0,0 +1,296 @@
"""
Enhanced Metadata Aggregation System for Universal Music Downloader
Provides cross-service matching and metadata enrichment without API keys
"""
import re
import asyncio
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
@dataclass
class CrossServiceMatch:
"""Cross-service song match information"""
service: str
service_id: str
title: str
artist: str
url: str
confidence: float
isrc: Optional[str] = None
duration_ms: Optional[int] = None
release_date: Optional[str] = None
cover_art: Optional[str] = None
@dataclass
class EnhancedMetadata:
"""Enhanced metadata with cross-service information"""
primary_metadata: Any
cross_matches: List[CrossServiceMatch]
canonical_info: Optional[Dict[str, Any]] = None
confidence_score: float = 0.0
recommendations: List[str] = None
class MetadataAggregator:
"""Aggregates and enhances metadata from multiple sources"""
def __init__(self):
self.canonical_cache = {}
self.artist_aliases = {}
def normalize_title(self, title: str) -> str:
"""Normalize song title for better matching"""
# Remove extra whitespace and convert to lowercase
normalized = title.strip().lower()
# Remove common prefixes and suffixes
prefixes_to_remove = ['official video', 'official audio', 'lyrics', 'live', 'acoustic', 'remastered']
for prefix in prefixes_to_remove:
normalized = re.sub(rf'\s*{prefix}\s*', '', normalized, flags=re.IGNORECASE)
# Remove content in parentheses
normalized = re.sub(r'\s*\([^)]*\)\s*', '', normalized)
# Remove extra dashes and special characters
normalized = re.sub(r'\s*[-–—]\s*', ' ', normalized)
return normalized.strip()
def normalize_artist(self, artist: str) -> str:
"""Normalize artist name for better matching"""
normalized = artist.strip().lower()
# Remove "feat." and similar
normalized = re.sub(r'\s*feat\.\s*', ' feat. ', normalized)
# Handle "vs" collaborations
normalized = re.sub(r'\s+vs\s+', ' vs ', normalized)
return normalized.strip()
def calculate_similarity_score(self, title1: str, artist1: str, title2: str, artist2: str) -> float:
"""Calculate similarity score between two songs"""
title_score = 0.0
artist_score = 0.0
# Title similarity
if title1 and title2:
norm_title1 = self.normalize_title(title1)
norm_title2 = self.normalize_title(title2)
if norm_title1 == norm_title2:
title_score = 1.0
elif norm_title1 in norm_title2 or norm_title2 in norm_title1:
title_score = 0.8
else:
# Partial match based on words
words1 = set(norm_title1.split())
words2 = set(norm_title2.split())
common_words = words1.intersection(words2)
title_score = len(common_words) / max(len(words1), len(words2)) if words1 and words2 else 0.0
# Artist similarity
if artist1 and artist2:
norm_artist1 = self.normalize_artist(artist1)
norm_artist2 = self.normalize_artist(artist2)
if norm_artist1 == norm_artist2:
artist_score = 1.0
elif norm_artist1 in norm_artist2 or norm_artist2 in norm_artist1:
artist_score = 0.8
else:
# Partial match based on words
words1 = set(norm_artist1.split())
words2 = set(norm_artist2.split())
common_words = words1.intersection(words2)
artist_score = len(common_words) / max(len(words1), len(words2)) if words1 and words2 else 0.0
# Combined score (title is more important)
return (title_score * 0.7 + artist_score * 0.3)
def find_cross_service_matches(self, primary_metadata: Any, all_services_data: Dict[str, Any]) -> List[CrossServiceMatch]:
"""Find matches of the same song across other services"""
matches = []
if not primary_metadata:
return matches
primary_title = getattr(primary_metadata, 'title', '')
primary_artist = getattr(primary_metadata, 'artist', '')
primary_isrc = getattr(primary_metadata, 'isrc', None)
for service, data in all_services_data.items():
service_attr = getattr(primary_metadata, 'service', None)
if service_attr and service == service_attr.value:
continue # Skip: same service
service_title = getattr(data, 'title', '')
service_artist = getattr(data, 'artist', '')
service_url = getattr(data, 'original_url', '')
# Calculate similarity score
similarity = self.calculate_similarity_score(
primary_title, primary_artist,
service_title, service_artist
)
# Only include matches with reasonable similarity
if similarity >= 0.6: # 60% similarity threshold
match = CrossServiceMatch(
service=service,
service_id=getattr(data, 'service_id', ''),
title=service_title,
artist=service_artist,
url=service_url,
confidence=similarity,
isrc=getattr(data, 'isrc', None),
duration_ms=getattr(data, 'duration_ms', None),
release_date=getattr(data, 'release_date', None),
cover_art=getattr(data, 'image_url', None)
)
matches.append(match)
# Sort by confidence score
matches.sort(key=lambda x: x.confidence, reverse=True)
return matches
def get_canonical_info(self, isrc: str) -> Optional[Dict[str, Any]]:
"""Get canonical information from ISRC"""
if not isrc or len(isrc) != 12:
return None
# Parse ISRC: Country-Registration Year-Sequence Number
country = isrc[:2]
year = isrc[2:6]
sequence = isrc[6:]
return {
'isrc': isrc,
'country': country,
'year': year,
'sequence': sequence,
'type': 'recording' if sequence.isdigit() else 'other'
}
def generate_recommendations(self, metadata: Any, cross_matches: List[CrossServiceMatch]) -> List[str]:
"""Generate recommendations based on metadata and cross matches"""
recommendations = []
# Base recommendations on genre
genre = getattr(metadata, 'genre', '')
if genre:
recommendations.append(f"Similar {genre} tracks")
# Add recommendations from high-confidence cross matches
high_confidence_matches = [m for m in cross_matches if m.confidence >= 0.8]
for match in high_confidence_matches[:3]: # Top 3 matches
recommendations.append(f"Also available on {match.service}")
# Add recommendations based on artist
artist = getattr(metadata, 'artist', '')
if artist:
recommendations.append(f"More from {artist}")
return list(set(recommendations)) # Remove duplicates
def create_enhanced_metadata(self, primary_metadata: Any, cross_matches: List[CrossServiceMatch]) -> EnhancedMetadata:
"""Create enhanced metadata object"""
# Calculate confidence score
max_confidence = max([m.confidence for m in cross_matches]) if cross_matches else 0.0
# Get canonical info if ISRC exists
canonical_info = None
isrc = getattr(primary_metadata, 'isrc', None)
if isrc:
canonical_info = self.get_canonical_info(isrc)
# Generate recommendations
recommendations = self.generate_recommendations(primary_metadata, cross_matches)
return EnhancedMetadata(
primary_metadata=primary_metadata,
cross_matches=cross_matches,
canonical_info=canonical_info,
confidence_score=max_confidence,
recommendations=recommendations
)
class FreeMetadataEnricher:
"""Free metadata enrichment without API keys"""
def __init__(self):
self.aggregator = MetadataAggregator()
def extract_lyrics_snippet(self, title: str, artist: str) -> str:
"""Extract potential lyrics snippet for search enhancement"""
# This would use web scraping of lyrics sites
# For now, return empty to avoid copyright issues
return ""
def detect_language(self, title: str, artist: str) -> str:
"""Detect likely language from title and artist"""
# Simple heuristic based on character patterns
if any(ord(c) > 127 for c in title + artist):
return "non-english"
return "english"
def estimate_mood(self, title: str, artist: str) -> str:
"""Estimate mood from title and artist name"""
title_lower = title.lower()
artist_lower = artist.lower()
mood_keywords = {
'happy': ['love', 'joy', 'sun', 'summer', 'dance', 'party'],
'sad': ['cry', 'tears', 'rain', 'winter', 'goodbye', 'broken'],
'energetic': ['rock', 'power', 'energy', 'loud', 'fast'],
'calm': ['peace', 'quiet', 'soft', 'gentle', 'acoustic'],
'dark': ['dark', 'death', 'black', 'night', 'shadow']
}
for mood, keywords in mood_keywords.items():
if any(keyword in title_lower or keyword in artist_lower for keyword in keywords):
return mood
return "neutral"
def calculate_quality_score(self, metadata: Any) -> float:
"""Calculate metadata quality score"""
score = 0.0
# Check for ISRC (high quality indicator)
if getattr(metadata, 'isrc', None):
score += 0.3
# Check for release date
if getattr(metadata, 'release_date', None):
score += 0.2
# Check for genre information
if getattr(metadata, 'genre', None):
score += 0.2
# Check for cover art
if getattr(metadata, 'image_url', None):
score += 0.1
# Check for duration
if getattr(metadata, 'duration_ms', None):
score += 0.1
# Check for extended metadata
if getattr(metadata, 'metadata', None):
score += 0.1
return min(score, 1.0)
# Global instances
metadata_aggregator = MetadataAggregator()
free_enricher = FreeMetadataEnricher()
@@ -0,0 +1,732 @@
"""
Mobile Offline Mode Service
This service provides comprehensive mobile offline functionality including:
- Mobile download manager with intelligent queuing
- Offline sync service with conflict resolution
- Offline playback with adaptive streaming
- Storage management and optimization
- Background sync and progress tracking
"""
import asyncio
import datetime
import json
import logging
import os
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, asdict
from enum import Enum
from pathlib import Path
import hashlib
from sqlalchemy import select, update, delete, and_, or_, func
from sqlalchemy.orm import Session
from swingmusic.db import db
from swingmusic.models.user import User
from swingmusic.models.track import Track
from swingmusic.models.playlist import Playlist
from swingmusic.services.universal_music_downloader import UniversalMusicDownloader
from swingmusic.services.audio_quality_manager import audio_quality_manager
from swingmusic.utils.storage_manager import StorageManager
from swingmusic.utils.background_sync import BackgroundSyncManager
from swingmusic.config import USER_DATA_DIR
logger = logging.getLogger(__name__)
class SyncStatus(Enum):
"""Sync status for mobile devices"""
NOT_SYNCED = "not_synced"
SYNCING = "syncing"
SYNCED = "synced"
SYNC_ERROR = "sync_error"
CONFLICT = "conflict"
class OfflineQuality(Enum):
"""Offline download quality presets"""
SPACE_SAVER = "space_saver" # Low quality, maximum storage efficiency
BALANCED = "balanced" # Medium quality, good balance
HIGH_QUALITY = "high_quality" # High quality, more storage usage
LOSSLESS = "lossless" # Lossless quality, maximum storage usage
@dataclass
class MobileDevice:
"""Represents a mobile device registered for offline sync"""
device_id: str
user_id: int
device_name: str
device_type: str # android, ios
storage_capacity: int # in bytes
available_storage: int # in bytes
last_sync: Optional[datetime.datetime]
sync_status: SyncStatus
offline_quality: OfflineQuality
auto_sync_enabled: bool
sync_preferences: Dict[str, Any]
created_at: datetime.datetime
updated_at: datetime.datetime
@dataclass
class OfflineTrack:
"""Track available for offline playback"""
track_id: str
device_id: str
user_id: int
local_path: str
file_size: int
quality: str
download_date: datetime.datetime
last_played: Optional[datetime.datetime]
play_count: int
sync_version: int
checksum: str
is_available: bool
@dataclass
class SyncQueue:
"""Item in the sync queue for mobile devices"""
queue_id: str
device_id: str
track_id: str
user_id: int
priority: int # 1=highest, 5=lowest
quality: str
status: str # pending, downloading, completed, failed
progress: float # 0-100
error_message: Optional[str]
added_at: datetime.datetime
started_at: Optional[datetime.datetime]
completed_at: Optional[datetime.datetime]
@dataclass
class StorageUsage:
"""Storage usage information"""
total_capacity: int
used_space: int
available_space: int
offline_tracks_count: int
offline_tracks_size: int
other_data_size: int
quality_breakdown: Dict[str, int]
class MobileOfflineService:
"""Service for managing mobile offline functionality"""
def __init__(self):
self.storage_manager = StorageManager()
self.background_sync = BackgroundSyncManager()
self.universal_downloader = UniversalMusicDownloader()
self.mobile_data_dir = USER_DATA_DIR / "mobile"
self.mobile_data_dir.mkdir(exist_ok=True)
# Device settings
self.max_concurrent_downloads = 3
self.default_offline_quality = OfflineQuality.BALANCED
self.auto_cleanup_threshold = 0.9 # 90% storage usage triggers cleanup
async def register_device(self, user_id: int, device_info: Dict[str, Any]) -> MobileDevice:
"""
Register a new mobile device for offline sync
Args:
user_id: User ID
device_info: Device information including name, type, storage
Returns:
Registered device information
"""
try:
device_id = self._generate_device_id(user_id, device_info)
device = MobileDevice(
device_id=device_id,
user_id=user_id,
device_name=device_info.get('name', 'Unknown Device'),
device_type=device_info.get('type', 'unknown'),
storage_capacity=device_info.get('storage_capacity', 0),
available_storage=device_info.get('available_storage', 0),
last_sync=None,
sync_status=SyncStatus.NOT_SYNCED,
offline_quality=self.default_offline_quality,
auto_sync_enabled=True,
sync_preferences=device_info.get('preferences', {}),
created_at=datetime.datetime.utcnow(),
updated_at=datetime.datetime.utcnow()
)
# Save device to database
await self._save_device(device)
# Initialize device storage
await self._initialize_device_storage(device)
logger.info(f"Registered mobile device {device_id} for user {user_id}")
return device
except Exception as e:
logger.error(f"Error registering mobile device: {e}")
raise
async def add_to_offline_library(self, user_id: int, device_id: str, track_ids: List[str],
quality: Optional[OfflineQuality] = None) -> List[SyncQueue]:
"""
Add tracks to offline library for mobile device
Args:
user_id: User ID
device_id: Device ID
track_ids: List of track IDs to download
quality: Download quality (uses device default if None)
Returns:
List of sync queue items
"""
try:
# Get device information
device = await self._get_device(device_id, user_id)
if not device:
raise ValueError(f"Device {device_id} not found for user {user_id}")
# Use device quality if not specified
if quality is None:
quality = device.offline_quality
# Check storage availability
storage_usage = await self._get_storage_usage(device_id)
required_space = await self._estimate_download_size(track_ids, quality)
if storage_usage.available_space < required_space:
# Try to cleanup space
freed_space = await self._cleanup_old_content(device_id, required_space)
if freed_space < required_space:
raise ValueError(f"Insufficient storage space. Need {required_space} bytes, only {storage_usage.available_space} available")
# Add tracks to sync queue
queue_items = []
for track_id in track_ids:
# Check if already downloaded
existing = await self._get_offline_track(device_id, track_id)
if existing and existing.is_available:
continue
# Create queue item
queue_item = SyncQueue(
queue_id=self._generate_queue_id(),
device_id=device_id,
track_id=track_id,
user_id=user_id,
priority=self._calculate_download_priority(track_id, user_id),
quality=quality.value,
status='pending',
progress=0.0,
error_message=None,
added_at=datetime.datetime.utcnow(),
started_at=None,
completed_at=None
)
await self._add_to_sync_queue(queue_item)
queue_items.append(queue_item)
# Start background processing if not already running
await self._start_background_sync(device_id)
logger.info(f"Added {len(queue_items)} tracks to offline library for device {device_id}")
return queue_items
except Exception as e:
logger.error(f"Error adding tracks to offline library: {e}")
raise
async def sync_playlist_offline(self, user_id: int, device_id: str, playlist_id: str,
quality: Optional[OfflineQuality] = None) -> List[SyncQueue]:
"""
Sync entire playlist for offline playback
Args:
user_id: User ID
device_id: Device ID
playlist_id: Playlist ID to sync
quality: Download quality
Returns:
List of sync queue items
"""
try:
# Get playlist tracks
playlist_tracks = await self._get_playlist_tracks(user_id, playlist_id)
track_ids = [track['id'] for track in playlist_tracks]
# Add to offline library
return await self.add_to_offline_library(user_id, device_id, track_ids, quality)
except Exception as e:
logger.error(f"Error syncing playlist offline: {e}")
raise
async def get_offline_library(self, user_id: int, device_id: str) -> Dict[str, Any]:
"""
Get offline library for mobile device
Args:
user_id: User ID
device_id: Device ID
Returns:
Offline library information
"""
try:
# Get device information
device = await self._get_device(device_id, user_id)
if not device:
raise ValueError(f"Device {device_id} not found for user {user_id}")
# Get offline tracks
offline_tracks = await self._get_offline_tracks(device_id)
# Get sync queue status
queue_status = await self._get_sync_queue_status(device_id)
# Get storage usage
storage_usage = await self._get_storage_usage(device_id)
return {
'device': asdict(device),
'offline_tracks': [asdict(track) for track in offline_tracks],
'sync_queue': {
'pending_count': queue_status['pending'],
'downloading_count': queue_status['downloading'],
'completed_count': queue_status['completed'],
'failed_count': queue_status['failed'],
'total_count': queue_status['total']
},
'storage_usage': asdict(storage_usage),
'last_sync': device.last_sync,
'sync_status': device.sync_status.value
}
except Exception as e:
logger.error(f"Error getting offline library: {e}")
raise
async def remove_from_offline_library(self, user_id: int, device_id: str, track_ids: List[str]) -> bool:
"""
Remove tracks from offline library
Args:
user_id: User ID
device_id: Device ID
track_ids: List of track IDs to remove
Returns:
Success status
"""
try:
removed_count = 0
for track_id in track_ids:
# Get offline track
offline_track = await self._get_offline_track(device_id, track_id)
if not offline_track:
continue
# Remove local file
if os.path.exists(offline_track.local_path):
os.remove(offline_track.local_path)
# Remove from database
await self._remove_offline_track(device_id, track_id)
removed_count += 1
# Update storage usage
await self._update_storage_usage(device_id)
logger.info(f"Removed {removed_count} tracks from offline library for device {device_id}")
return True
except Exception as e:
logger.error(f"Error removing tracks from offline library: {e}")
return False
async def update_device_settings(self, user_id: int, device_id: str, settings: Dict[str, Any]) -> bool:
"""
Update device settings and preferences
Args:
user_id: User ID
device_id: Device ID
settings: Settings to update
Returns:
Success status
"""
try:
device = await self._get_device(device_id, user_id)
if not device:
return False
# Update settings
if 'offline_quality' in settings:
device.offline_quality = OfflineQuality(settings['offline_quality'])
if 'auto_sync_enabled' in settings:
device.auto_sync_enabled = settings['auto_sync_enabled']
if 'sync_preferences' in settings:
device.sync_preferences.update(settings['sync_preferences'])
if 'storage_capacity' in settings:
device.storage_capacity = settings['storage_capacity']
if 'available_storage' in settings:
device.available_storage = settings['available_storage']
device.updated_at = datetime.datetime.utcnow()
# Save updated device
await self._save_device(device)
logger.info(f"Updated settings for device {device_id}")
return True
except Exception as e:
logger.error(f"Error updating device settings: {e}")
return False
async def get_sync_progress(self, user_id: int, device_id: str) -> Dict[str, Any]:
"""
Get sync progress for mobile device
Args:
user_id: User ID
device_id: Device ID
Returns:
Sync progress information
"""
try:
# Get queue items
queue_items = await self._get_sync_queue_items(device_id)
# Calculate progress
total_items = len(queue_items)
completed_items = len([item for item in queue_items if item.status == 'completed'])
downloading_items = len([item for item in queue_items if item.status == 'downloading'])
failed_items = len([item for item in queue_items if item.status == 'failed'])
# Calculate overall progress
overall_progress = 0.0
if total_items > 0:
total_progress = sum(item.progress for item in queue_items)
overall_progress = total_progress / total_items
# Get currently downloading items
current_downloads = [item for item in queue_items if item.status == 'downloading']
return {
'total_items': total_items,
'completed_items': completed_items,
'downloading_items': downloading_items,
'failed_items': failed_items,
'overall_progress': round(overall_progress, 2),
'current_downloads': [
{
'track_id': item.track_id,
'progress': item.progress,
'quality': item.quality,
'added_at': item.added_at.isoformat()
}
for item in current_downloads
],
'estimated_time_remaining': await self._estimate_sync_time_remaining(device_id)
}
except Exception as e:
logger.error(f"Error getting sync progress: {e}")
raise
async def force_sync_now(self, user_id: int, device_id: str) -> bool:
"""
Force immediate sync for mobile device
Args:
user_id: User ID
device_id: Device ID
Returns:
Success status
"""
try:
device = await self._get_device(device_id, user_id)
if not device:
return False
# Update sync status
device.sync_status = SyncStatus.SYNCING
device.last_sync = datetime.datetime.utcnow()
await self._save_device(device)
# Start background sync
await self._start_background_sync(device_id, force=True)
logger.info(f"Force sync started for device {device_id}")
return True
except Exception as e:
logger.error(f"Error forcing sync: {e}")
return False
# Private helper methods
def _generate_device_id(self, user_id: int, device_info: Dict[str, Any]) -> str:
"""Generate unique device ID"""
device_string = f"{user_id}_{device_info.get('type', 'unknown')}_{device_info.get('name', 'unknown')}"
return hashlib.sha256(device_string.encode()).hexdigest()[:16]
def _generate_queue_id(self) -> str:
"""Generate unique queue ID"""
return hashlib.sha256(f"{datetime.datetime.utcnow().isoformat()}".encode()).hexdigest()[:16]
async def _save_device(self, device: MobileDevice):
"""Save device to database"""
# This would save to database - simplified for now
device_file = self.mobile_data_dir / f"device_{device.device_id}.json"
with open(device_file, 'w') as f:
json.dump(asdict(device), f, default=str)
async def _get_device(self, device_id: str, user_id: int) -> Optional[MobileDevice]:
"""Get device from database"""
device_file = self.mobile_data_dir / f"device_{device_id}.json"
if not device_file.exists():
return None
with open(device_file, 'r') as f:
device_data = json.load(f)
if device_data['user_id'] != user_id:
return None
return MobileDevice(**device_data)
async def _initialize_device_storage(self, device: MobileDevice):
"""Initialize storage for device"""
device_storage = self.mobile_data_dir / device.device_id
device_storage.mkdir(exist_ok=True)
# Create subdirectories
(device_storage / "tracks").mkdir(exist_ok=True)
(device_storage / "metadata").mkdir(exist_ok=True)
(device_storage / "cache").mkdir(exist_ok=True)
async def _get_storage_usage(self, device_id: str) -> StorageUsage:
"""Get storage usage information"""
device_storage = self.mobile_data_dir / device_id
if not device_storage.exists():
return StorageUsage(0, 0, 0, 0, 0, 0, {})
# Calculate directory sizes
total_size = 0
tracks_size = 0
tracks_count = 0
for file_path in device_storage.rglob("*"):
if file_path.is_file():
file_size = file_path.stat().st_size
total_size += file_size
if file_path.parent.name == "tracks":
tracks_size += file_size
tracks_count += 1
# Get device capacity (this would come from device info)
device = await self._get_device(device_id, None) # user_id not needed for this
return StorageUsage(
total_capacity=device.storage_capacity if device else 0,
used_space=total_size,
available_space=device.available_storage if device else 0,
offline_tracks_count=tracks_count,
offline_tracks_size=tracks_size,
other_data_size=total_size - tracks_size,
quality_breakdown={} # Would calculate by quality
)
async def _estimate_download_size(self, track_ids: List[str], quality: OfflineQuality) -> int:
"""Estimate download size for tracks"""
# Simplified estimation - would use actual track metadata
quality_sizes = {
OfflineQuality.SPACE_SAVER: 3 * 1024 * 1024, # 3MB per track
OfflineQuality.BALANCED: 6 * 1024 * 1024, # 6MB per track
OfflineQuality.HIGH_QUALITY: 12 * 1024 * 1024, # 12MB per track
OfflineQuality.LOSSLESS: 30 * 1024 * 1024, # 30MB per track
}
return len(track_ids) * quality_sizes.get(quality, quality_sizes[OfflineQuality.BALANCED])
async def _cleanup_old_content(self, device_id: str, required_space: int) -> int:
"""Cleanup old content to free space"""
# Get offline tracks sorted by last played
offline_tracks = await self._get_offline_tracks(device_id)
# Sort by last played (oldest first)
offline_tracks.sort(key=lambda t: t.last_played or datetime.datetime.min)
freed_space = 0
for track in offline_tracks:
if freed_space >= required_space:
break
# Remove track
if os.path.exists(track.local_path):
file_size = os.path.getsize(track.local_path)
os.remove(track.local_path)
freed_space += file_size
# Mark as unavailable
track.is_available = False
await self._save_offline_track(track)
return freed_space
async def _add_to_sync_queue(self, queue_item: SyncQueue):
"""Add item to sync queue"""
queue_file = self.mobile_data_dir / f"queue_{queue_item.device_id}.json"
# Load existing queue
queue = []
if queue_file.exists():
with open(queue_file, 'r') as f:
queue = json.load(f)
# Add new item
queue.append(asdict(queue_item))
# Save queue
with open(queue_file, 'w') as f:
json.dump(queue, f, default=str)
async def _get_sync_queue_items(self, device_id: str) -> List[SyncQueue]:
"""Get all sync queue items for device"""
queue_file = self.mobile_data_dir / f"queue_{device_id}.json"
if not queue_file.exists():
return []
with open(queue_file, 'r') as f:
queue_data = json.load(f)
return [SyncQueue(**item) for item in queue_data]
async def _calculate_download_priority(self, track_id: str, user_id: int) -> int:
"""Calculate download priority for track"""
# This would consider factors like:
# - User's favorite tracks
# - Recently played tracks
# - Playlist membership
# - User preferences
# Simplified for now
return 3 # Medium priority
async def _start_background_sync(self, device_id: str, force: bool = False):
"""Start background sync process"""
# This would integrate with BackgroundSyncManager
# For now, just log the request
logger.info(f"Background sync requested for device {device_id}, force={force}")
async def _get_offline_track(self, device_id: str, track_id: str) -> Optional[OfflineTrack]:
"""Get offline track information"""
tracks_dir = self.mobile_data_dir / device_id / "metadata"
track_file = tracks_dir / f"{track_id}.json"
if not track_file.exists():
return None
with open(track_file, 'r') as f:
track_data = json.load(f)
return OfflineTrack(**track_data)
async def _save_offline_track(self, track: OfflineTrack):
"""Save offline track information"""
tracks_dir = self.mobile_data_dir / track.device_id / "metadata"
tracks_dir.mkdir(exist_ok=True)
track_file = tracks_dir / f"{track.track_id}.json"
with open(track_file, 'w') as f:
json.dump(asdict(track), f, default=str)
async def _get_offline_tracks(self, device_id: str) -> List[OfflineTrack]:
"""Get all offline tracks for device"""
tracks_dir = self.mobile_data_dir / device_id / "metadata"
if not tracks_dir.exists():
return []
tracks = []
for track_file in tracks_dir.glob("*.json"):
with open(track_file, 'r') as f:
track_data = json.load(f)
tracks.append(OfflineTrack(**track_data))
return tracks
async def _remove_offline_track(self, device_id: str, track_id: str):
"""Remove offline track from database"""
tracks_dir = self.mobile_data_dir / device_id / "metadata"
track_file = tracks_dir / f"{track_id}.json"
if track_file.exists():
track_file.unlink()
async def _get_sync_queue_status(self, device_id: str) -> Dict[str, int]:
"""Get sync queue status summary"""
queue_items = await self._get_sync_queue_items(device_id)
status_counts = {
'pending': 0,
'downloading': 0,
'completed': 0,
'failed': 0,
'total': len(queue_items)
}
for item in queue_items:
status_counts[item.status] = status_counts.get(item.status, 0) + 1
return status_counts
async def _update_storage_usage(self, device_id: str):
"""Update storage usage information"""
# This would update device storage information
# For now, just recalculate
await self._get_storage_usage(device_id)
async def _get_playlist_tracks(self, user_id: int, playlist_id: str) -> List[Dict[str, Any]]:
"""Get tracks in playlist"""
# This would query the database for playlist tracks
# Simplified for now
return []
async def _estimate_sync_time_remaining(self, device_id: str) -> Optional[int]:
"""Estimate time remaining for sync completion"""
queue_items = await self._get_sync_queue_items(device_id)
pending_items = [item for item in queue_items if item.status in ['pending', 'downloading']]
if not pending_items:
return None
# Estimate based on average download time
avg_time_per_track = 30 # seconds
return len(pending_items) * avg_time_per_track
# Global service instance
mobile_offline_service = MobileOfflineService()
+904
View File
@@ -0,0 +1,904 @@
"""
Music Catalog Service for SwingMusic
Provides Spotify-like browsing of global music catalog with download capabilities
"""
import os
import json
import time
import asyncio
import aiohttp
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, asdict
from enum import Enum
from datetime import datetime, timedelta
from swingmusic import logger
from swingmusic.db.sqlite.utils import get_db_connection
class CatalogItemType(Enum):
TRACK = "track"
ALBUM = "album"
ARTIST = "artist"
PLAYLIST = "playlist"
@dataclass
class CatalogItem:
"""Represents an item in the global music catalog"""
spotify_id: str
item_type: CatalogItemType
title: str
artist: str
album: Optional[str] = None
duration_ms: Optional[int] = None
popularity: Optional[int] = None
preview_url: Optional[str] = None
image_url: Optional[str] = None
release_date: Optional[str] = None
explicit: bool = False
data: Optional[Dict[str, Any]] = None
cached_at: Optional[datetime] = None
expires_at: Optional[datetime] = None
@dataclass
class ArtistInfo:
"""Extended artist information with top tracks"""
spotify_id: str
name: str
image_url: Optional[str] = None
followers: Optional[int] = None
popularity: Optional[int] = None
genres: Optional[List[str]] = None
top_tracks: Optional[List[CatalogItem]] = None
albums: Optional[List[CatalogItem]] = None
related_artists: Optional[List[Dict]] = None
@dataclass
class SearchResult:
"""Global search result across all content types"""
tracks: List[CatalogItem]
albums: List[CatalogItem]
artists: List[CatalogItem]
playlists: List[CatalogItem]
total: int
query: str
class MusicCatalogService:
"""Service for managing global music catalog with caching"""
def __init__(self):
self.cache_ttl = 3600 # 1 hour default cache TTL
self.max_top_tracks = 15
self.max_albums_per_artist = 20
self.session = None
async def _get_session(self):
"""Get or create aiohttp session"""
if self.session is None:
self.session = aiohttp.ClientSession()
return self.session
async def close(self):
"""Close aiohttp session"""
if self.session:
await self.session.close()
def _get_spotify_client(self):
"""Get Spotify metadata client"""
try:
from swingmusic.services.spotify_metadata_client import spotify_metadata_client
return spotify_metadata_client
except ImportError:
logger.warning("Spotify metadata client not available for catalog service")
return None
async def get_artist_top_tracks(self, artist_id: str, limit: int = 15) -> List[CatalogItem]:
"""
Get artist's most popular tracks
Args:
artist_id: Spotify artist ID
limit: Maximum number of tracks to return
Returns:
List of popular tracks
"""
try:
# Check cache first
cached_tracks = await self._get_cached_artist_top_tracks(artist_id, limit)
if cached_tracks:
return cached_tracks
# Fetch from Spotify API
spotify_client = self._get_spotify_client()
if not spotify_client:
return []
# This would integrate with the existing Spotify metadata client
# For now, return empty list - integration point
tracks_data = await self._fetch_artist_top_tracks_from_spotify(artist_id, limit)
# Cache the results
await self._cache_artist_top_tracks(artist_id, tracks_data)
return tracks_data
except Exception as e:
logger.error(f"Error getting artist top tracks: {e}")
return []
async def get_artist_discography(self, artist_id: str) -> List[CatalogItem]:
"""
Get complete artist discography with albums
Args:
artist_id: Spotify artist ID
Returns:
List of artist albums
"""
try:
# Check cache first
cached_albums = await self._get_cached_artist_albums(artist_id)
if cached_albums:
return cached_albums
# Fetch from Spotify API
spotify_client = self._get_spotify_client()
if not spotify_client:
return []
albums_data = await self._fetch_artist_albums_from_spotify(artist_id)
# Cache the results
await self._cache_artist_albums(artist_id, albums_data)
return albums_data
except Exception as e:
logger.error(f"Error getting artist discography: {e}")
return []
async def get_album_details(self, album_id: str) -> Optional[CatalogItem]:
"""
Get full album information with tracklist
Args:
album_id: Spotify album ID
Returns:
Album details with tracklist
"""
try:
# Check cache first
cached_album = await self._get_cached_album(album_id)
if cached_album:
return cached_album
# Fetch from Spotify API
spotify_client = self._get_spotify_client()
if not spotify_client:
return None
album_data = await self._fetch_album_details_from_spotify(album_id)
# Cache the result
await self._cache_album(album_id, album_data)
return album_data
except Exception as e:
logger.error(f"Error getting album details: {e}")
return None
async def search_global_catalog(self, query: str, item_type: str = "all", limit: int = 20) -> SearchResult:
"""
Search across all music types in global catalog
Args:
query: Search query
item_type: Type of content to search (all, tracks, albums, artists, playlists)
limit: Maximum results per type
Returns:
Search results across specified types
"""
try:
# Check cache first
cache_key = f"search:{query}:{item_type}:{limit}"
cached_result = await self._get_cached_search(cache_key)
if cached_result:
return cached_result
# Search different types based on request
tracks = []
albums = []
artists = []
playlists = []
spotify_client = self._get_spotify_client()
if spotify_client:
if item_type in ["all", "tracks"]:
tracks = await self._search_tracks(query, limit)
if item_type in ["all", "albums"]:
albums = await self._search_albums(query, limit)
if item_type in ["all", "artists"]:
artists = await self._search_artists(query, limit)
if item_type in ["all", "playlists"]:
playlists = await self._search_playlists(query, limit)
result = SearchResult(
tracks=tracks,
albums=albums,
artists=artists,
playlists=playlists,
total=len(tracks) + len(albums) + len(artists) + len(playlists),
query=query
)
# Cache the search result
await self._cache_search(cache_key, result)
return result
except Exception as e:
logger.error(f"Error searching global catalog: {e}")
return SearchResult([], [], [], [], 0, query)
async def get_artist_info(self, artist_id: str) -> Optional[ArtistInfo]:
"""
Get comprehensive artist information including top tracks and albums
Args:
artist_id: Spotify artist ID
Returns:
Complete artist information
"""
try:
# Check cache first
cached_info = await self._get_cached_artist_info(artist_id)
if cached_info:
return cached_info
# Fetch all artist data concurrently
top_tracks_task = self.get_artist_top_tracks(artist_id, self.max_top_tracks)
albums_task = self.get_artist_discography(artist_id)
basic_info_task = self._get_artist_basic_info(artist_id)
top_tracks, albums, basic_info = await asyncio.gather(
top_tracks_task, albums_task, basic_info_task, return_exceptions=True
)
if isinstance(basic_info, Exception):
logger.error(f"Error getting basic artist info: {basic_info}")
return None
artist_info = ArtistInfo(
spotify_id=artist_id,
name=basic_info.get("name", ""),
image_url=basic_info.get("image_url"),
followers=basic_info.get("followers"),
popularity=basic_info.get("popularity"),
genres=basic_info.get("genres", []),
top_tracks=top_tracks if not isinstance(top_tracks, Exception) else [],
albums=albums if not isinstance(albums, Exception) else [],
related_artists=basic_info.get("related_artists", [])
)
# Cache the complete artist info
await self._cache_artist_info(artist_id, artist_info)
return artist_info
except Exception as e:
logger.error(f"Error getting artist info: {e}")
return None
# Cache management methods
async def _get_cached_artist_top_tracks(self, artist_id: str, limit: int) -> Optional[List[CatalogItem]]:
"""Get cached top tracks for artist"""
try:
with get_db_connection() as conn:
query = """
SELECT data FROM global_catalog_cache
WHERE spotify_id = ? AND item_type = 'artist_top_tracks'
AND expires_at > datetime('now')
ORDER BY cached_at DESC LIMIT 1
"""
cursor = conn.execute(query, (artist_id,))
row = cursor.fetchone()
if row:
data = json.loads(row[0])
return [CatalogItem(**item) for item in data.get('tracks', [])[:limit]]
except Exception as e:
logger.error(f"Error getting cached artist top tracks: {e}")
return None
async def _cache_artist_top_tracks(self, artist_id: str, tracks: List[CatalogItem]):
"""Cache artist top tracks"""
try:
expires_at = datetime.now() + timedelta(seconds=self.cache_ttl)
with get_db_connection() as conn:
conn.execute("""
INSERT OR REPLACE INTO global_catalog_cache
(spotify_id, item_type, title, artist, data, cached_at, expires_at)
VALUES (?, 'artist_top_tracks', ?, ?, ?, datetime('now'), ?)
""", (
artist_id,
f"Top tracks for {artist_id}",
"",
json.dumps({"tracks": [asdict(track) for track in tracks]}),
expires_at.isoformat()
))
conn.commit()
except Exception as e:
logger.error(f"Error caching artist top tracks: {e}")
async def _get_cached_artist_albums(self, artist_id: str) -> Optional[List[CatalogItem]]:
"""Get cached albums for artist"""
try:
with get_db_connection() as conn:
query = """
SELECT data FROM global_catalog_cache
WHERE spotify_id = ? AND item_type = 'artist_albums'
AND expires_at > datetime('now')
ORDER BY cached_at DESC LIMIT 1
"""
cursor = conn.execute(query, (artist_id,))
row = cursor.fetchone()
if row:
data = json.loads(row[0])
return [CatalogItem(**item) for item in data.get('albums', [])]
except Exception as e:
logger.error(f"Error getting cached artist albums: {e}")
return None
async def _cache_artist_albums(self, artist_id: str, albums: List[CatalogItem]):
"""Cache artist albums"""
try:
expires_at = datetime.now() + timedelta(seconds=self.cache_ttl)
with get_db_connection() as conn:
conn.execute("""
INSERT OR REPLACE INTO global_catalog_cache
(spotify_id, item_type, title, artist, data, cached_at, expires_at)
VALUES (?, 'artist_albums', ?, ?, ?, datetime('now'), ?)
""", (
artist_id,
f"Albums for {artist_id}",
"",
json.dumps({"albums": [asdict(album) for album in albums]}),
expires_at.isoformat()
))
conn.commit()
except Exception as e:
logger.error(f"Error caching artist albums: {e}")
async def _get_cached_album(self, album_id: str) -> Optional[CatalogItem]:
"""Get cached album details"""
try:
with get_db_connection() as conn:
query = """
SELECT * FROM global_catalog_cache
WHERE spotify_id = ? AND item_type = 'album'
AND expires_at > datetime('now')
ORDER BY cached_at DESC LIMIT 1
"""
cursor = conn.execute(query, (album_id,))
row = cursor.fetchone()
if row:
return CatalogItem(
spotify_id=row[1],
item_type=CatalogItemType(row[2]),
title=row[3],
artist=row[4],
album=row[5],
duration_ms=row[6],
popularity=row[7],
preview_url=row[8],
image_url=row[9],
release_date=row[10],
explicit=bool(row[11]),
data=json.loads(row[12]) if row[12] else None
)
except Exception as e:
logger.error(f"Error getting cached album: {e}")
return None
async def _cache_album(self, album_id: str, album: CatalogItem):
"""Cache album details"""
try:
expires_at = datetime.now() + timedelta(seconds=self.cache_ttl)
with get_db_connection() as conn:
conn.execute("""
INSERT OR REPLACE INTO global_catalog_cache
(spotify_id, item_type, title, artist, album, duration_ms,
popularity, preview_url, image_url, release_date, explicit, data, cached_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?)
""", (
album.spotify_id,
album.item_type.value,
album.title,
album.artist,
album.album,
album.duration_ms,
album.popularity,
album.preview_url,
album.image_url,
album.release_date,
album.explicit,
json.dumps(asdict(album)) if album.data else None,
expires_at.isoformat()
))
conn.commit()
except Exception as e:
logger.error(f"Error caching album: {e}")
async def _get_cached_search(self, cache_key: str) -> Optional[SearchResult]:
"""Get cached search results"""
try:
with get_db_connection() as conn:
query = """
SELECT data FROM global_catalog_cache
WHERE spotify_id = ? AND item_type = 'search'
AND expires_at > datetime('now')
ORDER BY cached_at DESC LIMIT 1
"""
cursor = conn.execute(query, (cache_key,))
row = cursor.fetchone()
if row:
data = json.loads(row[0])
return SearchResult(
tracks=[CatalogItem(**item) for item in data.get('tracks', [])],
albums=[CatalogItem(**item) for item in data.get('albums', [])],
artists=[CatalogItem(**item) for item in data.get('artists', [])],
playlists=[CatalogItem(**item) for item in data.get('playlists', [])],
total=data.get('total', 0),
query=data.get('query', '')
)
except Exception as e:
logger.error(f"Error getting cached search: {e}")
return None
async def _cache_search(self, cache_key: str, result: SearchResult):
"""Cache search results"""
try:
expires_at = datetime.now() + timedelta(seconds=self.cache_ttl // 2) # Shorter cache for searches
with get_db_connection() as conn:
conn.execute("""
INSERT OR REPLACE INTO global_catalog_cache
(spotify_id, item_type, title, artist, data, cached_at, expires_at)
VALUES (?, 'search', ?, ?, ?, datetime('now'), ?)
""", (
cache_key,
f"Search: {result.query}",
"",
json.dumps({
'tracks': [asdict(track) for track in result.tracks],
'albums': [asdict(album) for album in result.albums],
'artists': [asdict(artist) for artist in result.artists],
'playlists': [asdict(playlist) for playlist in result.playlists],
'total': result.total,
'query': result.query
}),
expires_at.isoformat()
))
conn.commit()
except Exception as e:
logger.error(f"Error caching search: {e}")
async def _get_cached_artist_info(self, artist_id: str) -> Optional[ArtistInfo]:
"""Get cached complete artist info"""
try:
with get_db_connection() as conn:
query = """
SELECT data FROM global_catalog_cache
WHERE spotify_id = ? AND item_type = 'artist_info'
AND expires_at > datetime('now')
ORDER BY cached_at DESC LIMIT 1
"""
cursor = conn.execute(query, (artist_id,))
row = cursor.fetchone()
if row:
data = json.loads(row[0])
return ArtistInfo(
spotify_id=data['spotify_id'],
name=data['name'],
image_url=data.get('image_url'),
followers=data.get('followers'),
popularity=data.get('popularity'),
genres=data.get('genres', []),
top_tracks=[CatalogItem(**item) for item in data.get('top_tracks', [])],
albums=[CatalogItem(**item) for item in data.get('albums', [])],
related_artists=data.get('related_artists', [])
)
except Exception as e:
logger.error(f"Error getting cached artist info: {e}")
return None
async def _cache_artist_info(self, artist_id: str, artist_info: ArtistInfo):
"""Cache complete artist info"""
try:
expires_at = datetime.now() + timedelta(seconds=self.cache_ttl)
with get_db_connection() as conn:
conn.execute("""
INSERT OR REPLACE INTO global_catalog_cache
(spotify_id, item_type, title, artist, data, cached_at, expires_at)
VALUES (?, 'artist_info', ?, ?, ?, datetime('now'), ?)
""", (
artist_id,
f"Artist info: {artist_info.name}",
"",
json.dumps({
'spotify_id': artist_info.spotify_id,
'name': artist_info.name,
'image_url': artist_info.image_url,
'followers': artist_info.followers,
'popularity': artist_info.popularity,
'genres': artist_info.genres,
'top_tracks': [asdict(track) for track in artist_info.top_tracks or []],
'albums': [asdict(album) for album in artist_info.albums or []],
'related_artists': artist_info.related_artists or []
}),
expires_at.isoformat()
))
conn.commit()
except Exception as e:
logger.error(f"Error caching artist info: {e}")
# Spotify API integration methods
async def _fetch_artist_top_tracks_from_spotify(self, artist_id: str, limit: int) -> List[CatalogItem]:
"""Fetch artist top tracks from Spotify API"""
try:
spotify_client = self._get_spotify_client()
if not spotify_client:
return []
tracks = spotify_client.get_artist_top_tracks(artist_id, market='US')
catalog_items = []
for track in tracks[:limit]:
catalog_item = CatalogItem(
spotify_id=track.id,
item_type=CatalogItemType.TRACK,
title=track.name,
artist=', '.join([artist['name'] for artist in track.artists]),
album=track.album['name'] if track.album else None,
duration_ms=track.duration_ms,
popularity=track.popularity,
preview_url=track.preview_url,
image_url=track.album['images'][0]['url'] if track.album and track.album.get('images') else None,
explicit=track.explicit,
data={
'artists': track.artists,
'album': track.album,
'external_urls': track.external_urls,
'track_number': track.track_number,
'disc_number': track.disc_number,
'available_markets': track.available_markets
}
)
catalog_items.append(catalog_item)
return catalog_items
except Exception as e:
logger.error(f"Error fetching artist top tracks from Spotify: {e}")
return []
async def _fetch_artist_albums_from_spotify(self, artist_id: str) -> List[CatalogItem]:
"""Fetch artist albums from Spotify API"""
try:
spotify_client = self._get_spotify_client()
if not spotify_client:
return []
albums = spotify_client.get_artist_albums(artist_id, limit=self.max_albums_per_artist)
catalog_items = []
for album in albums:
catalog_item = CatalogItem(
spotify_id=album.id,
item_type=CatalogItemType.ALBUM,
title=album.name,
artist=', '.join([artist['name'] for artist in album.artists]),
album=album.name,
popularity=album.popularity,
image_url=album.images[0]['url'] if album.images else None,
release_date=album.release_date,
explicit=False, # Albums don't have explicit flag in API
data={
'artists': album.artists,
'total_tracks': album.total_tracks,
'external_urls': album.external_urls,
'available_markets': album.available_markets,
'album_type': album.album_type
}
)
catalog_items.append(catalog_item)
return catalog_items
except Exception as e:
logger.error(f"Error fetching artist albums from Spotify: {e}")
return []
async def _fetch_album_details_from_spotify(self, album_id: str) -> Optional[CatalogItem]:
"""Fetch album details from Spotify API"""
try:
spotify_client = self._get_spotify_client()
if not spotify_client:
return None
album = spotify_client.get_album(album_id)
if not album:
return None
# Get album tracks
tracks = spotify_client.get_album_tracks(album_id)
catalog_item = CatalogItem(
spotify_id=album.id,
item_type=CatalogItemType.ALBUM,
title=album.name,
artist=', '.join([artist['name'] for artist in album.artists]),
album=album.name,
popularity=album.popularity,
image_url=album.images[0]['url'] if album.images else None,
release_date=album.release_date,
explicit=False,
data={
'artists': album.artists,
'total_tracks': album.total_tracks,
'external_urls': album.external_urls,
'available_markets': album.available_markets,
'album_type': album.album_type,
'tracks': [
{
'id': track.id,
'name': track.name,
'artists': track.artists,
'duration_ms': track.duration_ms,
'track_number': track.track_number,
'disc_number': track.disc_number,
'explicit': track.explicit,
'preview_url': track.preview_url
}
for track in tracks
]
}
)
return catalog_item
except Exception as e:
logger.error(f"Error fetching album details from Spotify: {e}")
return None
async def _search_tracks(self, query: str, limit: int) -> List[CatalogItem]:
"""Search tracks in Spotify catalog"""
try:
spotify_client = self._get_spotify_client()
if not spotify_client:
return []
search_results = spotify_client.search(query, 'track', limit=limit)
catalog_items = []
for track in search_results.get('tracks', []):
catalog_item = CatalogItem(
spotify_id=track.id,
item_type=CatalogItemType.TRACK,
title=track.name,
artist=', '.join([artist['name'] for artist in track.artists]),
album=track.album['name'] if track.album else None,
duration_ms=track.duration_ms,
popularity=track.popularity,
preview_url=track.preview_url,
image_url=track.album['images'][0]['url'] if track.album and track.album.get('images') else None,
explicit=track.explicit,
data={
'artists': track.artists,
'album': track.album,
'external_urls': track.external_urls,
'track_number': track.track_number,
'disc_number': track.disc_number,
'available_markets': track.available_markets
}
)
catalog_items.append(catalog_item)
return catalog_items
except Exception as e:
logger.error(f"Error searching tracks: {e}")
return []
async def _search_albums(self, query: str, limit: int) -> List[CatalogItem]:
"""Search albums in Spotify catalog"""
try:
spotify_client = self._get_spotify_client()
if not spotify_client:
return []
search_results = spotify_client.search(query, 'album', limit=limit)
catalog_items = []
for album in search_results.get('albums', []):
catalog_item = CatalogItem(
spotify_id=album.id,
item_type=CatalogItemType.ALBUM,
title=album.name,
artist=', '.join([artist['name'] for artist in album.artists]),
album=album.name,
popularity=album.popularity,
image_url=album.images[0]['url'] if album.images else None,
release_date=album.release_date,
explicit=False,
data={
'artists': album.artists,
'total_tracks': album.total_tracks,
'external_urls': album.external_urls,
'available_markets': album.available_markets,
'album_type': album.album_type
}
)
catalog_items.append(catalog_item)
return catalog_items
except Exception as e:
logger.error(f"Error searching albums: {e}")
return []
async def _search_artists(self, query: str, limit: int) -> List[CatalogItem]:
"""Search artists in Spotify catalog"""
try:
spotify_client = self._get_spotify_client()
if not spotify_client:
return []
search_results = spotify_client.search(query, 'artist', limit=limit)
catalog_items = []
for artist in search_results.get('artists', []):
catalog_item = CatalogItem(
spotify_id=artist.id,
item_type=CatalogItemType.ARTIST,
title=artist.name,
artist=artist.name,
popularity=artist.popularity,
image_url=artist.images[0]['url'] if artist.images else None,
explicit=False,
data={
'followers': artist.followers,
'genres': artist.genres,
'external_urls': artist.external_urls
}
)
catalog_items.append(catalog_item)
return catalog_items
except Exception as e:
logger.error(f"Error searching artists: {e}")
return []
async def _search_playlists(self, query: str, limit: int) -> List[CatalogItem]:
"""Search playlists in Spotify catalog"""
try:
spotify_client = self._get_spotify_client()
if not spotify_client:
return []
search_results = spotify_client.search(query, 'playlist', limit=limit)
catalog_items = []
for playlist in search_results.get('playlists', []):
catalog_item = CatalogItem(
spotify_id=playlist.id,
item_type=CatalogItemType.PLAYLIST,
title=playlist.name,
artist=playlist.owner['display_name'] if playlist.owner else '',
popularity=0, # Playlists don't have popularity
image_url=playlist.images[0]['url'] if playlist.images else None,
explicit=False,
data={
'description': playlist.description,
'owner': playlist.owner,
'public': playlist.public,
'collaborative': playlist.collaborative,
'tracks': playlist.tracks,
'external_urls': playlist.external_urls
}
)
catalog_items.append(catalog_item)
return catalog_items
except Exception as e:
logger.error(f"Error searching playlists: {e}")
return []
async def _get_artist_basic_info(self, artist_id: str) -> Optional[Dict[str, Any]]:
"""Get basic artist information from Spotify API"""
try:
spotify_client = self._get_spotify_client()
if not spotify_client:
return None
artist = spotify_client.get_artist(artist_id)
if not artist:
return None
# Get related artists
related_artists = spotify_client.get_related_artists(artist_id)
return {
'name': artist.name,
'image_url': artist.images[0]['url'] if artist.images else None,
'followers': artist.followers.get('total', 0) if artist.followers else 0,
'popularity': artist.popularity,
'genres': artist.genres,
'related_artists': [
{
'id': related.id,
'name': related.name,
'popularity': related.popularity,
'image_url': related.images[0]['url'] if related.images else None
}
for related in related_artists[:10] # Limit to 10 related artists
]
}
except Exception as e:
logger.error(f"Error getting basic artist info: {e}")
return None
def cleanup_expired_cache(self):
"""Clean up expired cache entries"""
try:
with get_db_connection() as conn:
conn.execute("""
DELETE FROM global_catalog_cache
WHERE expires_at < datetime('now')
""")
conn.commit()
logger.info("Cleaned up expired catalog cache entries")
except Exception as e:
logger.error(f"Error cleaning up expired cache: {e}")
# Global instance
music_catalog_service = MusicCatalogService()
@@ -0,0 +1,315 @@
"""
MusicBrainz API v2 Client for Universal Music Downloader
Provides comprehensive music metadata from MusicBrainz database
"""
import aiohttp
import asyncio
from typing import Dict, List, Optional, Any
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
@dataclass
class MusicBrainzRecording:
"""MusicBrainz recording metadata"""
mbid: str
title: str
artist: str
artist_mbid: Optional[str] = None
release: Optional[str] = None
release_mbid: Optional[str] = None
isrc: Optional[str] = None
duration: Optional[int] = None
position: Optional[int] = None
genres: List[str] = None
release_date: Optional[str] = None
country: Optional[str] = None
tags: List[str] = None
cover_art: Optional[str] = None
@dataclass
class MusicBrainzArtist:
"""MusicBrainz artist metadata"""
mbid: str
name: str
sort_name: Optional[str] = None
disambiguation: Optional[str] = None
country: Optional[str] = None
life_span: Optional[Dict[str, str]] = None
genres: List[str] = None
tags: List[str] = None
rating: Optional[float] = None
class MusicBrainzClient:
"""MusicBrainz API v2 client"""
def __init__(self, app_name: str = "SwingMusic", app_version: str = "1.0.0"):
self.base_url = "https://musicbrainz.org/ws/2"
self.app_name = app_name
self.app_version = app_version
self.session = None
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session"""
if self.session is None:
self.session = aiohttp.ClientSession()
return self.session
def _build_url(self, endpoint: str, params: Dict[str, str] = None) -> str:
"""Build MusicBrainz API URL"""
url = f"{self.base_url}/{endpoint}"
if params:
param_string = "&".join([f"{k}={v}" for k, v in params.items()])
url += f"?{param_string}"
return url
async def lookup_recording(self, mbid: str, includes: List[str] = None) -> Optional[MusicBrainzRecording]:
"""Lookup detailed recording information"""
try:
session = await self._get_session()
params = {}
if includes:
params['inc'] = ",".join(includes)
url = self._build_url(f"recording/{mbid}", params)
headers = {
'User-Agent': f'{self.app_name}/{self.app_version}',
'Accept': 'application/json'
}
async with session.get(url, headers=headers) as response:
if response.status == 200:
data = await response.json()
return self._parse_recording_response(data)
else:
logger.warning(f"MusicBrainz recording lookup failed: {response.status}")
return None
except Exception as e:
logger.error(f"Error looking up MusicBrainz recording: {e}")
return None
async def lookup_artist(self, mbid: str, includes: List[str] = None) -> Optional[MusicBrainzArtist]:
"""Lookup detailed artist information"""
try:
session = await self._get_session()
params = {}
if includes:
params['inc'] = ",".join(includes)
url = self._build_url(f"artist/{mbid}", params)
headers = {
'User-Agent': f'{self.app_name}/{self.app_version}',
'Accept': 'application/json'
}
async with session.get(url, headers=headers) as response:
if response.status == 200:
data = await response.json()
return self._parse_artist_response(data)
else:
logger.warning(f"MusicBrainz artist lookup failed: {response.status}")
return None
except Exception as e:
logger.error(f"Error looking up MusicBrainz artist: {e}")
return None
async def search_recordings(self, query: str, artist: str = None, limit: int = 25) -> List[MusicBrainzRecording]:
"""Search for recordings"""
try:
session = await self._get_session()
params = {
'query': f'"{query}"',
'limit': str(limit)
}
if artist:
params['artist'] = f'"{artist}"'
url = self._build_url("recording", params)
headers = {
'User-Agent': f'{self.app_name}/{self.app_version}',
'Accept': 'application/json'
}
async with session.get(url, headers=headers) as response:
if response.status == 200:
data = await response.json()
return self._parse_recording_list_response(data)
else:
logger.warning(f"MusicBrainz recording search failed: {response.status}")
return []
except Exception as e:
logger.error(f"Error searching MusicBrainz recordings: {e}")
return []
async def get_artist_releases(self, mbid: str, release_types: List[str] = None) -> List[str]:
"""Get all releases for an artist"""
try:
session = await self._get_session()
params = {}
if release_types:
params['type'] = ",".join(release_types)
url = self._build_url(f"release", {'artist': mbid, **params})
headers = {
'User-Agent': f'{self.app_name}/{self.app_version}',
'Accept': 'application/json'
}
async with session.get(url, headers=headers) as response:
if response.status == 200:
data = await response.json()
releases = data.get('releases', [])
return [release.get('id', '') for release in releases]
else:
logger.warning(f"MusicBrainz artist releases failed: {response.status}")
return []
except Exception as e:
logger.error(f"Error getting MusicBrainz artist releases: {e}")
return []
def _parse_recording_response(self, data: Dict[str, Any]) -> Optional[MusicBrainzRecording]:
"""Parse MusicBrainz recording response"""
try:
recording_data = data.get('recording')
if not recording_data:
return None
# Extract basic info
title = recording_data.get('title', '')
# Extract artist info
artist_credit = recording_data.get('artist-credit', [])
artist = artist_credit[0].get('artist', {}).get('name', '') if artist_credit else ''
artist_mbid = artist_credit[0].get('artist', {}).get('id') if artist_credit else None
# Extract release info
release_list = recording_data.get('release-list', [])
release = release_list[0] if release_list else None
release_title = release.get('title', '') if release else None
release_mbid = release.get('id') if release else None
# Extract ISRC
isrc_list = recording_data.get('isrc-list', [])
isrc = isrc_list[0] if isrc_list else None
# Extract duration
duration = recording_data.get('length')
# Extract tags and genres
tag_list = recording_data.get('tag-list', [])
tags = [tag.get('name', '') for tag in tag_list]
# Extract release info
release_info = recording_data.get('release', {})
release_date = release_info.get('date')
country = release_info.get('country')
# Extract cover art
cover_art = None
if release:
cover_art_archive = release.get('cover-art-archive', [])
if cover_art_archive:
cover_art = cover_art_archive[0].get('image')
return MusicBrainzRecording(
mbid=data.get('id', ''),
title=title,
artist=artist,
artist_mbid=artist_mbid,
release=release_title,
release_mbid=release_mbid,
isrc=isrc,
duration=duration,
position=recording_data.get('position'),
genres=tags,
release_date=release_date,
country=country,
tags=tags,
cover_art=cover_art
)
except Exception as e:
logger.error(f"Error parsing MusicBrainz recording response: {e}")
return None
def _parse_artist_response(self, data: Dict[str, Any]) -> Optional[MusicBrainzArtist]:
"""Parse MusicBrainz artist response"""
try:
artist_data = data.get('artist')
if not artist_data:
return None
name = artist_data.get('name', '')
sort_name = artist_data.get('sort-name')
disambiguation = artist_data.get('disambiguation')
country = artist_data.get('country')
# Extract life span
life_span = artist_data.get('life-span')
# Extract tags and genres
tag_list = artist_data.get('tag-list', [])
tags = [tag.get('name', '') for tag in tag_list]
# Extract rating
rating = artist_data.get('rating', {}).get('value')
return MusicBrainzArtist(
mbid=data.get('id', ''),
name=name,
sort_name=sort_name,
disambiguation=disambiguation,
country=country,
life_span=life_span,
genres=tags,
tags=tags,
rating=rating
)
except Exception as e:
logger.error(f"Error parsing MusicBrainz artist response: {e}")
return None
def _parse_recording_list_response(self, data: Dict[str, Any]) -> List[MusicBrainzRecording]:
"""Parse MusicBrainz recording list response"""
try:
recordings = []
recording_list = data.get('recordings', [])
for recording_data in recording_list:
recording = self._parse_recording_response({'recording': recording_data})
if recording:
recordings.append(recording)
return recordings
except Exception as e:
logger.error(f"Error parsing MusicBrainz recording list: {e}")
return []
async def close(self):
"""Close the aiohttp session"""
if self.session:
await self.session.close()
# Global instance
musicbrainz_client = MusicBrainzClient()
+785
View File
@@ -0,0 +1,785 @@
"""
Year-in-Review Experience Service
This service provides comprehensive year-in-review generation including:
- Listening statistics and analytics
- Personalized music insights
- Video generation with Remotion
- Social sharing capabilities
- Interactive data visualization
"""
import asyncio
import datetime
import json
import logging
import os
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, asdict
from enum import Enum
from pathlib import Path
from sqlalchemy import select, func, and_, or_
from sqlalchemy.orm import Session
from swingmusic.db import db
from swingmusic.models.user import User
from swingmusic.models.track import Track
from swingmusic.models.playlog import Playlog
from swingmusic.config import USER_DATA_DIR
logger = logging.getLogger(__name__)
class RecapTheme(Enum):
"""Available recap themes"""
MODERN = "modern"
RETRO = "retro"
MINIMAL = "minimal"
VIBRANT = "vibrant"
DARK = "dark"
LIGHT = "light"
@dataclass
class ListeningStats:
"""User listening statistics for a time period"""
total_minutes: int
total_tracks: int
total_artists: int
total_albums: int
unique_tracks: int
average_daily_minutes: float
most_played_track: Optional[Dict]
most_played_artist: Optional[Dict]
most_played_album: Optional[Dict]
top_genres: List[Dict]
listening_streak: int
longest_session: int
favorite_time_of_day: str
discovery_rate: float
repeat_listen_rate: float
@dataclass
class MusicPersonality:
"""User music personality analysis"""
personality_type: str
traits: List[str]
description: str
diversity_score: float
exploration_score: float
loyalty_score: float
mood_profile: Dict[str, float]
genre_preferences: Dict[str, float]
audio_preferences: Dict[str, Any]
@dataclass
class RecapData:
"""Complete year-in-review data package"""
user_id: int
year: int
stats: ListeningStats
personality: MusicPersonality
monthly_breakdown: List[Dict]
top_tracks: List[Dict]
top_artists: List[Dict]
top_albums: List[Dict]
discoveries: List[Dict]
milestones: List[Dict]
created_at: datetime.datetime
class RecapService:
"""Service for generating comprehensive year-in-review experiences"""
def __init__(self):
self.recap_dir = USER_DATA_DIR / "recaps"
self.recap_dir.mkdir(exist_ok=True)
async def generate_year_recap(self, user_id: int, year: int) -> RecapData:
"""
Generate comprehensive year-in-review data
Args:
user_id: User ID
year: Year to generate recap for
Returns:
Complete recap data
"""
try:
logger.info(f"Generating year recap for user {user_id}, year {year}")
# Get listening data for the year
start_date = datetime.datetime(year, 1, 1)
end_date = datetime.datetime(year, 12, 31, 23, 59, 59)
# Generate all components
stats = await self._calculate_listening_stats(user_id, start_date, end_date)
personality = await self._analyze_music_personality(user_id, start_date, end_date)
monthly_breakdown = await self._get_monthly_breakdown(user_id, year)
top_tracks = await self._get_top_tracks(user_id, start_date, end_date, 50)
top_artists = await self._get_top_artists(user_id, start_date, end_date, 25)
top_albums = await self._get_top_albums(user_id, start_date, end_date, 25)
discoveries = await self._get_new_discoveries(user_id, start_date, end_date)
milestones = await self._calculate_milestones(stats, personality)
recap_data = RecapData(
user_id=user_id,
year=year,
stats=stats,
personality=personality,
monthly_breakdown=monthly_breakdown,
top_tracks=top_tracks,
top_artists=top_artists,
top_albums=top_albums,
discoveries=discoveries,
milestones=milestones,
created_at=datetime.datetime.utcnow()
)
# Save recap data
await self._save_recap_data(recap_data)
return recap_data
except Exception as e:
logger.error(f"Error generating year recap: {e}")
raise
async def get_recap_summary(self, user_id: int, year: int) -> Optional[Dict]:
"""
Get recap summary for quick display
Args:
user_id: User ID
year: Year to get summary for
Returns:
Recap summary or None if not available
"""
try:
recap_file = self.recap_dir / f"recap_{user_id}_{year}.json"
if not recap_file.exists():
return None
with open(recap_file, 'r') as f:
recap_data = json.load(f)
# Return summary data
return {
'year': recap_data['year'],
'total_minutes': recap_data['stats']['total_minutes'],
'total_tracks': recap_data['stats']['total_tracks'],
'top_track': recap_data['stats']['most_played_track'],
'top_artist': recap_data['stats']['most_played_artist'],
'personality_type': recap_data['personality']['personality_type'],
'created_at': recap_data['created_at']
}
except Exception as e:
logger.error(f"Error getting recap summary: {e}")
return None
async def _calculate_listening_stats(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime) -> ListeningStats:
"""Calculate comprehensive listening statistics"""
try:
with Session(db.engine) as session:
# Get all plays for the period
plays_query = select(Playlog).where(
and_(
Playlog.user_id == user_id,
Playlog.played_at >= start_date,
Playlog.played_at <= end_date
)
).order_by(Playlog.played_at)
plays = session.execute(plays_query).scalars().all()
if not plays:
return ListeningStats(
total_minutes=0, total_tracks=0, total_artists=0, total_albums=0,
unique_tracks=0, average_daily_minutes=0.0, most_played_track=None,
most_played_artist=None, most_played_album=None, top_genres=[],
listening_streak=0, longest_session=0, favorite_time_of_day="",
discovery_rate=0.0, repeat_listen_rate=0.0
)
# Basic statistics
total_minutes = sum(play.duration or 0 for play in plays)
unique_tracks = len(set(play.track_id for play in plays))
total_tracks = len(plays)
# Get track details for artist/album counts
track_ids = list(set(play.track_id for play in plays))
tracks_query = select(Track).where(Track.id.in_(track_ids))
tracks = session.execute(tracks_query).scalars().all()
unique_artists = len(set(track.artist for track in tracks))
unique_albums = len(set(track.album for track in tracks))
# Most played items
track_counts = {}
artist_counts = {}
album_counts = {}
for play in plays:
track = next((t for t in tracks if t.id == play.track_id), None)
if track:
# Track counts
track_counts[track.id] = track_counts.get(track.id, 0) + 1
# Artist counts
artist_counts[track.artist] = artist_counts.get(track.artist, 0) + 1
# Album counts
album_counts[track.album] = album_counts.get(track.album, 0) + 1
most_played_track_id = max(track_counts, key=track_counts.get) if track_counts else None
most_played_track = None
if most_played_track_id:
track = next((t for t in tracks if t.id == most_played_track_id), None)
if track:
most_played_track = {
'id': track.id,
'title': track.title,
'artist': track.artist,
'album': track.album,
'play_count': track_counts[most_played_track_id]
}
most_played_artist_name = max(artist_counts, key=artist_counts.get) if artist_counts else None
most_played_artist = {
'name': most_played_artist_name,
'play_count': artist_counts.get(most_played_artist_name, 0)
} if most_played_artist_name else None
most_played_album_name = max(album_counts, key=album_counts.get) if album_counts else None
most_played_album = {
'name': most_played_album_name,
'play_count': album_counts.get(most_played_album_name, 0)
} if most_played_album_name else None
# Calculate additional stats
days_in_period = (end_date - start_date).days + 1
average_daily_minutes = total_minutes / days_in_period
# Listening streak (consecutive days with plays)
listening_streak = await self._calculate_listening_streak(plays)
# Longest session
longest_session = await self._calculate_longest_session(plays)
# Favorite time of day
favorite_time_of_day = await self._calculate_favorite_time_of_day(plays)
# Discovery and repeat rates
discovery_rate = await self._calculate_discovery_rate(user_id, plays)
repeat_listen_rate = (total_tracks - unique_tracks) / total_tracks if total_tracks > 0 else 0
return ListeningStats(
total_minutes=int(total_minutes),
total_tracks=total_tracks,
total_artists=unique_artists,
total_albums=unique_albums,
unique_tracks=unique_tracks,
average_daily_minutes=average_daily_minutes,
most_played_track=most_played_track,
most_played_artist=most_played_artist,
most_played_album=most_played_album,
top_genres=[], # Would need genre data from tracks
listening_streak=listening_streak,
longest_session=longest_session,
favorite_time_of_day=favorite_time_of_day,
discovery_rate=discovery_rate,
repeat_listen_rate=repeat_listen_rate
)
except Exception as e:
logger.error(f"Error calculating listening stats: {e}")
raise
async def _analyze_music_personality(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime) -> MusicPersonality:
"""Analyze user's music personality based on listening patterns"""
try:
# This is a simplified version - would integrate with audio analyzer for deeper insights
with Session(db.engine) as session:
plays_query = select(Playlog).where(
and_(
Playlog.user_id == user_id,
Playlog.played_at >= start_date,
Playlog.played_at <= end_date
)
)
plays = session.execute(plays_query).scalars().all()
if not plays:
return MusicPersonality(
personality_type="Explorer",
traits=["Curious", "Open-minded"],
description="You love discovering new music",
diversity_score=0.8,
exploration_score=0.9,
loyalty_score=0.3,
mood_profile={"energetic": 0.6, "relaxed": 0.4},
genre_preferences={},
audio_preferences={}
)
# Analyze patterns
track_ids = list(set(play.track_id for play in plays))
tracks_query = select(Track).where(Track.id.in_(track_ids))
tracks = session.execute(tracks_query).scalars().all()
# Calculate metrics
unique_tracks = len(track_ids)
total_plays = len(plays)
diversity_score = unique_tracks / total_plays if total_plays > 0 else 0
# Determine personality type based on patterns
if diversity_score > 0.7:
personality_type = "Explorer"
traits = ["Curious", "Open-minded", "Adventurous"]
description = "You love discovering new music and exploring different genres"
elif diversity_score > 0.4:
personality_type = "Balanced"
traits = ["Versatile", "Open-minded", "Selective"]
description = "You enjoy both new discoveries and familiar favorites"
else:
personality_type = "Loyalist"
traits = ["Dedicated", "Selective", "Consistent"]
description = "You prefer to stick with what you love and dive deep into favorites"
return MusicPersonality(
personality_type=personality_type,
traits=traits,
description=description,
diversity_score=diversity_score,
exploration_score=diversity_score, # Simplified
loyalty_score=1.0 - diversity_score, # Simplified
mood_profile={"energetic": 0.6, "relaxed": 0.4}, # Would analyze audio features
genre_preferences={}, # Would analyze genre data
audio_preferences={} # Would analyze audio features
)
except Exception as e:
logger.error(f"Error analyzing music personality: {e}")
raise
async def _get_monthly_breakdown(self, user_id: int, year: int) -> List[Dict]:
"""Get monthly listening breakdown"""
try:
monthly_data = []
for month in range(1, 13):
start_date = datetime.datetime(year, month, 1)
if month == 12:
end_date = datetime.datetime(year, 12, 31, 23, 59, 59)
else:
end_date = datetime.datetime(year, month + 1, 1) - datetime.timedelta(seconds=1)
with Session(db.engine) as session:
plays_query = select(func.sum(Playlog.duration)).where(
and_(
Playlog.user_id == user_id,
Playlog.played_at >= start_date,
Playlog.played_at <= end_date
)
)
total_minutes = session.execute(plays_query).scalar() or 0
# Get track count
count_query = select(func.count(Playlog.id)).where(
and_(
Playlog.user_id == user_id,
Playlog.played_at >= start_date,
Playlog.played_at <= end_date
)
)
track_count = session.execute(count_query).scalar() or 0
monthly_data.append({
'month': month,
'month_name': datetime.date(year, month, 1).strftime('%B'),
'total_minutes': int(total_minutes),
'track_count': track_count
})
return monthly_data
except Exception as e:
logger.error(f"Error getting monthly breakdown: {e}")
return []
async def _get_top_tracks(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime, limit: int) -> List[Dict]:
"""Get top tracks for the period"""
try:
with Session(db.engine) as session:
# Get play counts
play_counts_query = select(
Playlog.track_id,
func.count(Playlog.id).label('play_count'),
func.sum(Playlog.duration).label('total_duration')
).where(
and_(
Playlog.user_id == user_id,
Playlog.played_at >= start_date,
Playlog.played_at <= end_date
)
).group_by(Playlog.track_id).order_by(func.count(Playlog.id).desc()).limit(limit)
play_counts = session.execute(play_counts_query).all()
top_tracks = []
for play_count in play_counts:
track = session.get(Track, play_count.track_id)
if track:
top_tracks.append({
'id': track.id,
'title': track.title,
'artist': track.artist,
'album': track.album,
'play_count': play_count.play_count,
'total_duration': int(play_count.total_duration or 0),
'image': track.image
})
return top_tracks
except Exception as e:
logger.error(f"Error getting top tracks: {e}")
return []
async def _get_top_artists(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime, limit: int) -> List[Dict]:
"""Get top artists for the period"""
try:
with Session(db.engine) as session:
# Get artist play counts
artist_counts_query = select(
Track.artist,
func.count(Playlog.id).label('play_count'),
func.sum(Playlog.duration).label('total_duration'),
func.count(func.distinct(Track.id)).label('unique_tracks')
).join(Playlog, Track.id == Playlog.track_id).where(
and_(
Playlog.user_id == user_id,
Playlog.played_at >= start_date,
Playlog.played_at <= end_date
)
).group_by(Track.artist).order_by(func.count(Playlog.id).desc()).limit(limit)
artist_counts = session.execute(artist_counts_query).all()
top_artists = []
for artist_count in artist_counts:
top_artists.append({
'name': artist_count.artist,
'play_count': artist_count.play_count,
'total_duration': int(artist_count.total_duration or 0),
'unique_tracks': artist_count.unique_tracks
})
return top_artists
except Exception as e:
logger.error(f"Error getting top artists: {e}")
return []
async def _get_top_albums(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime, limit: int) -> List[Dict]:
"""Get top albums for the period"""
try:
with Session(db.engine) as session:
# Get album play counts
album_counts_query = select(
Track.album,
Track.artist,
func.count(Playlog.id).label('play_count'),
func.sum(Playlog.duration).label('total_duration'),
func.count(func.distinct(Track.id)).label('unique_tracks')
).join(Playlog, Track.id == Playlog.track_id).where(
and_(
Playlog.user_id == user_id,
Playlog.played_at >= start_date,
Playlog.played_at <= end_date
)
).group_by(Track.album, Track.artist).order_by(func.count(Playlog.id).desc()).limit(limit)
album_counts = session.execute(album_counts_query).all()
top_albums = []
for album_count in album_counts:
top_albums.append({
'name': album_count.album,
'artist': album_count.artist,
'play_count': album_count.play_count,
'total_duration': int(album_count.total_duration or 0),
'unique_tracks': album_count.unique_tracks
})
return top_albums
except Exception as e:
logger.error(f"Error getting top albums: {e}")
return []
async def _get_new_discoveries(self, user_id: int, start_date: datetime.datetime, end_date: datetime.datetime) -> List[Dict]:
"""Get tracks discovered during the period"""
try:
with Session(db.engine) as session:
# Get first play of each track in the period
first_plays_query = select(
Track.id,
Track.title,
Track.artist,
Track.album,
func.min(Playlog.played_at).label('first_played'),
func.count(Playlog.id).label('play_count')
).join(Playlog, Track.id == Playlog.track_id).where(
and_(
Playlog.user_id == user_id,
Playlog.played_at >= start_date,
Playlog.played_at <= end_date
)
).group_by(Track.id, Track.title, Track.artist, Track.album).order_by(func.min(Playlog.played_at).desc())
discoveries = session.execute(first_plays_query).all()
discovery_list = []
for discovery in discoveries:
# Check if this was actually discovered in this period (no plays before start_date)
prior_plays_query = select(func.count(Playlog.id)).where(
and_(
Playlog.user_id == user_id,
Playlog.track_id == discovery.id,
Playlog.played_at < start_date
)
)
prior_plays = session.execute(prior_plays_query).scalar() or 0
if prior_plays == 0: # Truly discovered in this period
discovery_list.append({
'id': discovery.id,
'title': discovery.title,
'artist': discovery.artist,
'album': discovery.album,
'discovered_date': discovery.first_played.isoformat(),
'play_count': discovery.play_count
})
return discovery_list[:50] # Limit to top 50 discoveries
except Exception as e:
logger.error(f"Error getting new discoveries: {e}")
return []
async def _calculate_milestones(self, stats: ListeningStats, personality: MusicPersonality) -> List[Dict]:
"""Calculate user milestones"""
milestones = []
# Listening time milestones
if stats.total_minutes >= 50000: # ~833 hours
milestones.append({
'type': 'listening_time',
'title': 'Marathon Listener',
'description': f'Listened for {stats.total_minutes // 60} hours this year!',
'icon': 'clock',
'level': 'gold'
})
elif stats.total_minutes >= 25000: # ~417 hours
milestones.append({
'type': 'listening_time',
'title': 'Dedicated Listener',
'description': f'Listened for {stats.total_minutes // 60} hours this year!',
'icon': 'clock',
'level': 'silver'
})
elif stats.total_minutes >= 10000: # ~167 hours
milestones.append({
'type': 'listening_time',
'title': 'Music Enthusiast',
'description': f'Listened for {stats.total_minutes // 60} hours this year!',
'icon': 'clock',
'level': 'bronze'
})
# Discovery milestones
if stats.unique_tracks >= 10000:
milestones.append({
'type': 'discovery',
'title': 'Ultimate Explorer',
'description': f'Discovered {stats.unique_tracks} unique tracks!',
'icon': 'compass',
'level': 'gold'
})
elif stats.unique_tracks >= 5000:
milestones.append({
'type': 'discovery',
'title': 'Music Explorer',
'description': f'Discovered {stats.unique_tracks} unique tracks!',
'icon': 'compass',
'level': 'silver'
})
elif stats.unique_tracks >= 1000:
milestones.append({
'type': 'discovery',
'title': 'Curious Listener',
'description': f'Discovered {stats.unique_tracks} unique tracks!',
'icon': 'compass',
'level': 'bronze'
})
# Streak milestones
if stats.listening_streak >= 365:
milestones.append({
'type': 'streak',
'title': 'Everyday Listener',
'description': f'Listened music every day for {stats.listening_streak} days!',
'icon': 'calendar',
'level': 'gold'
})
elif stats.listening_streak >= 100:
milestones.append({
'type': 'streak',
'title': 'Consistent Listener',
'description': f'Listened music for {stats.listening_streak} consecutive days!',
'icon': 'calendar',
'level': 'silver'
})
elif stats.listening_streak >= 30:
milestones.append({
'type': 'streak',
'title': 'Monthly Streak',
'description': f'Listened music for {stats.listening_streak} consecutive days!',
'icon': 'calendar',
'level': 'bronze'
})
return milestones
async def _save_recap_data(self, recap_data: RecapData):
"""Save recap data to file"""
try:
recap_file = self.recap_dir / f"recap_{recap_data.user_id}_{recap_data.year}.json"
# Convert to dict and save
recap_dict = asdict(recap_data)
with open(recap_file, 'w') as f:
json.dump(recap_dict, f, indent=2, default=str)
logger.info(f"Saved recap data to {recap_file}")
except Exception as e:
logger.error(f"Error saving recap data: {e}")
raise
async def _calculate_listening_streak(self, plays: List) -> int:
"""Calculate longest consecutive day streak"""
if not plays:
return 0
# Get unique days with plays
play_days = set(play.played_at.date() for play in plays)
sorted_days = sorted(play_days)
max_streak = 0
current_streak = 0
for i, day in enumerate(sorted_days):
if i == 0:
current_streak = 1
else:
prev_day = sorted_days[i-1]
if (day - prev_day).days == 1:
current_streak += 1
else:
current_streak = 1
max_streak = max(max_streak, current_streak)
return max_streak
async def _calculate_longest_session(self, plays: List) -> int:
"""Calculate longest listening session"""
if not plays:
return 0
longest_session = 0
current_session = 0
# Sort plays by time
sorted_plays = sorted(plays, key=lambda p: p.played_at)
for i, play in enumerate(sorted_plays):
current_session = play.duration or 0
# Check if next play is within 30 minutes (continuation of session)
if i < len(sorted_plays) - 1:
next_play = sorted_plays[i + 1]
time_diff = (next_play.played_at - play.played_at).total_seconds() / 60
if time_diff <= 30: # Within 30 minutes = same session
current_session += next_play.duration or 0
else:
longest_session = max(longest_session, current_session)
current_session = 0
else:
longest_session = max(longest_session, current_session)
return int(longest_session)
async def _calculate_favorite_time_of_day(self, plays: List) -> str:
"""Calculate favorite time of day for listening"""
if not plays:
return ""
# Count plays by hour
hour_counts = {}
for play in plays:
hour = play.played_at.hour
hour_counts[hour] = hour_counts.get(hour, 0) + 1
# Find most common hour
favorite_hour = max(hour_counts, key=hour_counts.get)
# Convert to time period
if 6 <= favorite_hour < 12:
return "Morning"
elif 12 <= favorite_hour < 18:
return "Afternoon"
elif 18 <= favorite_hour < 22:
return "Evening"
else:
return "Night"
async def _calculate_discovery_rate(self, user_id: int, plays: List) -> float:
"""Calculate rate of new music discovery"""
if not plays:
return 0.0
# Get first play date for each track
track_first_plays = {}
for play in plays:
if play.track_id not in track_first_plays:
track_first_plays[play.track_id] = play.played_at
# Count tracks first played during this period vs total
period_start = min(play.played_at for play in plays)
period_end = max(play.played_at for play in plays)
# Check if tracks were first discovered in this period
new_discoveries = 0
for track_id, first_play in track_first_plays.items():
if period_start <= first_play <= period_end:
# Check if there were any plays before this period
# This is simplified - would need to query database for prior plays
new_discoveries += 1
return new_discoveries / len(track_first_plays) if track_first_plays else 0.0
# Global service instance
recap_service = RecapService()
@@ -0,0 +1,839 @@
"""
Robust Statistics System for SwingMusic
Prevents data loss with backup, validation, and integrity checks
"""
import os
import time
import json
import sqlite3
import threading
from typing import Dict, List, Optional, Any, Tuple
from dataclasses import dataclass, asdict
from datetime import datetime, timedelta
from pathlib import Path
import hashlib
import shutil
from swingmusic import logger
from swingmusic.db.sqlite.utils import get_db_connection
@dataclass
class ListeningStats:
"""Listening statistics for a track"""
user_id: str
track_id: str
play_count: int
last_played: float
total_time: int # Total seconds listened
skip_count: int
favorite: bool
rating: Optional[int] # 1-5 stars
created_at: float
updated_at: float
@dataclass
class ArtistStats:
"""Artist-level statistics"""
artist_id: str
artist_name: str
total_plays: int
total_time: int
unique_tracks: int
last_played: float
favorite_tracks: List[str]
@dataclass
class AlbumStats:
"""Album-level statistics"""
album_id: str
album_name: str
artist_name: str
total_plays: int
total_time: int
unique_tracks: int
last_played: float
completion_rate: float # Percentage of album listened to
@dataclass
class BackupEntry:
"""Backup entry metadata"""
backup_id: str
timestamp: float
backup_type: str # 'full', 'incremental', 'auto'
file_path: str
checksum: str
size: int
compressed: bool
class StatisticsValidator:
"""Validates statistics data integrity"""
@staticmethod
def validate_listening_data(data: Dict[str, Any]) -> Tuple[bool, List[str]]:
"""Validate listening statistics data"""
errors = []
# Required fields
required_fields = ['user_id', 'track_id', 'play_count', 'last_played']
for field in required_fields:
if field not in data:
errors.append(f"Missing required field: {field}")
# Data type validation
if 'play_count' in data and not isinstance(data['play_count'], int):
errors.append("play_count must be an integer")
if 'last_played' in data and not isinstance(data['last_played'], (int, float)):
errors.append("last_played must be a timestamp")
if 'total_time' in data and not isinstance(data['total_time'], int):
errors.append("total_time must be an integer")
# Value validation
if 'play_count' in data and data['play_count'] < 0:
errors.append("play_count cannot be negative")
if 'total_time' in data and data['total_time'] < 0:
errors.append("total_time cannot be negative")
if 'rating' in data and data['rating'] is not None:
if not isinstance(data['rating'], int) or not (1 <= data['rating'] <= 5):
errors.append("rating must be an integer between 1 and 5")
return len(errors) == 0, errors
@staticmethod
def validate_timestamp_consistency(stats: List[ListeningStats]) -> List[str]:
"""Validate timestamp consistency across statistics"""
errors = []
current_time = time.time()
for stat in stats:
# Check for future timestamps
if stat.last_played > current_time + 60: # Allow 1 minute buffer
errors.append(f"Future timestamp detected for track {stat.track_id}")
# Check for very old timestamps (before 2000)
if stat.last_played < 946684800: # Jan 1, 2000
errors.append(f"Suspicious old timestamp for track {stat.track_id}")
# Check if updated_at >= last_played
if stat.updated_at < stat.last_played:
errors.append(f"updated_at before last_played for track {stat.track_id}")
return errors
@staticmethod
def calculate_checksum(data: Any) -> str:
"""Calculate SHA-256 checksum of data"""
if isinstance(data, str):
data_bytes = data.encode('utf-8')
elif isinstance(data, dict):
data_bytes = json.dumps(data, sort_keys=True).encode('utf-8')
else:
data_bytes = str(data).encode('utf-8')
return hashlib.sha256(data_bytes).hexdigest()
class StatisticsBackup:
"""Manages statistics backups with compression and verification"""
def __init__(self, backup_dir: str = None):
self.backup_dir = backup_dir or os.path.join(
Path.home(), '.swingmusic', 'backups', 'statistics'
)
os.makedirs(self.backup_dir, exist_ok=True)
# Backup configuration
self.max_backups = 10 # Maximum number of backups to keep
self.auto_backup_interval = 3600 # 1 hour in seconds
self.compress_backups = True
def create_backup(self, backup_type: str = 'auto') -> BackupEntry:
"""Create a statistics backup"""
timestamp = time.time()
backup_id = f"stats_{backup_type}_{int(timestamp)}"
backup_file = os.path.join(self.backup_dir, f"{backup_id}.json")
try:
# Collect statistics data
stats_data = self._collect_statistics_data()
# Create backup entry
backup_entry = BackupEntry(
backup_id=backup_id,
timestamp=timestamp,
backup_type=backup_type,
file_path=backup_file,
checksum="",
size=0,
compressed=self.compress_backups
)
# Write backup file
with open(backup_file, 'w', encoding='utf-8') as f:
json.dump(stats_data, f, indent=2, ensure_ascii=False)
# Calculate checksum and size
backup_entry.checksum = StatisticsValidator.calculate_checksum(stats_data)
backup_entry.size = os.path.getsize(backup_file)
# Compress if enabled
if self.compress_backups:
backup_file = self._compress_backup(backup_file)
backup_entry.file_path = backup_file
backup_entry.size = os.path.getsize(backup_file)
logger.info(f"Created statistics backup: {backup_id}")
return backup_entry
except Exception as e:
logger.error(f"Failed to create statistics backup: {e}")
if os.path.exists(backup_file):
os.remove(backup_file)
raise
def _collect_statistics_data(self) -> Dict[str, Any]:
"""Collect all statistics data from database"""
try:
with get_db_connection() as conn:
# Get listening statistics
cursor = conn.execute("""
SELECT
user_id,
trackhash as track_id,
playcount as play_count,
lastplayed as last_played,
total_time,
skip_count,
favorite,
rating,
created_at,
updated_at
FROM listening_stats
""")
listening_stats = [dict(row) for row in cursor.fetchall()]
# Get artist statistics
cursor = conn.execute("""
SELECT
artist_id,
artist_name,
total_plays,
total_time,
unique_tracks,
last_played,
favorite_tracks
FROM artist_stats
""")
artist_stats = [dict(row) for row in cursor.fetchall()]
# Get album statistics
cursor = conn.execute("""
SELECT
album_id,
album_name,
artist_name,
total_plays,
total_time,
unique_tracks,
last_played,
completion_rate
FROM album_stats
""")
album_stats = [dict(row) for row in cursor.fetchall()]
return {
'backup_timestamp': time.time(),
'listening_stats': listening_stats,
'artist_stats': artist_stats,
'album_stats': album_stats,
'version': '1.0'
}
except Exception as e:
logger.error(f"Error collecting statistics data: {e}")
return {}
def _compress_backup(self, file_path: str) -> str:
"""Compress backup file using gzip"""
try:
import gzip
compressed_path = file_path + '.gz'
with open(file_path, 'rb') as f_in:
with gzip.open(compressed_path, 'wb') as f_out:
shutil.copyfileobj(f_in, f_out)
# Remove uncompressed file
os.remove(file_path)
return compressed_path
except ImportError:
logger.warning("gzip not available, backup not compressed")
return file_path
except Exception as e:
logger.error(f"Error compressing backup: {e}")
return file_path
def restore_backup(self, backup_id: str) -> bool:
"""Restore statistics from backup"""
backup_file = None
try:
# Find backup file
if backup_id.endswith('.gz'):
backup_file = os.path.join(self.backup_dir, backup_id)
else:
backup_file = os.path.join(self.backup_dir, f"{backup_id}.json")
if not os.path.exists(backup_file):
backup_file = os.path.join(self.backup_dir, f"{backup_id}.json.gz")
if not os.path.exists(backup_file):
logger.error(f"Backup file not found: {backup_id}")
return False
# Load backup data
stats_data = self._load_backup_file(backup_file)
if not stats_data:
logger.error("Failed to load backup data")
return False
# Restore data to database
success = self._restore_statistics_data(stats_data)
if success:
logger.info(f"Successfully restored statistics from backup: {backup_id}")
else:
logger.error(f"Failed to restore statistics from backup: {backup_id}")
return success
except Exception as e:
logger.error(f"Error restoring backup {backup_id}: {e}")
return False
def _load_backup_file(self, file_path: str) -> Optional[Dict[str, Any]]:
"""Load backup file (compressed or uncompressed)"""
try:
if file_path.endswith('.gz'):
import gzip
with gzip.open(file_path, 'rt', encoding='utf-8') as f:
return json.load(f)
else:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading backup file {file_path}: {e}")
return None
def _restore_statistics_data(self, stats_data: Dict[str, Any]) -> bool:
"""Restore statistics data to database"""
try:
with get_db_connection() as conn:
# Clear existing statistics
conn.execute("DELETE FROM listening_stats")
conn.execute("DELETE FROM artist_stats")
conn.execute("DELETE FROM album_stats")
# Restore listening statistics
if 'listening_stats' in stats_data:
for stat in stats_data['listening_stats']:
conn.execute("""
INSERT INTO listening_stats (
user_id, trackhash, playcount, lastplayed, total_time,
skip_count, favorite, rating, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
stat['user_id'],
stat['track_id'],
stat['play_count'],
stat['last_played'],
stat['total_time'],
stat.get('skip_count', 0),
stat.get('favorite', False),
stat.get('rating'),
stat.get('created_at', time.time()),
stat.get('updated_at', time.time())
))
# Restore artist statistics
if 'artist_stats' in stats_data:
for stat in stats_data['artist_stats']:
conn.execute("""
INSERT INTO artist_stats (
artist_id, artist_name, total_plays, total_time,
unique_tracks, last_played, favorite_tracks
) VALUES (?, ?, ?, ?, ?, ?, ?)
""", (
stat['artist_id'],
stat['artist_name'],
stat['total_plays'],
stat['total_time'],
stat['unique_tracks'],
stat['last_played'],
json.dumps(stat.get('favorite_tracks', []))
))
# Restore album statistics
if 'album_stats' in stats_data:
for stat in stats_data['album_stats']:
conn.execute("""
INSERT INTO album_stats (
album_id, album_name, artist_name, total_plays,
total_time, unique_tracks, last_played, completion_rate
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
stat['album_id'],
stat['album_name'],
stat['artist_name'],
stat['total_plays'],
stat['total_time'],
stat['unique_tracks'],
stat['last_played'],
stat.get('completion_rate', 0.0)
))
conn.commit()
return True
except Exception as e:
logger.error(f"Error restoring statistics data: {e}")
return False
def list_backups(self) -> List[BackupEntry]:
"""List all available backups"""
backups = []
try:
for file_name in os.listdir(self.backup_dir):
if file_name.endswith(('.json', '.gz')):
file_path = os.path.join(self.backup_dir, file_name)
# Extract backup info from filename
parts = file_name.replace('.json', '').replace('.gz', '').split('_')
if len(parts) >= 3:
backup_type = parts[1]
timestamp = float(parts[2])
backup_entry = BackupEntry(
backup_id=file_name.replace('.json', '').replace('.gz', ''),
timestamp=timestamp,
backup_type=backup_type,
file_path=file_path,
checksum="",
size=os.path.getsize(file_path),
compressed=file_path.endswith('.gz')
)
backups.append(backup_entry)
# Sort by timestamp (newest first)
backups.sort(key=lambda x: x.timestamp, reverse=True)
except Exception as e:
logger.error(f"Error listing backups: {e}")
return backups
def cleanup_old_backups(self):
"""Remove old backups, keeping only the most recent ones"""
backups = self.list_backups()
if len(backups) > self.max_backups:
# Keep the most recent backups
backups_to_keep = backups[:self.max_backups]
backups_to_remove = backups[self.max_backups:]
for backup in backups_to_remove:
try:
os.remove(backup.file_path)
logger.info(f"Removed old backup: {backup.backup_id}")
except Exception as e:
logger.error(f"Error removing backup {backup.backup_id}: {e}")
class RobustStatisticsManager:
"""Robust statistics manager with backup and validation"""
def __init__(self):
self.backup_manager = StatisticsBackup()
self.validator = StatisticsValidator()
self.last_backup_time = 0
self.backup_lock = threading.Lock()
# Start auto-backup thread
self._start_auto_backup()
def _start_auto_backup(self):
"""Start automatic backup thread"""
def backup_worker():
while True:
time.sleep(self.backup_manager.auto_backup_interval)
try:
self._create_auto_backup()
except Exception as e:
logger.error(f"Auto-backup failed: {e}")
backup_thread = threading.Thread(target=backup_worker, daemon=True)
backup_thread.start()
def _create_auto_backup(self):
"""Create automatic backup"""
with self.backup_lock:
try:
self.backup_manager.create_backup('auto')
self.last_backup_time = time.time()
self.backup_manager.cleanup_old_backups()
except Exception as e:
logger.error(f"Auto-backup failed: {e}")
async def update_listening_stats(self, user_id: str, track_id: str,
listening_data: Dict[str, Any]) -> bool:
"""Update statistics with data integrity checks"""
try:
# Validate data before storage
is_valid, errors = self.validator.validate_listening_data(listening_data)
if not is_valid:
logger.error(f"Invalid listening data: {errors}")
return False
# Create backup before update
backup_success = self._create_update_backup(user_id)
if not backup_success:
logger.warning("Failed to create backup before statistics update")
# Update with transaction
with get_db_connection() as conn:
conn.execute("BEGIN TRANSACTION")
try:
# Update or insert listening stats
cursor = conn.execute("""
SELECT playcount, total_time, skip_count, favorite, rating
FROM listening_stats
WHERE user_id = ? AND trackhash = ?
""", (user_id, track_id))
existing = cursor.fetchone()
if existing:
# Update existing record
new_play_count = existing['playcount'] + listening_data.get('play_count', 1)
new_total_time = existing['total_time'] + listening_data.get('duration', 0)
new_skip_count = existing['skip_count'] + listening_data.get('skip_count', 0)
conn.execute("""
UPDATE listening_stats
SET playcount = ?, lastplayed = ?, total_time = ?,
skip_count = ?, updated_at = ?
WHERE user_id = ? AND trackhash = ?
""", (
new_play_count,
listening_data.get('last_played', time.time()),
new_total_time,
new_skip_count,
time.time(),
user_id,
track_id
))
else:
# Insert new record
conn.execute("""
INSERT INTO listening_stats (
user_id, trackhash, playcount, lastplayed, total_time,
skip_count, favorite, rating, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
user_id,
track_id,
listening_data.get('play_count', 1),
listening_data.get('last_played', time.time()),
listening_data.get('duration', 0),
listening_data.get('skip_count', 0),
listening_data.get('favorite', False),
listening_data.get('rating'),
time.time(),
time.time()
))
# Update artist and album statistics
await self._update_artist_stats(conn, user_id, track_id)
await self._update_album_stats(conn, user_id, track_id)
conn.commit()
# Verify integrity after update
await self._verify_integrity(user_id)
return True
except Exception as e:
conn.rollback()
logger.error(f"Error updating statistics: {e}")
# Attempt to restore from backup
if backup_success:
self._restore_from_backup(user_id)
return False
except Exception as e:
logger.error(f"Error in update_listening_stats: {e}")
return False
async def _update_artist_stats(self, conn: sqlite3.Connection, user_id: str, track_id: str):
"""Update artist-level statistics"""
try:
# Get track information
cursor = conn.execute("""
SELECT artist, album FROM tracks WHERE trackhash = ?
""", (track_id,))
track_info = cursor.fetchone()
if not track_info:
return
artist = track_info['artist']
# Update artist statistics
cursor = conn.execute("""
SELECT total_plays, total_time, unique_tracks, last_played
FROM artist_stats
WHERE artist_id = ? AND user_id = ?
""", (artist, user_id))
existing = cursor.fetchone()
if existing:
# Update existing
cursor = conn.execute("""
SELECT COUNT(DISTINCT trackhash) as unique_count
FROM listening_stats
WHERE user_id = ? AND trackhash IN (
SELECT trackhash FROM tracks WHERE artist = ?
)
""", (user_id, artist))
unique_tracks = cursor.fetchone()['unique_count']
conn.execute("""
UPDATE artist_stats
SET total_plays = total_plays + 1,
total_time = total_time + ?,
unique_tracks = ?,
last_played = ?
WHERE artist_id = ? AND user_id = ?
""", (
track_info.get('duration', 0),
unique_tracks,
time.time(),
artist,
user_id
))
else:
# Insert new
conn.execute("""
INSERT INTO artist_stats (
artist_id, artist_name, user_id, total_plays, total_time,
unique_tracks, last_played, favorite_tracks
) VALUES (?, ?, ?, 1, ?, 1, ?, ?)
""", (
artist,
artist,
user_id,
track_info.get('duration', 0),
time.time(),
json.dumps([])
))
except Exception as e:
logger.error(f"Error updating artist stats: {e}")
async def _update_album_stats(self, conn: sqlite3.Connection, user_id: str, track_id: str):
"""Update album-level statistics"""
try:
# Get track information
cursor = conn.execute("""
SELECT artist, album FROM tracks WHERE trackhash = ?
""", (track_id,))
track_info = cursor.fetchone()
if not track_info:
return
album = track_info['album']
artist = track_info['artist']
# Update album statistics
cursor = conn.execute("""
SELECT total_plays, total_time, unique_tracks, last_played
FROM album_stats
WHERE album_id = ? AND user_id = ?
""", (album, user_id))
existing = cursor.fetchone()
if existing:
# Update existing
cursor = conn.execute("""
SELECT COUNT(DISTINCT trackhash) as unique_count
FROM listening_stats
WHERE user_id = ? AND trackhash IN (
SELECT trackhash FROM tracks WHERE album = ?
)
""", (user_id, album))
unique_tracks = cursor.fetchone()['unique_count']
conn.execute("""
UPDATE album_stats
SET total_plays = total_plays + 1,
total_time = total_time + ?,
unique_tracks = ?,
last_played = ?
WHERE album_id = ? AND user_id = ?
""", (
track_info.get('duration', 0),
unique_tracks,
time.time(),
album,
user_id
))
else:
# Insert new
conn.execute("""
INSERT INTO album_stats (
album_id, album_name, artist_name, user_id, total_plays,
total_time, unique_tracks, last_played, completion_rate
) VALUES (?, ?, ?, ?, 1, ?, 1, ?, 0.0)
""", (
album,
album,
artist,
user_id,
track_info.get('duration', 0),
time.time()
))
except Exception as e:
logger.error(f"Error updating album stats: {e}")
async def _verify_integrity(self, user_id: str):
"""Verify statistics integrity after update"""
try:
with get_db_connection() as conn:
# Get all listening stats for user
cursor = conn.execute("""
SELECT * FROM listening_stats WHERE user_id = ?
""", (user_id,))
stats = [ListeningStats(**dict(row)) for row in cursor.fetchall()]
# Validate timestamp consistency
errors = self.validator.validate_timestamp_consistency(stats)
if errors:
logger.warning(f"Statistics integrity issues for user {user_id}: {errors}")
except Exception as e:
logger.error(f"Error verifying statistics integrity: {e}")
def _create_update_backup(self, user_id: str) -> bool:
"""Create backup before statistics update"""
try:
with self.backup_lock:
backup_id = f"pre_update_{user_id}_{int(time.time())}"
backup_entry = self.backup_manager.create_backup('update')
return True
except Exception as e:
logger.error(f"Failed to create update backup: {e}")
return False
def _restore_from_backup(self, user_id: str):
"""Restore statistics from most recent backup"""
try:
backups = self.backup_manager.list_backups()
if backups:
# Find the most recent backup
latest_backup = backups[0]
success = self.backup_manager.restore_backup(latest_backup.backup_id)
if success:
logger.info(f"Restored statistics from backup: {latest_backup.backup_id}")
else:
logger.error(f"Failed to restore from backup: {latest_backup.backup_id}")
except Exception as e:
logger.error(f"Error restoring from backup: {e}")
def get_statistics_summary(self, user_id: str) -> Dict[str, Any]:
"""Get statistics summary for user"""
try:
with get_db_connection() as conn:
# Get overall statistics
cursor = conn.execute("""
SELECT
COUNT(*) as total_tracks,
SUM(playcount) as total_plays,
SUM(total_time) as total_time,
COUNT(DISTINCT artist) as unique_artists,
COUNT(DISTINCT album) as unique_albums
FROM listening_stats ls
JOIN tracks t ON ls.trackhash = t.trackhash
WHERE ls.user_id = ?
""", (user_id,))
overall = cursor.fetchone()
# Get top tracks
cursor = conn.execute("""
SELECT t.title, t.artist, ls.playcount, ls.lastplayed
FROM listening_stats ls
JOIN tracks t ON ls.trackhash = t.trackhash
WHERE ls.user_id = ?
ORDER BY ls.playcount DESC
LIMIT 10
""", (user_id,))
top_tracks = [dict(row) for row in cursor.fetchall()]
# Get top artists
cursor = conn.execute("""
SELECT artist_name, total_plays, total_time
FROM artist_stats
WHERE user_id = ?
ORDER BY total_plays DESC
LIMIT 10
""", (user_id,))
top_artists = [dict(row) for row in cursor.fetchall()]
return {
'overall': dict(overall) if overall else {},
'top_tracks': top_tracks,
'top_artists': top_artists,
'last_backup': self.last_backup_time
}
except Exception as e:
logger.error(f"Error getting statistics summary: {e}")
return {}
# Global robust statistics manager instance
robust_statistics_manager = RobustStatisticsManager()
@@ -0,0 +1,577 @@
"""
Spotify Metadata Client for SwingMusic
Handles fetching metadata from Spotify API for catalog browsing and downloads
"""
import os
import json
import time
import base64
import requests
from typing import Dict, List, Optional, Any, Tuple
from dataclasses import dataclass
from urllib.parse import urlencode
from swingmusic.logger import log as logger
@dataclass
class SpotifyTrack:
"""Spotify track metadata"""
id: str
name: str
artists: List[Dict[str, Any]]
album: Dict[str, Any]
duration_ms: int
popularity: int
preview_url: Optional[str]
explicit: bool
external_urls: Dict[str, str]
track_number: int
disc_number: int
available_markets: List[str]
@dataclass
class SpotifyAlbum:
"""Spotify album metadata"""
id: str
name: str
artists: List[Dict[str, Any]]
release_date: str
total_tracks: int
popularity: int
images: List[Dict[str, str]]
external_urls: Dict[str, str]
available_markets: List[str]
album_type: str # album, single, compilation
@dataclass
class SpotifyArtist:
"""Spotify artist metadata"""
id: str
name: str
popularity: int
followers: Dict[str, int]
genres: List[str]
images: List[Dict[str, str]]
external_urls: Dict[str, str]
@dataclass
class SpotifyPlaylist:
"""Spotify playlist metadata"""
id: str
name: str
description: Optional[str]
owner: Dict[str, Any]
public: bool
collaborative: bool
tracks: Dict[str, Any] # Contains href, total, limit
images: List[Dict[str, str]]
external_urls: Dict[str, str]
class SpotifyMetadataClient:
"""Client for accessing Spotify Web API for metadata"""
def __init__(self):
self.client_id = os.getenv('SPOTIFY_CLIENT_ID', '')
self.client_secret = os.getenv('SPOTIFY_CLIENT_SECRET', '')
self.access_token = None
self.token_expires_at = 0
self.base_url = 'https://api.spotify.com/v1'
self.rate_limit_remaining = 0
self.rate_limit_reset = 0
# Fallback to demo/public endpoints for development
self.use_demo_mode = not (self.client_id and self.client_secret)
if self.use_demo_mode:
logger.warning("Spotify client credentials not configured, using demo mode")
def _get_access_token(self) -> Optional[str]:
"""Get or refresh Spotify access token"""
if self.use_demo_mode:
return "demo_token"
# Check if current token is still valid
if self.access_token and time.time() < self.token_expires_at:
return self.access_token
try:
# Request new token
auth_string = base64.b64encode(
f"{self.client_id}:{self.client_secret}".encode('utf-8')
).decode('utf-8')
response = requests.post(
'https://accounts.spotify.com/api/token',
headers={
'Authorization': f'Basic {auth_string}',
'Content-Type': 'application/x-www-form-urlencoded'
},
data='grant_type=client_credentials'
)
if response.status_code == 200:
data = response.json()
self.access_token = data['access_token']
self.token_expires_at = time.time() + data['expires_in'] - 60 # 1 minute buffer
logger.info("Successfully obtained Spotify access token")
return self.access_token
else:
logger.error(f"Failed to get Spotify token: {response.status_code} {response.text}")
return None
except Exception as e:
logger.error(f"Error getting Spotify access token: {e}")
return None
def _make_request(self, endpoint: str, params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
"""Make authenticated request to Spotify API"""
if self.use_demo_mode:
return self._demo_response(endpoint, params)
token = self._get_access_token()
if not token:
return None
# Check rate limiting
if self.rate_limit_remaining <= 0 and time.time() < self.rate_limit_reset:
wait_time = self.rate_limit_reset - time.time()
logger.warning(f"Rate limited, waiting {wait_time:.2f} seconds")
time.sleep(wait_time)
try:
url = f"{self.base_url}/{endpoint.lstrip('/')}"
if params:
url += f"?{urlencode(params)}"
response = requests.get(
url,
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json'
}
)
# Update rate limit info
self.rate_limit_remaining = int(response.headers.get('X-RateLimit-Remaining', 0))
self.rate_limit_reset = int(response.headers.get('X-RateLimit-Reset', 0))
if response.status_code == 200:
return response.json()
elif response.status_code == 429:
retry_after = int(response.headers.get('Retry-After', 5))
logger.warning(f"Rate limited, retrying after {retry_after} seconds")
time.sleep(retry_after)
return self._make_request(endpoint, params)
elif response.status_code == 401:
# Token expired, refresh and retry
self.access_token = None
return self._make_request(endpoint, params)
else:
logger.error(f"Spotify API error: {response.status_code} {response.text}")
return None
except Exception as e:
logger.error(f"Error making Spotify API request: {e}")
return None
def _demo_response(self, endpoint: str, params: Dict[str, Any] = None) -> Optional[Dict[str, Any]]:
"""Generate demo responses for development"""
logger.info(f"Demo mode response for: {endpoint}")
if 'tracks' in endpoint:
track_id = endpoint.split('/')[-1] if '/' in endpoint else 'demo_track'
return {
'id': track_id,
'name': f'Demo Track {track_id}',
'artists': [{'id': 'demo_artist', 'name': 'Demo Artist'}],
'album': {
'id': 'demo_album',
'name': 'Demo Album',
'images': [{'url': 'https://via.placeholder.com/300'}]
},
'duration_ms': 180000,
'popularity': 75,
'preview_url': None,
'explicit': False,
'external_urls': {'spotify': f'https://open.spotify.com/track/{track_id}'},
'track_number': 1,
'disc_number': 1,
'available_markets': ['US', 'GB', 'DE']
}
elif 'albums' in endpoint:
album_id = endpoint.split('/')[-1] if '/' in endpoint else 'demo_album'
return {
'id': album_id,
'name': f'Demo Album {album_id}',
'artists': [{'id': 'demo_artist', 'name': 'Demo Artist'}],
'release_date': '2024-01-01',
'total_tracks': 10,
'popularity': 70,
'images': [{'url': 'https://via.placeholder.com/300'}],
'external_urls': {'spotify': f'https://open.spotify.com/album/{album_id}'},
'available_markets': ['US', 'GB', 'DE'],
'album_type': 'album',
'tracks': {
'items': [
{
'id': f'demo_track_{i}',
'name': f'Demo Track {i+1}',
'duration_ms': 180000,
'track_number': i+1,
'explicit': False
}
for i in range(10)
]
}
}
elif 'artists' in endpoint:
if 'albums' in endpoint:
return {
'items': [
{
'id': f'demo_album_{i}',
'name': f'Demo Album {i+1}',
'release_date': '2024-01-01',
'total_tracks': 10,
'images': [{'url': 'https://via.placeholder.com/300'}],
'album_type': 'album'
}
for i in range(5)
]
}
elif 'top-tracks' in endpoint:
return {
'tracks': [
{
'id': f'demo_track_{i}',
'name': f'Demo Track {i+1}',
'artists': [{'id': 'demo_artist', 'name': 'Demo Artist'}],
'album': {
'id': 'demo_album',
'name': 'Demo Album',
'images': [{'url': 'https://via.placeholder.com/300'}]
},
'duration_ms': 180000,
'popularity': 80 - i,
'preview_url': None,
'explicit': False,
'external_urls': {'spotify': f'https://open.spotify.com/track/demo_track_{i}'},
'track_number': i+1
}
for i in range(15)
]
}
else:
artist_id = endpoint.split('/')[-1] if '/' in endpoint else 'demo_artist'
return {
'id': artist_id,
'name': f'Demo Artist {artist_id}',
'popularity': 75,
'followers': {'total': 1000000},
'genres': ['Demo Genre', 'Test Genre'],
'images': [{'url': 'https://via.placeholder.com/300'}],
'external_urls': {'spotify': f'https://open.spotify.com/artist/{artist_id}'}
}
elif 'search' in endpoint:
query = params.get('q', '') if params else ''
return {
'tracks': {
'items': [
{
'id': f'search_track_{i}',
'name': f'{query} Track {i+1}',
'artists': [{'id': 'search_artist', 'name': f'{query} Artist'}],
'album': {
'id': 'search_album',
'name': f'{query} Album',
'images': [{'url': 'https://via.placeholder.com/300'}]
},
'duration_ms': 180000,
'popularity': 70 - i,
'explicit': False
}
for i in range(min(params.get('limit', 20) if params else 20, 20))
],
'total': 100
},
'albums': {
'items': [
{
'id': f'search_album_{i}',
'name': f'{query} Album {i+1}',
'artists': [{'id': 'search_artist', 'name': f'{query} Artist'}],
'release_date': '2024-01-01',
'total_tracks': 10,
'images': [{'url': 'https://via.placeholder.com/300'}],
'album_type': 'album'
}
for i in range(min(params.get('limit', 20) if params else 20, 20))
],
'total': 50
},
'artists': {
'items': [
{
'id': f'search_artist_{i}',
'name': f'{query} Artist {i+1}',
'popularity': 70 - i,
'followers': {'total': 100000 * (i+1)},
'genres': ['Search Genre'],
'images': [{'url': 'https://via.placeholder.com/300'}]
}
for i in range(min(params.get('limit', 20) if params else 20, 20))
],
'total': 25
}
}
return None
def get_track(self, track_id: str) -> Optional[SpotifyTrack]:
"""Get track by ID"""
data = self._make_request(f'tracks/{track_id}')
if not data:
return None
return SpotifyTrack(
id=data['id'],
name=data['name'],
artists=data['artists'],
album=data['album'],
duration_ms=data['duration_ms'],
popularity=data['popularity'],
preview_url=data.get('preview_url'),
explicit=data['explicit'],
external_urls=data['external_urls'],
track_number=data['track_number'],
disc_number=data.get('disc_number', 1),
available_markets=data.get('available_markets', [])
)
def get_album(self, album_id: str) -> Optional[SpotifyAlbum]:
"""Get album by ID"""
data = self._make_request(f'albums/{album_id}')
if not data:
return None
return SpotifyAlbum(
id=data['id'],
name=data['name'],
artists=data['artists'],
release_date=data['release_date'],
total_tracks=data['total_tracks'],
popularity=data.get('popularity', 0),
images=data['images'],
external_urls=data['external_urls'],
available_markets=data.get('available_markets', []),
album_type=data['album_type']
)
def get_album_tracks(self, album_id: str, limit: int = 50, offset: int = 0) -> List[SpotifyTrack]:
"""Get tracks from album"""
data = self._make_request(f'albums/{album_id}/tracks', {
'limit': limit,
'offset': offset
})
if not data or 'items' not in data:
return []
tracks = []
for item in data['items']:
# Get full track details for each track
track = self.get_track(item['id'])
if track:
tracks.append(track)
return tracks
def get_artist(self, artist_id: str) -> Optional[SpotifyArtist]:
"""Get artist by ID"""
data = self._make_request(f'artists/{artist_id}')
if not data:
return None
return SpotifyArtist(
id=data['id'],
name=data['name'],
popularity=data['popularity'],
followers=data['followers'],
genres=data['genres'],
images=data['images'],
external_urls=data['external_urls']
)
def get_artist_albums(self, artist_id: str, limit: int = 20, include_groups: str = 'album,single') -> List[SpotifyAlbum]:
"""Get artist albums"""
data = self._make_request(f'artists/{artist_id}/albums', {
'limit': limit,
'include_groups': include_groups
})
if not data or 'items' not in data:
return []
albums = []
for item in data['items']:
album = SpotifyAlbum(
id=item['id'],
name=item['name'],
artists=item['artists'],
release_date=item['release_date'],
total_tracks=item['total_tracks'],
popularity=item.get('popularity', 0),
images=item['images'],
external_urls=item['external_urls'],
available_markets=item.get('available_markets', []),
album_type=item['album_type']
)
albums.append(album)
return albums
def get_artist_top_tracks(self, artist_id: str, market: str = 'US') -> List[SpotifyTrack]:
"""Get artist's top tracks"""
data = self._make_request(f'artists/{artist_id}/top-tracks', {
'market': market
})
if not data or 'tracks' not in data:
return []
tracks = []
for item in data['tracks']:
track = SpotifyTrack(
id=item['id'],
name=item['name'],
artists=item['artists'],
album=item['album'],
duration_ms=item['duration_ms'],
popularity=item['popularity'],
preview_url=item.get('preview_url'),
explicit=item['explicit'],
external_urls=item['external_urls'],
track_number=item.get('track_number', 1),
disc_number=item.get('disc_number', 1),
available_markets=item.get('available_markets', [])
)
tracks.append(track)
return tracks
def get_related_artists(self, artist_id: str) -> List[SpotifyArtist]:
"""Get related artists"""
data = self._make_request(f'artists/{artist_id}/related-artists')
if not data or 'artists' not in data:
return []
artists = []
for item in data['artists']:
artist = SpotifyArtist(
id=item['id'],
name=item['name'],
popularity=item['popularity'],
followers=item['followers'],
genres=item['genres'],
images=item['images'],
external_urls=item['external_urls']
)
artists.append(artist)
return artists
def search(self, query: str, search_type: str = 'track', limit: int = 20, offset: int = 0, market: str = 'US') -> Dict[str, List]:
"""Search for content"""
types = search_type if search_type in ['track', 'album', 'artist', 'playlist'] else 'track'
data = self._make_request('search', {
'q': query,
'type': types,
'limit': limit,
'offset': offset,
'market': market
})
if not data:
return {'tracks': [], 'albums': [], 'artists': [], 'playlists': []}
result = {'tracks': [], 'albums': [], 'artists': [], 'playlists': []}
# Process tracks
if 'tracks' in data and 'items' in data['tracks']:
for item in data['tracks']['items']:
track = SpotifyTrack(
id=item['id'],
name=item['name'],
artists=item['artists'],
album=item['album'],
duration_ms=item['duration_ms'],
popularity=item['popularity'],
preview_url=item.get('preview_url'),
explicit=item['explicit'],
external_urls=item['external_urls'],
track_number=item.get('track_number', 1),
disc_number=item.get('disc_number', 1),
available_markets=item.get('available_markets', [])
)
result['tracks'].append(track)
# Process albums
if 'albums' in data and 'items' in data['albums']:
for item in data['albums']['items']:
album = SpotifyAlbum(
id=item['id'],
name=item['name'],
artists=item['artists'],
release_date=item['release_date'],
total_tracks=item['total_tracks'],
popularity=item.get('popularity', 0),
images=item['images'],
external_urls=item['external_urls'],
available_markets=item.get('available_markets', []),
album_type=item['album_type']
)
result['albums'].append(album)
# Process artists
if 'artists' in data and 'items' in data['artists']:
for item in data['artists']['items']:
artist = SpotifyArtist(
id=item['id'],
name=item['name'],
popularity=item['popularity'],
followers=item['followers'],
genres=item['genres'],
images=item['images'],
external_urls=item['external_urls']
)
result['artists'].append(artist)
# Process playlists
if 'playlists' in data and 'items' in data['playlists']:
for item in data['playlists']['items']:
playlist = SpotifyPlaylist(
id=item['id'],
name=item['name'],
description=item.get('description'),
owner=item['owner'],
public=item.get('public', False),
collaborative=item.get('collaborative', False),
tracks=item['tracks'],
images=item.get('images', []),
external_urls=item['external_urls']
)
result['playlists'].append(playlist)
return result
# Global instance
spotify_metadata_client = SpotifyMetadataClient()
@@ -0,0 +1,343 @@
"""
Universal Music Downloader - Minimal Working Version
"""
import os
import time
import asyncio
import aiohttp
from typing import Dict, List, Optional, Any
from dataclasses import dataclass
from enum import Enum
from swingmusic.services.universal_url_parser import universal_url_parser, MusicService, ParsedURL
import logging
logger = logging.getLogger(__name__)
class DownloadStatus(Enum):
PENDING = "pending"
DOWNLOADING = "downloading"
COMPLETED = "completed"
FAILED = "failed"
class DownloadQuality(Enum):
LOSSLESS = "lossless"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
@dataclass
class UniversalMetadata:
"""Universal metadata structure for all music services"""
service: MusicService
service_id: str
title: str
artist: str
album: Optional[str] = None
duration_ms: Optional[int] = None
isrc: Optional[str] = None
release_date: Optional[str] = None
genre: Optional[str] = None
image_url: Optional[str] = None
original_url: str = ""
metadata: Dict[str, Any] = None
def __post_init__(self):
if self.metadata is None:
self.metadata = {}
@dataclass
class DownloadItem:
"""Represents a download item in the queue"""
id: str
url: str
metadata: UniversalMetadata
quality: DownloadQuality
status: DownloadStatus
progress: float = 0.0
file_path: Optional[str] = None
error_message: Optional[str] = None
created_at: float = None
def __post_init__(self):
if self.created_at is None:
self.created_at = time.time()
class UniversalMusicDownloader:
"""Universal music downloader supporting multiple streaming services"""
def __init__(self, download_dir: str = None, max_concurrent_downloads: int = 3):
self.download_dir = download_dir or os.path.expanduser("~/Downloads/SwingMusic")
self.max_concurrent_downloads = max_concurrent_downloads
self.default_quality = DownloadQuality.HIGH
self.download_queue: List[DownloadItem] = []
self.session = None
# Ensure download directory exists
os.makedirs(self.download_dir, exist_ok=True)
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create aiohttp session"""
if self.session is None:
self.session = aiohttp.ClientSession()
return self.session
async def close(self):
"""Close aiohttp session"""
if self.session:
await self.session.close()
def parse_url(self, url: str) -> Optional[ParsedURL]:
"""Parse and validate a music service URL"""
return universal_url_parser.parse_url(url)
async def get_metadata(self, url: str) -> Optional[UniversalMetadata]:
"""Get metadata from any supported music service URL"""
try:
# Parse URL
parsed_url = universal_url_parser.parse_url(url)
if not parsed_url:
logger.warning(f"Could not parse URL: {url}")
return None
# Route to appropriate service
if parsed_url.service == MusicService.SPOTIFY:
return await self._get_spotify_metadata(parsed_url)
elif parsed_url.service == MusicService.TIDAL:
return await self._get_tidal_metadata(parsed_url)
elif parsed_url.service == MusicService.APPLE_MUSIC:
return await self._get_apple_music_metadata(parsed_url)
elif parsed_url.service == MusicService.YOUTUBE:
return await self._get_youtube_metadata(parsed_url)
elif parsed_url.service == MusicService.YOUTUBE_MUSIC:
return await self._get_youtube_music_metadata(parsed_url)
elif parsed_url.service == MusicService.SOUNDCLOUD:
return await self._get_soundcloud_metadata(parsed_url)
elif parsed_url.service == MusicService.DEEZER:
return await self._get_deezer_metadata(parsed_url)
elif parsed_url.service == MusicService.MUSICBRAINZ:
return await self._get_musicbrainz_metadata(parsed_url)
elif parsed_url.service == MusicService.DISCOGS:
return await self._get_discogs_metadata(parsed_url)
else:
logger.warning(f"Unsupported service: {parsed_url.service}")
return None
except Exception as e:
logger.error(f"Error getting metadata for {url}: {e}")
return None
async def _get_spotify_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]:
"""Get metadata from Spotify"""
try:
return UniversalMetadata(
service=MusicService.SPOTIFY,
service_id=parsed_url.id,
title=f"Spotify {parsed_url.item_type.title()}",
artist="Unknown Artist",
original_url=parsed_url.url
)
except Exception as e:
logger.error(f"Error getting Spotify metadata: {e}")
return None
async def _get_tidal_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]:
"""Get metadata from Tidal"""
try:
import aiohttp
from bs4 import BeautifulSoup
url = f"https://tidal.com/browse/{parsed_url.item_type}/{parsed_url.id}"
session = await self._get_session()
async with session.get(url, headers={'User-Agent': 'Mozilla/5.0'}) as response:
if response.status == 200:
html = await response.text()
soup = BeautifulSoup(html, 'html.parser')
title_elem = soup.find('meta', property='og:title')
artist_elem = soup.find('meta', property='og:music:artist')
image_elem = soup.find('meta', property='og:image')
title = title_elem.get('content', '') if title_elem else ''
artist = artist_elem.get('content', '') if artist_elem else 'Unknown Artist'
image_url = image_elem.get('content', '') if image_elem else None
return UniversalMetadata(
service=MusicService.TIDAL,
service_id=parsed_url.id,
title=title or f"Tidal {parsed_url.item_type.title()}",
artist=artist,
image_url=image_url,
original_url=parsed_url.url
)
else:
logger.warning(f"Tidal page not found: {response.status}")
except Exception as e:
logger.error(f"Error getting Tidal metadata: {e}")
# Fallback metadata
return UniversalMetadata(
service=MusicService.TIDAL,
service_id=parsed_url.id,
title=f"Tidal {parsed_url.item_type.title()}",
artist="Unknown Artist",
original_url=parsed_url.url
)
async def _get_apple_music_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]:
"""Get metadata from Apple Music"""
try:
return UniversalMetadata(
service=MusicService.APPLE_MUSIC,
service_id=parsed_url.id,
title=f"Apple Music {parsed_url.item_type.title()}",
artist="Unknown Artist",
original_url=parsed_url.url
)
except Exception as e:
logger.error(f"Error getting Apple Music metadata: {e}")
return None
async def _get_youtube_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]:
"""Get metadata from YouTube"""
try:
return UniversalMetadata(
service=MusicService.YOUTUBE,
service_id=parsed_url.id,
title=f"YouTube {parsed_url.item_type.title()}",
artist="Unknown Artist",
original_url=parsed_url.url
)
except Exception as e:
logger.error(f"Error getting YouTube metadata: {e}")
return None
async def _get_youtube_music_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]:
"""Get metadata from YouTube Music"""
try:
return UniversalMetadata(
service=MusicService.YOUTUBE_MUSIC,
service_id=parsed_url.id,
title=f"YouTube Music {parsed_url.item_type.title()}",
artist="Unknown Artist",
original_url=parsed_url.url
)
except Exception as e:
logger.error(f"Error getting YouTube Music metadata: {e}")
return None
async def _get_soundcloud_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]:
"""Get metadata from SoundCloud"""
try:
return UniversalMetadata(
service=MusicService.SOUNDCLOUD,
service_id=parsed_url.id,
title=f"SoundCloud {parsed_url.item_type.title()}",
artist="Unknown Artist",
original_url=parsed_url.url
)
except Exception as e:
logger.error(f"Error getting SoundCloud metadata: {e}")
return None
async def _get_deezer_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]:
"""Get metadata from Deezer"""
try:
return UniversalMetadata(
service=MusicService.DEEZER,
service_id=parsed_url.id,
title=f"Deezer {parsed_url.item_type.title()}",
artist="Unknown Artist",
original_url=parsed_url.url
)
except Exception as e:
logger.error(f"Error getting Deezer metadata: {e}")
return None
async def _get_musicbrainz_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]:
"""Get metadata from MusicBrainz"""
try:
return UniversalMetadata(
service=MusicService.MUSICBRAINZ,
service_id=parsed_url.id,
title=f"MusicBrainz {parsed_url.item_type.title()}",
artist="Unknown Artist",
original_url=parsed_url.url
)
except Exception as e:
logger.error(f"Error getting MusicBrainz metadata: {e}")
return None
async def _get_discogs_metadata(self, parsed_url: ParsedURL) -> Optional[UniversalMetadata]:
"""Get metadata from Discogs"""
try:
return UniversalMetadata(
service=MusicService.DISCOGS,
service_id=parsed_url.id,
title=f"Discogs {parsed_url.item_type.title()}",
artist="Unknown Artist",
original_url=parsed_url.url
)
except Exception as e:
logger.error(f"Error getting Discogs metadata: {e}")
return None
def add_download(self, url: str, quality: DownloadQuality = None) -> Optional[str]:
"""Add a download to the queue"""
try:
if quality is None:
quality = self.default_quality
# Parse URL
parsed_url = self.parse_url(url)
if not parsed_url:
logger.error(f"Invalid URL: {url}")
return None
# Create download item
download_id = str(time.time())
item = DownloadItem(
id=download_id,
url=url,
metadata=UniversalMetadata(
service=parsed_url.service,
service_id=parsed_url.id,
title=f"{parsed_url.service.value.title()} {parsed_url.item_type.title()}",
artist="Unknown Artist",
original_url=url
),
quality=quality,
status=DownloadStatus.PENDING
)
# Add to queue
self.download_queue.append(item)
logger.info(f"Added download: {url}")
return download_id
except Exception as e:
logger.error(f"Error adding download: {e}")
return None
def get_download_status(self, download_id: str) -> Optional[DownloadItem]:
"""Get status of a download"""
for item in self.download_queue:
if item.id == download_id:
return item
return None
def get_all_downloads(self) -> List[DownloadItem]:
"""Get all downloads"""
return self.download_queue.copy()
# Global instance
universal_music_downloader = UniversalMusicDownloader()
@@ -0,0 +1,375 @@
"""
Universal Music URL Parser for SwingMusic
Supports multiple music streaming services for universal downloading
"""
import re
from enum import Enum
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass
class MusicService(Enum):
SPOTIFY = "spotify"
TIDAL = "tidal"
APPLE_MUSIC = "apple_music"
YOUTUBE_MUSIC = "youtube_music"
YOUTUBE = "youtube"
SOUNDCLOUD = "soundcloud"
DEEZER = "deezer"
BANDCAMP = "bandcamp"
MUSICBRAINZ = "musicbrainz"
DISCOGS = "discogs"
@dataclass
class ParsedURL:
"""Represents a parsed music service URL"""
service: MusicService
url: str
item_type: str # track, album, playlist, artist, etc.
id: str
metadata: Dict[str, Any] = None
class UniversalMusicURLParser:
"""Universal parser for music service URLs"""
def __init__(self):
self.patterns = {
MusicService.SPOTIFY: [
r'https://open\.spotify\.com/(track|album|playlist|artist|user)/([a-zA-Z0-9]+)',
r'https://spotify\.link/([a-zA-Z0-9]+)', # Short links
],
MusicService.TIDAL: [
r'https://tidal\.com/(browse|track|album|playlist|artist)/(\d+)',
r'https://tidal\.com/browse/(album|track|playlist|artist)/(\d+)',
r'https://listen\.tidal\.com/(browse|track|album|playlist|artist)/(\d+)',
],
MusicService.APPLE_MUSIC: [
r'https://music\.apple\.com/([a-z]{2})/song/([^/]+)/(\d+)',
r'https://music\.apple\.com/([a-z]{2})/album/(.*?)/(\d+)',
r'https://music\.apple\.com/([a-z]{2})/playlist/(.*?)/pl\.(.+)',
r'https://music\.apple\.com/([a-z]{2})/artist/(.*?)/(\d+)',
],
MusicService.YOUTUBE_MUSIC: [
r'https://music\.youtube\.com/(watch|playlist|channel)(\?[^#]*)',
r'https://youtube\.com/music/(watch|playlist|channel)(\?[^#]*)',
],
MusicService.YOUTUBE: [
r'https://www\.youtube\.com/watch\?v=([a-zA-Z0-9_-]+)',
r'https://youtu\.be/([a-zA-Z0-9_-]+)',
r'https://www\.youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)',
r'https://www\.youtube\.com/channel/([a-zA-Z0-9_-]+)',
r'https://www\.youtube\.com/c/([a-zA-Z0-9_-]+)',
],
MusicService.SOUNDCLOUD: [
r'https://soundcloud\.com/([^/]+)/([^/]+)',
r'https://soundcloud\.com/([^/]+)/sets/([^/]+)',
],
MusicService.DEEZER: [
r'https://www\.deezer\.com/(en|fr|de|es|it|pt|nl|ru|ja)/(track|album|playlist|artist)/(\d+)',
r'https://deezer\.page\.link/(track|album|playlist|artist)/(\d+)',
r'https://link\.deezer\.com/s/([a-zA-Z0-9_-]+)',
],
MusicService.BANDCAMP: [
r'https://([a-zA-Z0-9-]+)\.bandcamp\.com/(track|album)/(.+)',
r'https://bandcamp\.com/search\?q=(.+)',
],
MusicService.MUSICBRAINZ: [
r'https://musicbrainz\.org/(recording|release|release-group|artist)/([a-f0-9-]+)',
r'https://musicbrainz\.org/doc/([a-f0-9-]+)', # API docs
r'https://musicbrainz\.org/artist/([a-f0-9-]+)', # Direct artist links
r'https://musicbrainz\.org/release-group/([a-f0-9-]+)', # Release groups
r'https://musicbrainz\.org/label/([a-f0-9-]+)', # Record labels
r'https://musicbrainz\.org/search\?query=([^&]+)', # Search queries
],
MusicService.DISCOGS: [
r'https://www\.discogs\.com/(release|master|artist)/(\d+)',
]
}
def parse_url(self, url: str) -> Optional[ParsedURL]:
"""
Parse a music service URL and extract service, type, and ID
Args:
url: The URL to parse
Returns:
ParsedURL object if successful, None otherwise
"""
if not url or not isinstance(url, str):
return None
url = url.strip()
# Try each service pattern
for service, patterns in self.patterns.items():
for pattern in patterns:
match = re.match(pattern, url, re.IGNORECASE)
if match:
return self._extract_service_info(service, match, url)
return None
def _extract_service_info(self, service: MusicService, match: re.Match, url: str) -> ParsedURL:
"""Extract service-specific information from regex match"""
groups = match.groups()
if service == MusicService.SPOTIFY:
if len(groups) == 2:
item_type, item_id = groups
return ParsedURL(service, url, item_type, item_id)
elif len(groups) == 1: # Short link
# Would need to resolve short link
return ParsedURL(service, url, 'short', groups[0])
elif service == MusicService.TIDAL:
item_type, item_id = groups
return ParsedURL(service, url, item_type, item_id)
elif service == MusicService.APPLE_MUSIC:
if len(groups) >= 2:
item_type = self._map_apple_music_type(groups[0])
item_id = groups[-1] # Last group is usually the ID
return ParsedURL(service, url, item_type, item_id, {
'region': groups[0] if len(groups) > 2 else 'us',
'name': groups[1] if len(groups) > 2 else ''
})
elif service == MusicService.YOUTUBE_MUSIC:
item_type = self._extract_youtube_type(groups[0], groups[1])
item_id = self._extract_youtube_id(groups[1])
return ParsedURL(service, url, item_type, item_id)
elif service == MusicService.YOUTUBE:
if 'watch' in url:
video_id = self._extract_youtube_id(url)
return ParsedURL(service, url, 'video', video_id)
elif 'playlist' in url:
playlist_id = self._extract_youtube_playlist_id(url)
return ParsedURL(service, url, 'playlist', playlist_id)
elif 'channel' in url or '/c/' in url:
channel_id = self._extract_youtube_channel_id(url)
return ParsedURL(service, url, 'channel', channel_id)
elif service == MusicService.SOUNDCLOUD:
if len(groups) == 2:
if groups[1] == 'sets':
item_type = 'playlist'
else:
item_type = 'track' if groups[1] else 'artist'
item_id = f"{groups[0]}/{groups[1]}"
return ParsedURL(service, url, item_type, item_id)
elif service == MusicService.DEEZER:
if len(groups) == 2:
item_type, item_id = groups
else:
# Short link format: link.deezer.com/s/ID
item_type = 'track' # Default to track for short links
item_id = groups[0] if groups else ''
return ParsedURL(service, url, item_type, item_id)
elif service == MusicService.BANDCAMP:
if len(groups) == 3:
item_type, item_name = groups[1], groups[2]
item_id = f"{groups[0]}/{item_type}/{item_name}"
return ParsedURL(service, url, item_type, item_id)
elif service == MusicService.MUSICBRAINZ:
if len(groups) == 2:
item_type, item_id = groups
elif len(groups) == 1:
# Handle special cases like doc/, artist/, etc.
url_path = url.split('/')[-2] if '/' in url else ''
if 'doc/' in url:
item_type = 'doc'
elif 'artist/' in url:
item_type = 'artist'
elif 'label/' in url:
item_type = 'label'
elif 'search' in url:
item_type = 'search'
# Extract query from search URL
query_match = re.search(r'query=([^&]+)', url)
item_id = query_match.group(1) if query_match else groups[0]
else:
item_type = groups[0] if groups else 'unknown'
item_id = groups[0] if groups else ''
return ParsedURL(service, url, item_type, item_id)
elif service == MusicService.DISCOGS:
item_type, item_id = groups
return ParsedURL(service, url, item_type, item_id)
return ParsedURL(service, url, 'unknown', '')
def _map_apple_music_type(self, type_str: str) -> str:
"""Map Apple Music URL types to standard types"""
mapping = {
'album': 'album',
'playlist': 'playlist',
'artist': 'artist',
'song': 'song'
}
return mapping.get(type_str, 'unknown')
def _extract_youtube_type(self, path: str, query: str) -> str:
"""Extract YouTube content type from URL"""
if 'watch' in path or 'v=' in query:
return 'watch'
elif 'playlist' in path or 'list=' in query:
return 'playlist'
elif 'channel' in path:
return 'channel'
return 'unknown'
def _extract_youtube_id(self, url: str) -> str:
"""Extract YouTube video or channel ID from URL"""
# Video ID
video_match = re.search(r'[?&]v=([a-zA-Z0-9_-]+)', url)
if video_match:
return video_match.group(1)
# Short URL
short_match = re.search(r'youtu\.be/([a-zA-Z0-9_-]+)', url)
if short_match:
return short_match.group(1)
# Channel ID
channel_match = re.search(r'channel/([a-zA-Z0-9_-]+)', url)
if channel_match:
return channel_match.group(1)
# Custom channel
custom_match = re.search(r'/c/([a-zA-Z0-9_-]+)', url)
if custom_match:
return custom_match.group(1)
return ''
def _extract_youtube_playlist_id(self, url: str) -> str:
"""Extract YouTube playlist ID from URL"""
match = re.search(r'[?&]list=([a-zA-Z0-9_-]+)', url)
return match.group(1) if match else ''
def _extract_youtube_channel_id(self, url: str) -> str:
"""Extract YouTube channel ID from URL"""
# Handle both /channel/ and /c/ formats
channel_match = re.search(r'/(channel|c)/([a-zA-Z0-9_-]+)', url)
return channel_match.group(2) if channel_match else ''
def get_supported_services(self) -> List[Dict[str, Any]]:
"""Get list of supported services with their info"""
return [
{
'id': MusicService.SPOTIFY.value,
'name': 'Spotify',
'url_patterns': self.patterns[MusicService.SPOTIFY],
'supported_types': ['track', 'album', 'playlist', 'artist'],
'features': ['metadata', 'download', 'playlist']
},
{
'id': MusicService.TIDAL.value,
'name': 'Tidal',
'url_patterns': self.patterns[MusicService.TIDAL],
'supported_types': ['track', 'album', 'playlist', 'artist'],
'features': ['metadata', 'download', 'playlist']
},
{
'id': MusicService.APPLE_MUSIC.value,
'name': 'Apple Music',
'url_patterns': self.patterns[MusicService.APPLE_MUSIC],
'supported_types': ['track', 'album', 'playlist', 'artist'],
'features': ['metadata', 'download', 'playlist']
},
{
'id': MusicService.YOUTUBE_MUSIC.value,
'name': 'YouTube Music',
'url_patterns': self.patterns[MusicService.YOUTUBE_MUSIC],
'supported_types': ['video', 'playlist', 'channel'],
'features': ['metadata', 'download']
},
{
'id': MusicService.YOUTUBE.value,
'name': 'YouTube',
'url_patterns': self.patterns[MusicService.YOUTUBE],
'supported_types': ['video', 'playlist', 'channel'],
'features': ['metadata', 'download']
},
{
'id': MusicService.SOUNDCLOUD.value,
'name': 'SoundCloud',
'url_patterns': self.patterns[MusicService.SOUNDCLOUD],
'supported_types': ['track', 'playlist', 'artist'],
'features': ['metadata', 'download']
},
{
'id': MusicService.DEEZER.value,
'name': 'Deezer',
'url_patterns': self.patterns[MusicService.DEEZER],
'supported_types': ['track', 'album', 'playlist', 'artist'],
'features': ['metadata', 'download', 'playlist']
},
{
'id': MusicService.BANDCAMP.value,
'name': 'Bandcamp',
'url_patterns': self.patterns[MusicService.BANDCAMP],
'supported_types': ['track', 'album'],
'features': ['metadata', 'download']
},
{
'id': MusicService.MUSICBRAINZ.value,
'name': 'MusicBrainz',
'url_patterns': self.patterns[MusicService.MUSICBRAINZ],
'supported_types': ['recording', 'release', 'artist'],
'features': ['metadata']
},
{
'id': MusicService.DISCOGS.value,
'name': 'Discogs',
'url_patterns': self.patterns[MusicService.DISCOGS],
'supported_types': ['release', 'artist'],
'features': ['metadata']
}
]
def validate_url(self, url: str) -> bool:
"""Validate if URL is from a supported service"""
return self.parse_url(url) is not None
def get_service_from_url(self, url: str) -> Optional[MusicService]:
"""Get service type from URL without full parsing"""
if not url:
return None
url_lower = url.lower()
if 'spotify.com' in url_lower or 'spotify.link' in url_lower:
return MusicService.SPOTIFY
elif 'tidal.com' in url_lower or 'listen.tidal.com' in url_lower:
return MusicService.TIDAL
elif 'music.apple.com' in url_lower:
return MusicService.APPLE_MUSIC
elif 'music.youtube.com' in url_lower:
return MusicService.YOUTUBE_MUSIC
elif 'youtube.com' in url_lower or 'youtu.be' in url_lower:
return MusicService.YOUTUBE
elif 'soundcloud.com' in url_lower:
return MusicService.SOUNDCLOUD
elif 'deezer.com' in url_lower or 'deezer.page.link' in url_lower:
return MusicService.DEEZER
elif 'bandcamp.com' in url_lower:
return MusicService.BANDCAMP
elif 'musicbrainz.org' in url_lower:
return MusicService.MUSICBRAINZ
elif 'discogs.com' in url_lower:
return MusicService.DISCOGS
return None
# Global instance
universal_url_parser = UniversalMusicURLParser()
+720
View File
@@ -0,0 +1,720 @@
"""
Auto Track Updates & New Release Monitoring Service
This service provides intelligent monitoring of followed artists for new releases,
with smart download queuing, priority management, and multi-channel notifications.
"""
import asyncio
import datetime
import json
import logging
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, asdict
from enum import Enum
from sqlalchemy import select, update, delete, insert, and_, or_
from sqlalchemy.orm import Session
from swingmusic.db import db
from swingmusic.models.user import User
from swingmusic.services.spotify_metadata_client import SpotifyMetadataClient
from swingmusic.services.universal_music_downloader import UniversalMusicDownloader
from swingmusic.services.library_integration import LibraryIntegrationService
from swingmusic.utils.notifications import NotificationService
from swingmusic.config import USER_DATA_DIR
logger = logging.getLogger(__name__)
class FollowLevel(Enum):
CASUAL = "casual"
FOLLOWED = "followed"
FAVORITE = "favorite"
class ReleaseType(Enum):
ALBUM = "album"
SINGLE = "single"
EP = "ep"
COMPILATION = "compilation"
class DownloadPriority(Enum):
LOW = "low"
NORMAL = "normal"
HIGH = "high"
URGENT = "urgent"
@dataclass
class ArtistFollow:
user_id: int
artist_id: str
artist_name: str
follow_level: FollowLevel
auto_download_new_releases: bool = False
preferred_quality: str = "flac"
notification_preferences: Dict = None
follow_date: datetime.datetime = None
last_check_date: Optional[datetime.datetime] = None
def __post_init__(self):
if self.notification_preferences is None:
self.notification_preferences = {
"in_app": True,
"push": False,
"email": False,
"discord": False
}
if self.follow_date is None:
self.follow_date = datetime.datetime.utcnow()
@dataclass
class ReleaseUpdate:
release_id: str
artist_id: str
artist_name: str
release_title: str
release_type: ReleaseType
release_date: datetime.date
spotify_url: str
cover_image_url: Optional[str]
total_tracks: int
popularity: int
explicit: bool = False
discovered_at: datetime.datetime = None
processed_at: Optional[datetime.datetime] = None
download_status: str = "pending"
auto_downloaded: bool = False
notification_sent: bool = False
def __post_init__(self):
if self.discovered_at is None:
self.discovered_at = datetime.datetime.utcnow()
@dataclass
class UpdateNotification:
user_id: int
release_id: str
notification_type: str
sent_at: datetime.datetime
opened_at: Optional[datetime.datetime] = None
action_taken: Optional[str] = None
@dataclass
class UpdateMonitoringPreferences:
user_id: int
enable_artist_monitoring: bool = True
check_frequency: str = "daily"
auto_download_favorites: bool = False
auto_download_followed: bool = False
max_auto_downloads_per_week: int = 5
quality_preference: str = "flac"
storage_limit_mb: int = 10240
notification_channels: Dict = None
exclude_explicit: bool = False
preferred_release_types: List[str] = None
def __post_init__(self):
if self.notification_channels is None:
self.notification_channels = {
"in_app": True,
"push": False,
"email": False,
"discord": False
}
if self.preferred_release_types is None:
self.preferred_release_types = ["album", "ep", "single"]
class UpdateCache:
"""Simple in-memory cache for update tracking"""
def __init__(self):
self._cache = {}
self._cache_ttl = 3600 # 1 hour
def get_cached_releases(self, artist_id: str) -> Optional[List[Dict]]:
"""Get cached releases for artist"""
cache_key = f"releases_{artist_id}"
if cache_key in self._cache:
cached_data, timestamp = self._cache[cache_key]
if datetime.datetime.now().timestamp() - timestamp < self._cache_ttl:
return cached_data
return None
def set_cached_releases(self, artist_id: str, releases: List[Dict]):
"""Cache releases for artist"""
cache_key = f"releases_{artist_id}"
self._cache[cache_key] = (releases, datetime.datetime.now().timestamp())
def clear_cache(self, artist_id: str = None):
"""Clear cache for specific artist or all"""
if artist_id:
keys_to_remove = [k for k in self._cache.keys() if k.endswith(artist_id)]
for key in keys_to_remove:
del self._cache[key]
else:
self._cache.clear()
class AutoUpdateTracker:
"""
Intelligent artist update tracking service
Features:
- Background monitoring of followed artists
- Smart download queuing with priority management
- Multi-channel notifications
- Resource-aware processing
- User preference integration
"""
def __init__(self):
self.spotify_client = SpotifyMetadataClient()
self.downloader = UniversalMusicDownloader()
self.library_integration = LibraryIntegrationService()
self.notification_service = NotificationService()
self.update_cache = UpdateCache()
self._monitoring_tasks = []
self._running = False
async def start_monitoring(self):
"""Start background monitoring for updates"""
if self._running:
logger.warning("Update monitoring is already running")
return
self._running = True
logger.info("Starting update monitoring service")
# Schedule periodic checks
self._monitoring_tasks = [
asyncio.create_task(self._daily_artist_check()),
asyncio.create_task(self._weekly_album_check()),
asyncio.create_task(self._realtime_follow_check()),
asyncio.create_task(self._cleanup_old_data())
]
async def stop_monitoring(self):
"""Stop background monitoring"""
self._running = False
logger.info("Stopping update monitoring service")
# Cancel all monitoring tasks
for task in self._monitoring_tasks:
task.cancel()
# Wait for tasks to complete
if self._monitoring_tasks:
await asyncio.gather(*self._monitoring_tasks, return_exceptions=True)
self._monitoring_tasks.clear()
async def _daily_artist_check(self):
"""Daily check for followed artists' new releases"""
while self._running:
try:
logger.info("Starting daily artist update check")
with db.session() as session:
# Get all active follows
follows = self._get_followed_artists(session)
for follow in follows:
try:
await self._check_artist_updates(follow)
except Exception as e:
logger.error(f"Error checking updates for artist {follow.artist_id}: {e}")
continue
logger.info("Daily artist update check completed")
except Exception as e:
logger.error(f"Error in daily artist check: {e}")
# Wait 24 hours
await asyncio.sleep(86400)
async def _weekly_album_check(self):
"""Weekly comprehensive album check"""
while self._running:
try:
logger.info("Starting weekly album check")
# This is a more comprehensive check that might include
# back catalog updates, reissues, etc.
with db.session() as session:
follows = self._get_followed_artists(session)
for follow in follows:
if follow.follow_level in [FollowLevel.FAVORITE, FollowLevel.FOLLOWED]:
await self._comprehensive_artist_check(follow)
logger.info("Weekly album check completed")
except Exception as e:
logger.error(f"Error in weekly album check: {e}")
# Wait 7 days
await asyncio.sleep(604800)
async def _realtime_follow_check(self):
"""Real-time check for new follows and immediate artist validation"""
while self._running:
try:
# Check for new follows that need initial processing
with db.session() as session:
new_follows = self._get_unprocessed_follows(session)
for follow in new_follows:
await self._initial_artist_processing(follow)
# Check every 5 minutes for new follows
await asyncio.sleep(300)
except Exception as e:
logger.error(f"Error in realtime follow check: {e}")
await asyncio.sleep(300)
async def _cleanup_old_data(self):
"""Periodic cleanup of old data"""
while self._running:
try:
logger.info("Starting data cleanup")
with db.session() as session:
# Clean old notifications (older than 30 days)
cutoff_date = datetime.datetime.utcnow() - datetime.timedelta(days=30)
self._cleanup_old_notifications(session, cutoff_date)
# Clean old release updates (older than 1 year, unless downloaded)
old_cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=365)
self._cleanup_old_releases(session, old_cutoff)
logger.info("Data cleanup completed")
# Run cleanup weekly
await asyncio.sleep(604800)
except Exception as e:
logger.error(f"Error in data cleanup: {e}")
await asyncio.sleep(604800)
def _get_followed_artists(self, session: Session) -> List[ArtistFollow]:
"""Get all followed artists from database"""
try:
# This would query the artist_follows table
# For now, return empty list as we'll implement the database schema next
return []
except Exception as e:
logger.error(f"Error getting followed artists: {e}")
return []
def _get_unprocessed_follows(self, session: Session) -> List[ArtistFollow]:
"""Get follows that haven't been processed yet"""
try:
# This would query for follows where last_check_date is NULL
return []
except Exception as e:
logger.error(f"Error getting unprocessed follows: {e}")
return []
async def _check_artist_updates(self, follow: ArtistFollow):
"""Check for new releases from specific artist"""
try:
logger.info(f"Checking updates for artist: {follow.artist_name} ({follow.artist_id})")
# Get latest releases from Spotify
latest_releases = await self.spotify_client.get_artist_releases(follow.artist_id)
# Check against local cache
cached_releases = self.update_cache.get_cached_releases(follow.artist_id)
# Identify new releases
new_releases = self._identify_new_releases(latest_releases, cached_releases)
if new_releases:
logger.info(f"Found {len(new_releases)} new releases for {follow.artist_name}")
await self._process_new_releases(follow, new_releases)
# Update cache
self.update_cache.set_cached_releases(follow.artist_id, latest_releases)
# Update last check date
await self._update_artist_check_date(follow)
except Exception as e:
logger.error(f"Error checking updates for artist {follow.artist_id}: {e}")
async def _comprehensive_artist_check(self, follow: ArtistFollow):
"""More comprehensive check for favorite/followed artists"""
try:
# This could include checking for:
# - Back catalog additions
# - Reissues and remasters
# - Deluxe editions
# - Live albums
# - Compilations
# For now, delegate to regular check
await self._check_artist_updates(follow)
except Exception as e:
logger.error(f"Error in comprehensive check for {follow.artist_id}: {e}")
async def _initial_artist_processing(self, follow: ArtistFollow):
"""Initial processing when user follows an artist"""
try:
logger.info(f"Initial processing for new follow: {follow.artist_name}")
# Get artist's complete discography
discography = await self.spotify_client.get_artist_discography(follow.artist_id)
# Mark existing releases as "known" so we don't notify about them
self.update_cache.set_cached_releases(follow.artist_id, discography)
# Update follow as processed
await self._update_artist_check_date(follow)
except Exception as e:
logger.error(f"Error in initial processing for {follow.artist_id}: {e}")
def _identify_new_releases(self, latest_releases: List[Dict], cached_releases: Optional[List[Dict]]) -> List[Dict]:
"""Identify releases that are new since last check"""
if not cached_releases:
return latest_releases
cached_ids = {release.get('id') for release in cached_releases}
new_releases = [release for release in latest_releases if release.get('id') not in cached_ids]
return new_releases
async def _process_new_releases(self, follow: ArtistFollow, releases: List[Dict]):
"""Process newly discovered releases"""
for release_data in releases:
try:
# Create release update object
release = self._create_release_update(release_data)
# Store in database
await self._store_release_update(release)
# Check if should auto-download
if await self._should_auto_download(follow, release):
await self._auto_download_release(follow, release)
# Send notification
await self._send_update_notification(follow, release)
except Exception as e:
logger.error(f"Error processing release {release_data.get('id')}: {e}")
continue
def _create_release_update(self, release_data: Dict) -> ReleaseUpdate:
"""Create ReleaseUpdate object from Spotify data"""
return ReleaseUpdate(
release_id=release_data['id'],
artist_id=release_data['artists'][0]['id'],
artist_name=release_data['artists'][0]['name'],
release_title=release_data['name'],
release_type=ReleaseType(release_data['album_type'].lower()),
release_date=datetime.datetime.strptime(release_data['release_date'], '%Y-%m-%d').date(),
spotify_url=release_data['external_urls']['spotify'],
cover_image_url=release_data['images'][0]['url'] if release_data.get('images') else None,
total_tracks=release_data['total_tracks'],
popularity=release_data.get('popularity', 0),
explicit=release_data.get('explicit', False)
)
async def _store_release_update(self, release: ReleaseUpdate):
"""Store release update in database"""
try:
with db.session() as session:
# This would insert into the release_updates table
# For now, just log it
logger.info(f"Storing release update: {release.release_title} by {release.artist_name}")
except Exception as e:
logger.error(f"Error storing release update: {e}")
async def _should_auto_download(self, follow: ArtistFollow, release: ReleaseUpdate) -> bool:
"""Determine if release should be auto-downloaded"""
try:
# Get user preferences
user_prefs = await self._get_user_preferences(follow.user_id)
# Check various conditions
conditions = [
follow.auto_download_new_releases,
self._is_preferred_release_type(release, user_prefs),
await self._has_storage_space(user_prefs),
not self._is_explicit_blocked(release, user_prefs),
self._within_download_limits(user_prefs)
]
return all(conditions)
except Exception as e:
logger.error(f"Error checking auto-download conditions: {e}")
return False
def _is_preferred_release_type(self, release: ReleaseUpdate, user_prefs: Dict) -> bool:
"""Check if release type matches user preferences"""
preferred_types = user_prefs.get('preferred_release_types', ['album', 'ep', 'single'])
return release.release_type.value in preferred_types
async def _has_storage_space(self, user_prefs: Dict) -> bool:
"""Check if there's enough storage space"""
# This would check available storage against user limits
# For now, return True
return True
def _is_explicit_blocked(self, release: ReleaseUpdate, user_prefs: Dict) -> bool:
"""Check if explicit content is blocked"""
return release.explicit and user_prefs.get('exclude_explicit', False)
def _within_download_limits(self, user_prefs: Dict) -> bool:
"""Check if within weekly download limits"""
# This would check current week's downloads against limits
# For now, return True
return True
async def _auto_download_release(self, follow: ArtistFollow, release: ReleaseUpdate):
"""Auto-download a release"""
try:
logger.info(f"Auto-downloading release: {release.release_title}")
# Get user preferences for quality
user_prefs = await self._get_user_preferences(follow.user_id)
quality = user_prefs.get('quality_preference', follow.preferred_quality)
# Download all tracks in release
tracks = await self.spotify_client.get_album_tracks(release.release_id)
download_tasks = []
for track in tracks:
task = self.downloader.download_from_url(
spotify_url=track['external_urls']['spotify'],
quality=quality,
auto_add_to_library=True,
metadata={
'auto_downloaded': True,
'release_id': release.release_id,
'artist_follow_id': follow.artist_id
}
)
download_tasks.append(task)
# Execute downloads concurrently
results = await asyncio.gather(*download_tasks, return_exceptions=True)
# Mark release as downloaded
await self._mark_release_downloaded(release, results)
logger.info(f"Auto-download completed for {release.release_title}")
except Exception as e:
logger.error(f"Error auto-downloading release {release.release_id}: {e}")
async def _send_update_notification(self, follow: ArtistFollow, release: ReleaseUpdate):
"""Send notification for new release"""
try:
user_prefs = await self._get_user_preferences(follow.user_id)
notification_data = {
'type': 'new_release',
'artist_name': release.artist_name,
'release_title': release.release_title,
'release_type': release.release_type.value,
'release_date': release.release_date.isoformat(),
'cover_image': release.cover_image_url,
'spotify_url': release.spotify_url,
'auto_download_enabled': follow.auto_download_new_releases,
'actions': [
{'label': 'Play Preview', 'action': 'preview'},
{'label': 'Download Now', 'action': 'download'},
{'label': 'Add to Queue', 'action': 'queue'}
]
}
# Send through enabled channels
if user_prefs.get('notification_channels', {}).get('in_app', True):
await self.notification_service.send_in_app_notification(follow.user_id, notification_data)
if user_prefs.get('notification_channels', {}).get('push', False):
await self.notification_service.send_push_notification(follow.user_id, notification_data)
# Mark notification as sent
await self._mark_notification_sent(release)
except Exception as e:
logger.error(f"Error sending notification for release {release.release_id}: {e}")
async def _get_user_preferences(self, user_id: int) -> Dict:
"""Get user's update monitoring preferences"""
try:
with db.session() as session:
# This would query update_monitoring_preferences table
# For now, return defaults
return {
'enable_artist_monitoring': True,
'check_frequency': 'daily',
'auto_download_favorites': False,
'auto_download_followed': False,
'max_auto_downloads_per_week': 5,
'quality_preference': 'flac',
'storage_limit_mb': 10240,
'notification_channels': {
'in_app': True,
'push': False,
'email': False,
'discord': False
},
'exclude_explicit': False,
'preferred_release_types': ['album', 'ep', 'single']
}
except Exception as e:
logger.error(f"Error getting user preferences for {user_id}: {e}")
return {}
async def _update_artist_check_date(self, follow: ArtistFollow):
"""Update the last check date for an artist follow"""
try:
follow.last_check_date = datetime.datetime.utcnow()
# This would update the artist_follows table
logger.debug(f"Updated check date for {follow.artist_name}")
except Exception as e:
logger.error(f"Error updating check date for {follow.artist_id}: {e}")
async def _mark_release_downloaded(self, release: ReleaseUpdate, results: List):
"""Mark a release as downloaded in database"""
try:
release.download_status = "completed"
release.auto_downloaded = True
release.processed_at = datetime.datetime.utcnow()
# This would update the release_updates table
logger.info(f"Marked release {release.release_id} as downloaded")
except Exception as e:
logger.error(f"Error marking release as downloaded: {e}")
async def _mark_notification_sent(self, release: ReleaseUpdate):
"""Mark notification as sent for release"""
try:
release.notification_sent = True
# This would update the release_updates table
logger.debug(f"Marked notification sent for release {release.release_id}")
except Exception as e:
logger.error(f"Error marking notification sent: {e}")
def _cleanup_old_notifications(self, session: Session, cutoff_date: datetime.datetime):
"""Clean up old notifications"""
try:
# This would delete from update_notifications table
logger.debug(f"Cleaning up notifications older than {cutoff_date}")
except Exception as e:
logger.error(f"Error cleaning up old notifications: {e}")
def _cleanup_old_releases(self, session: Session, cutoff_date: datetime.datetime):
"""Clean up old releases"""
try:
# This would delete old releases from release_updates table
# unless they were downloaded
logger.debug(f"Cleaning up releases older than {cutoff_date}")
except Exception as e:
logger.error(f"Error cleaning up old releases: {e}")
# Public API methods
async def follow_artist(self, follow_data: Dict) -> bool:
"""Follow an artist for update tracking"""
try:
follow = ArtistFollow(
user_id=follow_data['user_id'],
artist_id=follow_data['artist_id'],
artist_name=follow_data['artist_name'],
follow_level=FollowLevel(follow_data.get('follow_level', 'followed')),
auto_download_new_releases=follow_data.get('auto_download', False),
preferred_quality=follow_data.get('preferred_quality', 'flac')
)
# Store in database
with db.session() as session:
# This would insert into artist_follows table
logger.info(f"User {follow.user_id} followed artist: {follow.artist_name}")
return True
except Exception as e:
logger.error(f"Error following artist: {e}")
return False
async def unfollow_artist(self, user_id: int, artist_id: str) -> bool:
"""Unfollow an artist"""
try:
with db.session() as session:
# This would delete from artist_follows table
logger.info(f"User {user_id} unfollowed artist: {artist_id}")
return True
except Exception as e:
logger.error(f"Error unfollowing artist: {e}")
return False
async def get_user_updates(self, user_id: int, limit: int = 20) -> List[Dict]:
"""Get recent updates for a user"""
try:
with db.session() as session:
# This would query release_updates joined with artist_follows
# For now, return empty list
return []
except Exception as e:
logger.error(f"Error getting user updates: {e}")
return []
async def get_user_settings(self, user_id: int) -> Dict:
"""Get user's update tracking settings"""
return await self._get_user_preferences(user_id)
async def update_user_settings(self, user_id: int, settings: Dict) -> bool:
"""Update user's update tracking settings"""
try:
with db.session() as session:
# This would update update_monitoring_preferences table
logger.info(f"Updated settings for user {user_id}")
return True
except Exception as e:
logger.error(f"Error updating user settings: {e}")
return False
async def get_user_stats(self, user_id: int) -> Dict:
"""Get user's update tracking statistics"""
try:
with db.session() as session:
# This would calculate statistics from various tables
return {
'followed_artists': 0,
'new_releases': 0,
'pending_downloads': 0,
'auto_downloaded': 0,
'last_check': None
}
except Exception as e:
logger.error(f"Error getting user stats: {e}")
return {}
# Singleton instance
update_tracker = AutoUpdateTracker()
+5
View File
@@ -5,6 +5,7 @@ from swingmusic.plugins.register import register_plugins
from swingmusic.setup import load_into_mem, run_setup
from swingmusic.start_info_logger import log_startup_info
from swingmusic.utils.threading import background
from swingmusic.services.spotify_downloader import spotify_downloader
import setproctitle
@@ -90,6 +91,10 @@ def start_swingmusic(host: str, port: int):
setproctitle.setproctitle(f"swingmusic {host}:{port}")
start_cron_jobs()
# Start Spotify downloader service
spotify_downloader.start()
print("Spotify downloader service started")
app = app_builder.build()
+19
View File
@@ -1,4 +1,5 @@
import locale
import re
from typing import Iterable, TypeVar
T = TypeVar("T")
@@ -21,6 +22,24 @@ def flatten(list_: Iterable[list[T]]) -> list[T]:
return [item for sublist in list_ for item in sublist]
def create_valid_filename(filename: str) -> str:
"""
Create a valid filename by removing invalid characters.
"""
# Remove invalid characters for filenames
invalid_chars = r'[<>:"/\\|?*]'
filename = re.sub(invalid_chars, '_', filename)
# Remove leading/trailing spaces and dots
filename = filename.strip(' .')
# Ensure filename is not empty
if not filename:
filename = "unnamed"
return filename
class classproperty(property):
"""
A class property decorator.