Files
swingmusic-extended/src/swingmusic/api/recap.py
T
Tomas Dvorak 38f1981283 Move backend files to root level for cleaner GitHub display
- Move all backend files from swingmusic/ to root level
- Backend files now display directly on GitHub repository page
- Keep client applications as submodules (swingmusic-android, swingmusic-desktop, swingmusic-webclient)
- Update README to reflect new structure (no cd swingmusic needed)
- Cleaner, more professional GitHub repository layout

Files moved to root:
- src/ (main source code)
- pyproject.toml, requirements.txt, run.py
- swingmusic.spec, uv.lock, version.txt
- services/

Result: GitHub shows backend files directly while maintaining organized structure
2026-03-17 22:37:49 +01:00

436 lines
15 KiB
Python

"""
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)