Reorganize repository structure for better organization

- Move backend code to swingmusic/ folder
- Move client applications to root level (swingmusic-android, swingmusic-desktop, swingmusic-webclient)
- Remove intermediate backend/ and clients/ folders
- Update README with new folder structure and setup instructions
- Clean and organized repository layout
This commit is contained in:
Tomas Dvorak
2026-03-17 22:34:34 +01:00
parent 17e859dd2f
commit 4c04287800
206 changed files with 14 additions and 7 deletions
-621
View File
@@ -1,621 +0,0 @@
"""
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