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