mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-03 20:13:02 +00:00
4338dd1d9c
- 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
622 lines
20 KiB
Python
622 lines
20 KiB
Python
"""
|
|
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
|