""" 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/', 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//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/', 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/', 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//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)