mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-04 20:43:04 +00:00
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
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
"""
|
||||
Year-in-Review API Endpoints
|
||||
|
||||
This module provides REST API endpoints for the year-in-review experience,
|
||||
including recap generation, summary retrieval, and video generation.
|
||||
"""
|
||||
|
||||
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.recap_service import recap_service, RecapTheme
|
||||
from swingmusic.utils.request import APIError, success_response, error_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
recap_bp = Blueprint('recap', __name__, url_prefix='/api/recap')
|
||||
|
||||
|
||||
def get_current_user_id() -> int:
|
||||
"""Get current user ID from Flask-Login"""
|
||||
return current_user.id if current_user.is_authenticated else None
|
||||
|
||||
|
||||
@recap_bp.route('/generate/<int:year>', methods=['POST'])
|
||||
@login_required
|
||||
async def generate_recap(year: int):
|
||||
"""
|
||||
Generate year-in-review for a specific year
|
||||
|
||||
Path Parameters:
|
||||
- year: Year to generate recap for
|
||||
|
||||
Query Parameters:
|
||||
- force: Force regeneration even if recap exists (default: false)
|
||||
"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
force_regeneration = request.args.get('force', 'false').lower() == 'true'
|
||||
|
||||
# Check if recap already exists
|
||||
if not force_regeneration:
|
||||
existing_recap = await recap_service.get_recap_summary(user_id, year)
|
||||
if existing_recap:
|
||||
return success_response({
|
||||
'message': f'Recap for {year} already exists',
|
||||
'recap': existing_recap
|
||||
})
|
||||
|
||||
# Generate new recap
|
||||
recap_data = await recap_service.generate_year_recap(user_id, year)
|
||||
|
||||
return success_response({
|
||||
'message': f'Successfully generated recap for {year}',
|
||||
'recap_id': f"{user_id}_{year}",
|
||||
'year': recap_data.year,
|
||||
'stats': {
|
||||
'total_minutes': recap_data.stats.total_minutes,
|
||||
'total_tracks': recap_data.stats.total_tracks,
|
||||
'total_artists': recap_data.stats.total_artists,
|
||||
'unique_tracks': recap_data.stats.unique_tracks,
|
||||
'listening_streak': recap_data.stats.listening_streak,
|
||||
'personality_type': recap_data.personality.personality_type
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating recap: {e}")
|
||||
return error_response("Internal server error", 500)
|
||||
|
||||
|
||||
@recap_bp.route('/summary/<int:year>', methods=['GET'])
|
||||
@login_required
|
||||
async def get_recap_summary(year: int):
|
||||
"""
|
||||
Get recap summary for a specific year
|
||||
|
||||
Path Parameters:
|
||||
- year: Year to get recap summary for
|
||||
"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
|
||||
recap_summary = await recap_service.get_recap_summary(user_id, year)
|
||||
|
||||
if not recap_summary:
|
||||
return error_response(f"No recap found for year {year}", 404)
|
||||
|
||||
return success_response({
|
||||
'recap': recap_summary
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recap summary: {e}")
|
||||
return error_response("Internal server error", 500)
|
||||
|
||||
|
||||
@recap_bp.route('/details/<int:year>', methods=['GET'])
|
||||
@login_required
|
||||
async def get_recap_details(year: int):
|
||||
"""
|
||||
Get detailed recap data for a specific year
|
||||
|
||||
Path Parameters:
|
||||
- year: Year to get recap details for
|
||||
|
||||
Query Parameters:
|
||||
- include_top_tracks: Include top tracks data (default: true)
|
||||
- include_top_artists: Include top artists data (default: true)
|
||||
- include_top_albums: Include top albums data (default: true)
|
||||
- include_discoveries: Include discoveries data (default: true)
|
||||
- include_milestones: Include milestones data (default: true)
|
||||
"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
|
||||
# Get recap summary first
|
||||
recap_summary = await recap_service.get_recap_summary(user_id, year)
|
||||
|
||||
if not recap_summary:
|
||||
return error_response(f"No recap found for year {year}", 404)
|
||||
|
||||
# Parse include flags
|
||||
include_flags = {
|
||||
'top_tracks': request.args.get('include_top_tracks', 'true').lower() == 'true',
|
||||
'top_artists': request.args.get('include_top_artists', 'true').lower() == 'true',
|
||||
'top_albums': request.args.get('include_top_albums', 'true').lower() == 'true',
|
||||
'discoveries': request.args.get('include_discoveries', 'true').lower() == 'true',
|
||||
'milestones': request.args.get('include_milestones', 'true').lower() == 'true'
|
||||
}
|
||||
|
||||
# Load full recap data from file
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
recap_file = Path(recap_service.recap_dir) / f"recap_{user_id}_{year}.json"
|
||||
|
||||
if not recap_file.exists():
|
||||
return error_response(f"Recap data not found for year {year}", 404)
|
||||
|
||||
with open(recap_file, 'r') as f:
|
||||
full_recap_data = json.load(f)
|
||||
|
||||
# Build response based on include flags
|
||||
response_data = {
|
||||
'year': full_recap_data['year'],
|
||||
'stats': full_recap_data['stats'],
|
||||
'personality': full_recap_data['personality'],
|
||||
'monthly_breakdown': full_recap_data['monthly_breakdown'],
|
||||
'created_at': full_recap_data['created_at']
|
||||
}
|
||||
|
||||
if include_flags['top_tracks']:
|
||||
response_data['top_tracks'] = full_recap_data['top_tracks']
|
||||
|
||||
if include_flags['top_artists']:
|
||||
response_data['top_artists'] = full_recap_data['top_artists']
|
||||
|
||||
if include_flags['top_albums']:
|
||||
response_data['top_albums'] = full_recap_data['top_albums']
|
||||
|
||||
if include_flags['discoveries']:
|
||||
response_data['discoveries'] = full_recap_data['discoveries']
|
||||
|
||||
if include_flags['milestones']:
|
||||
response_data['milestones'] = full_recap_data['milestones']
|
||||
|
||||
return success_response({
|
||||
'recap': response_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recap details: {e}")
|
||||
return error_response("Internal server error", 500)
|
||||
|
||||
|
||||
@recap_bp.route('/video/<int:year>', methods=['POST'])
|
||||
@login_required
|
||||
async def generate_recap_video(year: int):
|
||||
"""
|
||||
Generate recap video for a specific year
|
||||
|
||||
Path Parameters:
|
||||
- year: Year to generate video for
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"theme": "modern|retro|minimal|vibrant|dark|light",
|
||||
"include_audio": true,
|
||||
"duration_limit": 180 // Optional: max duration in seconds
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate theme
|
||||
theme_name = data.get('theme', 'modern')
|
||||
try:
|
||||
theme = RecapTheme(theme_name)
|
||||
except ValueError:
|
||||
return error_response(f"Invalid theme: {theme_name}. Must be one of: {[t.value for t in RecapTheme]}", 400)
|
||||
|
||||
# Check if recap exists
|
||||
recap_summary = await recap_service.get_recap_summary(user_id, year)
|
||||
if not recap_summary:
|
||||
return error_response(f"No recap found for year {year}. Generate recap first.", 404)
|
||||
|
||||
# Generate video (this is a placeholder - would integrate with Remotion service)
|
||||
video_path = await recap_service.generate_recap_video(
|
||||
# This would need to load the full recap data
|
||||
None, # recap_data would be loaded here
|
||||
theme
|
||||
)
|
||||
|
||||
return success_response({
|
||||
'message': f'Video generation started for {year}',
|
||||
'video_path': video_path,
|
||||
'theme': theme.value,
|
||||
'estimated_completion': '2-5 minutes'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating recap video: {e}")
|
||||
return error_response("Internal server error", 500)
|
||||
|
||||
|
||||
@recap_bp.route('/available-years', methods=['GET'])
|
||||
@login_required
|
||||
async def get_available_years():
|
||||
"""
|
||||
Get list of years for which recaps are available
|
||||
"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
|
||||
# Scan recap directory for user's recaps
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
recap_dir = Path(recap_service.recap_dir)
|
||||
available_years = []
|
||||
|
||||
if recap_dir.exists():
|
||||
for file_path in recap_dir.glob(f"recap_{user_id}_*.json"):
|
||||
# Extract year from filename
|
||||
parts = file_path.stem.split('_')
|
||||
if len(parts) >= 3:
|
||||
year = parts[2]
|
||||
try:
|
||||
year_int = int(year)
|
||||
available_years.append(year_int)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Sort years in descending order
|
||||
available_years.sort(reverse=True)
|
||||
|
||||
return success_response({
|
||||
'available_years': available_years,
|
||||
'total_recaps': len(available_years)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available years: {e}")
|
||||
return error_response("Internal server error", 500)
|
||||
|
||||
|
||||
@recap_bp.route('/share/<int:year>', methods=['POST'])
|
||||
@login_required
|
||||
async def create_shareable_link(year: int):
|
||||
"""
|
||||
Create a shareable link for recap
|
||||
|
||||
Path Parameters:
|
||||
- year: Year to create shareable link for
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"include_personal_data": false,
|
||||
"expires_in_days": 30
|
||||
}
|
||||
"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
|
||||
# Get request data
|
||||
data = request.get_json() or {}
|
||||
include_personal_data = data.get('include_personal_data', False)
|
||||
expires_in_days = data.get('expires_in_days', 30)
|
||||
|
||||
# Check if recap exists
|
||||
recap_summary = await recap_service.get_recap_summary(user_id, year)
|
||||
if not recap_summary:
|
||||
return error_response(f"No recap found for year {year}", 404)
|
||||
|
||||
# Generate shareable link (this is a placeholder implementation)
|
||||
import secrets
|
||||
import hashlib
|
||||
|
||||
# Generate unique token
|
||||
token_data = f"{user_id}_{year}_{datetime.utcnow().timestamp()}"
|
||||
share_token = hashlib.sha256(token_data.encode()).hexdigest()[:16]
|
||||
|
||||
# Create shareable data
|
||||
shareable_data = {
|
||||
'year': year,
|
||||
'stats': {
|
||||
'total_minutes': recap_summary['total_minutes'],
|
||||
'total_tracks': recap_summary['total_tracks'],
|
||||
'personality_type': recap_summary['personality_type']
|
||||
},
|
||||
'top_track': recap_summary.get('top_track'),
|
||||
'top_artist': recap_summary.get('top_artist'),
|
||||
'created_at': recap_summary['created_at']
|
||||
}
|
||||
|
||||
# Save shareable data (in a real implementation, this would go to database)
|
||||
share_file = Path(recap_service.recap_dir) / f"share_{share_token}.json"
|
||||
import json
|
||||
with open(share_file, 'w') as f:
|
||||
json.dump({
|
||||
'user_id': user_id,
|
||||
'year': year,
|
||||
'data': shareable_data,
|
||||
'expires_at': (datetime.utcnow() + datetime.timedelta(days=expires_in_days)).isoformat(),
|
||||
'created_at': datetime.utcnow().isoformat()
|
||||
}, f)
|
||||
|
||||
share_url = f"/recap/shared/{share_token}"
|
||||
|
||||
return success_response({
|
||||
'share_url': share_url,
|
||||
'share_token': share_token,
|
||||
'expires_in_days': expires_in_days,
|
||||
'includes_personal_data': include_personal_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating shareable link: {e}")
|
||||
return error_response("Internal server error", 500)
|
||||
|
||||
|
||||
@recap_bp.route('/shared/<token>', methods=['GET'])
|
||||
async def get_shared_recap(token: str):
|
||||
"""
|
||||
Get shared recap by token (public endpoint)
|
||||
|
||||
Path Parameters:
|
||||
- token: Share token
|
||||
"""
|
||||
try:
|
||||
# Load share data
|
||||
share_file = Path(recap_service.recap_dir) / f"share_{token}.json"
|
||||
|
||||
if not share_file.exists():
|
||||
return error_response("Shared recap not found or expired", 404)
|
||||
|
||||
import json
|
||||
with open(share_file, 'r') as f:
|
||||
share_data = json.load(f)
|
||||
|
||||
# Check if expired
|
||||
expires_at = datetime.fromisoformat(share_data['expires_at'])
|
||||
if datetime.utcnow() > expires_at:
|
||||
share_file.unlink() # Clean up expired share
|
||||
return error_response("Shared recap has expired", 410)
|
||||
|
||||
return success_response({
|
||||
'shared_recap': share_data['data'],
|
||||
'year': share_data['year'],
|
||||
'created_at': share_data['created_at']
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting shared recap: {e}")
|
||||
return error_response("Internal server error", 500)
|
||||
|
||||
|
||||
@recap_bp.route('/compare/<int:year1>/<int:year2>', methods=['GET'])
|
||||
@login_required
|
||||
async def compare_years(year1: int, year2: int):
|
||||
"""
|
||||
Compare recaps between two years
|
||||
|
||||
Path Parameters:
|
||||
- year1: First year to compare
|
||||
- year2: Second year to compare
|
||||
"""
|
||||
try:
|
||||
user_id = get_current_user_id()
|
||||
|
||||
# Get both recaps
|
||||
recap1 = await recap_service.get_recap_summary(user_id, year1)
|
||||
recap2 = await recap_service.get_recap_summary(user_id, year2)
|
||||
|
||||
if not recap1:
|
||||
return error_response(f"No recap found for year {year1}", 404)
|
||||
|
||||
if not recap2:
|
||||
return error_response(f"No recap found for year {year2}", 404)
|
||||
|
||||
# Calculate comparisons
|
||||
comparison = {
|
||||
'year1': year1,
|
||||
'year2': year2,
|
||||
'listening_time_change': {
|
||||
'absolute': recap2['total_minutes'] - recap1['total_minutes'],
|
||||
'percentage': ((recap2['total_minutes'] - recap1['total_minutes']) / recap1['total_minutes'] * 100) if recap1['total_minutes'] > 0 else 0
|
||||
},
|
||||
'tracks_change': {
|
||||
'absolute': recap2['total_tracks'] - recap1['total_tracks'],
|
||||
'percentage': ((recap2['total_tracks'] - recap1['total_tracks']) / recap1['total_tracks'] * 100) if recap1['total_tracks'] > 0 else 0
|
||||
},
|
||||
'personality_change': {
|
||||
'from': recap1['personality_type'],
|
||||
'to': recap2['personality_type'],
|
||||
'changed': recap1['personality_type'] != recap2['personality_type']
|
||||
}
|
||||
}
|
||||
|
||||
return success_response({
|
||||
'comparison': comparison,
|
||||
'recap1': recap1,
|
||||
'recap2': recap2
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error comparing years: {e}")
|
||||
return error_response("Internal server error", 500)
|
||||
Reference in New Issue
Block a user