mirror of
https://github.com/Dvorinka/swingmusic-extended.git
synced 2026-06-04 04:23:01 +00:00
38f1981283
- 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
436 lines
15 KiB
Python
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)
|