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