Files
swingmusic-extended/src/swingmusic/api/update_tracking.py
T
Tomas Dvorak 4338dd1d9c 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
2026-03-17 17:56:20 +01:00

602 lines
19 KiB
Python

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