nifty-coder/stemsplit-backend
0
1import os2import tempfile3import shutil4import subprocess5import logging6import zipfile7import json8import hashlib9import uuid10import requests11import secrets12from datetime import datetime, timedelta13from dotenv import load_dotenv14import boto315import traceback16 17# Load local .env if present18load_dotenv()19from botocore.client import Config20from fastapi.middleware.cors import CORSMiddleware21from fastapi.responses import FileResponse22from pydantic import BaseModel23from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks, Response, Request, Depends, Header, Form, WebSocket, WebSocketDisconnect24from deepgram import DeepgramClient25 26 27# Logging setup28logging.basicConfig(level=logging.INFO)29logger = logging.getLogger(__name__)30 31# ----------------------------------------32 33# FastAPI app34app = FastAPI()35 36# Configure CORS37default_origins = [38 "http://localhost:8080",39 "https://aimusicapp-a2b65.web.app",40 "https://aimusicapp-a2b65.firebaseapp.com",41 "https://niftydaw.com",42 "https://www.niftydaw.com",43]44allowed_origins_env = os.environ.get('ALLOWED_ORIGINS')45allowed_origins = [o.strip() for o in allowed_origins_env.split(',') if o.strip()] if allowed_origins_env else default_origins46 47app.add_middleware(48 CORSMiddleware,49 allow_origins=allowed_origins,50 allow_credentials=True,51 allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],52 allow_headers=["Authorization", "Content-Type", "X-Requested-With"],53 expose_headers=["X-Cache-Key", "X-Video-Title", "Content-Disposition"],54)55 56@app.options("/upload")57def options_handler(request: Request):58 origin = request.headers.get("origin")59 return Response(status_code=200, headers={60 "Access-Control-Allow-Origin": origin or "*",61 "Access-Control-Allow-Methods": "POST, OPTIONS",62 "Access-Control-Allow-Headers": "Content-Type, Authorization",63 })64 65@app.get("/")66def health_check():67 return {"status": "healthy", "message": "NiftyDAW Backend is running!"}68 69# --- Utility Functions ---70def find_ffmpeg() -> str:71 common_paths = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"]72 for path in common_paths:73 if os.path.exists(path) and os.access(path, os.X_OK):74 return path75 76 # Check current directory77 local_binary = os.path.join(os.getcwd(), "ffmpeg")78 if os.path.exists(local_binary):79 return local_binary80 81 return shutil.which("ffmpeg") or "ffmpeg"82 83FFMPEG_PATH = find_ffmpeg()84FFMPEG_DIR = os.path.dirname(FFMPEG_PATH) if FFMPEG_PATH != "ffmpeg" else None85logger.info(f"Using ffmpeg at: {FFMPEG_PATH}")86 87def find_demucs() -> list:88 import sys89 path = shutil.which("demucs")90 if path: return [path]91 try:92 import demucs93 return [sys.executable, "-m", "demucs"]94 except ImportError:95 return ["demucs"]96 97DEMUCS_CMD = find_demucs()98CACHE_DIR = os.path.join(tempfile.gettempdir(), "aimusic_cache")99os.makedirs(CACHE_DIR, exist_ok=True)100 101# --- Firebase Admin Setup ---102firebase_admin = None103firebase_auth = None104FIREBASE_INITIALIZED = False105 106try:107 import firebase_admin108 from firebase_admin import credentials, auth as firebase_auth109 if not firebase_admin._apps:110 cred_json = os.environ.get('FIREBASE_CREDENTIALS')111 initialized = False112 113 if cred_json:114 try:115 # Try as JSON string first116 cred_dict = json.loads(cred_json)117 firebase_admin.initialize_app(credentials.Certificate(cred_dict))118 FIREBASE_INITIALIZED = True119 initialized = True120 logger.info("Firebase Admin initialized via environment variable (JSON)")121 except json.JSONDecodeError:122 # Try as file path if NOT valid JSON123 if os.path.exists(cred_json):124 firebase_admin.initialize_app(credentials.Certificate(cred_json))125 FIREBASE_INITIALIZED = True126 initialized = True127 logger.info(f"Firebase Admin initialized via environment variable (path): {cred_json}")128 129 if not initialized:130 # Fallback: Try to find a JSON file in the project folder131 for f in os.listdir(os.path.dirname(os.path.abspath(__file__))):132 if f.endswith('.json') and 'firebase' in f.lower() and 'admin' in f.lower():133 firebase_admin.initialize_app(credentials.Certificate(f))134 FIREBASE_INITIALIZED = True135 logger.info(f"Firebase Admin initialized via file: {f}")136 break137except Exception as e:138 logger.warning(f"Firebase Admin setup skipped: {e}")139 140# --- Deepgram Speech Setup ---141@app.websocket("/ws/transcribe")142async def websocket_transcribe(websocket: WebSocket):143 logger.info("New WebSocket connection request matched /ws/transcribe")144 try:145 await websocket.accept()146 logger.info("WebSocket connection accepted")147 except Exception as e:148 logger.error(f"Failed to accept WebSocket: {e}")149 return150 151 api_key = os.environ.get("DEEPGRAM_API_KEY")152 if not api_key:153 logger.error("DEEPGRAM_API_KEY not found in environment variables!")154 await websocket.close(code=1008, reason="Missing API configuration")155 return156 else:157 logger.info(f"DEEPGRAM_API_KEY found (length: {len(api_key)})")158 159 try:160 from deepgram import AsyncDeepgramClient161 import asyncio162 163 # Initialize Async Deepgram Client164 deepgram = AsyncDeepgramClient(api_key=api_key)165 166 # Configure options - MUST use string values as per SDK signature for V1Client167 options = {168 "model": "nova-2",169 "language": "en-US",170 "smart_format": "true",171 "interim_results": "true",172 # "encoding": "opus", # Let Deepgram detect container173 }174 175 # Connect using async context manager176 # deepgram.listen.v1.connect returns an AsyncV1SocketClient177 async with deepgram.listen.v1.connect(**options) as dg_connection:178 logger.info("Deepgram connection started (AsyncV1)")179 180 # Define a task to read from Deepgram and send to frontend181 async def receive_from_deepgram():182 try:183 async for message in dg_connection:184 # message is a V1SocketClientResponse union (likely Pydantic models)185 # We need to extract transcript186 transcript = ""187 is_final = False188 189 # Inspect the message type190 # It could be ListenV1ResultsEvent, ListenV1MetadataEvent, etc.191 # We'll try to convert to dict or access attributes safely192 try:193 # If it's a Pydantic model (ListenV1ResultsEvent)194 if hasattr(message, 'channel'):195 if message.channel and message.channel.alternatives:196 alt = message.channel.alternatives[0]197 transcript = alt.transcript198 if hasattr(message, 'is_final'):199 is_final = message.is_final200 # If it's a raw dict (fallback)201 elif isinstance(message, dict):202 transcript = message.get('channel', {}).get('alternatives', [{}])[0].get('transcript', "")203 is_final = message.get('is_final', False)204 205 if transcript:206 logger.info(f"Sending transcript: {transcript} (Final: {is_final})")207 await websocket.send_json({208 "transcript": transcript,209 "isFinal": is_final210 })211 except Exception as e:212 logger.error(f"Error processing Deepgram message: {e}")213 214 except Exception as e:215 logger.error(f"Deepgram receiver loop ended: {e}")216 217 # Start receiver task218 receiver_task = asyncio.create_task(receive_from_deepgram())219 220 # Forward audio from frontend to Deepgram (Main Loop)221 chunk_count = 0222 try:223 while True:224 data = await websocket.receive_bytes()225 chunk_count += 1226 if chunk_count <= 3:227 logger.info(f"Received audio chunk #{chunk_count}, size: {len(data)}")228 229 # Send to deepgram230 await dg_connection.send_media(data)231 232 except WebSocketDisconnect:233 logger.info(f"WebSocket disconnected after {chunk_count} chunks")234 except Exception as e:235 logger.error(f"Error in audio sender loop: {e}")236 finally:237 # Cancel receiver task when audio stream ends238 receiver_task.cancel()239 try:240 await receiver_task241 except asyncio.CancelledError:242 pass243 244 except Exception as e:245 logger.error(f"Transcription setup error: {e}")246 try:247 await websocket.close()248 except:249 pass250 251 252# --- Cloudflare R2 Setup ---253R2_ENDPOINT = os.environ.get('R2_ENDPOINT')254R2_ACCESS_KEY_ID = os.environ.get('R2_ACCESS_KEY_ID')255R2_SECRET_ACCESS_KEY = os.environ.get('R2_SECRET_ACCESS_KEY')256R2_BUCKET_NAME = os.environ.get('R2_BUCKET_NAME')257R2_ACCOUNT_ID = os.environ.get('R2_ACCOUNT_ID')258 259s3_client = None260R2_ENABLED = False261# 10 GB limit262MAX_STORAGE_BYTES = 10 * (1000 ** 3)263 264if all([R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME]):265 try:266 s3_client = boto3.client(267 's3',268 endpoint_url=R2_ENDPOINT,269 aws_access_key_id=R2_ACCESS_KEY_ID,270 aws_secret_access_key=R2_SECRET_ACCESS_KEY,271 config=Config(signature_version='s3v4'),272 region_name='auto' # R2 uses 'auto' for region273 )274 R2_ENABLED = True275 logger.info(f"R2 client initialized for bucket: {R2_BUCKET_NAME}")276 except Exception as e:277 logger.error(f"Failed to initialize R2 client: {e}")278else:279 logger.warning("R2 credentials not found. File storage will use local temp directory.")280 281def get_r2_usage_bytes() -> int:282 """Calculate the total storage usage of the R2 bucket in bytes."""283 if not R2_ENABLED:284 return 0285 286 total_size = 0287 try:288 paginator = s3_client.get_paginator('list_objects_v2')289 for page in paginator.paginate(Bucket=R2_BUCKET_NAME):290 if 'Contents' in page:291 for obj in page['Contents']:292 total_size += obj.get('Size', 0)293 return total_size294 except Exception as e:295 logger.error(f"Error calculating R2 usage: {e}")296 return 0297 298def check_storage_limit():299 """Check if the R2 storage limit has been reached."""300 if not R2_ENABLED:301 return302 303 usage = get_r2_usage_bytes()304 logger.info(f"Current R2 storage usage: {usage / (1024*1024*1024):.2f} GB / {MAX_STORAGE_BYTES / (1024*1024*1024):.2f} GB")305 306 if usage >= MAX_STORAGE_BYTES:307 raise HTTPException(308 status_code=507, 309 detail="Storage limit reached. Please try again tomorrow."310 )311 312# --- SMTP2GO & Activation Configuration ---313SMTP2GO_API_KEY = os.environ.get('SMTP2GO_API_KEY')314SMTP2GO_SENDER = os.environ.get('SMTP2GO_SENDER', 'contact@niftysoftsol.com')315ACTIVATIONS_FILE = os.path.join(CACHE_DIR, "activations.json")316 317def load_activations() -> dict:318 try:319 if os.path.exists(ACTIVATIONS_FILE):320 with open(ACTIVATIONS_FILE, 'r', encoding='utf-8') as fh:321 return json.loads(fh.read())322 except Exception:323 logger.exception('Failed to load activations file')324 return {}325 326def save_activations(data: dict) -> None:327 try:328 with open(ACTIVATIONS_FILE, 'w', encoding='utf-8') as fh:329 fh.write(json.dumps(data, ensure_ascii=False, indent=2))330 except Exception:331 logger.exception('Failed to save activations file')332 333def send_activation_email(email: str, token: str, origin: str):334 if not SMTP2GO_API_KEY:335 logger.warning("SMTP2GO_API_KEY not found. Skipping email sending.")336 logger.info(f"Activation link for {email}: {origin}/activate?token={token}")337 return338 339 activation_url = f"{origin}/activate?token={token}"340 341 try:342 response = requests.post(343 'https://api.smtp2go.com/v3/email/send',344 json={345 'api_key': SMTP2GO_API_KEY,346 'sender': SMTP2GO_SENDER,347 'to': [email],348 'subject': 'Activate your NiftyDAW account',349 'html_body': f"""350 <h1>Welcome to NiftyDAW!</h1>351 <p>Please click the link below to activate your account. This link is valid for 15 minutes.</p>352 <a href="{activation_url}" style="display: inline-block; padding: 10px 20px; background-color: #007bff; color: white; text-decoration: none; border-radius: 5px;">Activate Account</a>353 <p>If the button doesn't work, copy and paste this link into your browser:</p>354 <p>{activation_url}</p>355 """356 },357 timeout=10358 )359 result = response.json()360 if response.status_code == 200 and result.get('data', {}).get('succeeded'):361 logger.info(f"Activation email sent to {email}")362 else:363 logger.error(f"Failed to send activation email: {result}")364 except Exception as e:365 logger.error(f"Error sending activation email: {e}")366 367# --- reCAPTCHA Verification ---368RECAPTCHA_SECRET_KEY = os.environ.get('RECAPTCHA_SECRET_KEY')369 370def verify_recaptcha(token: str, action: str = 'upload') -> bool:371 """Verify Google reCAPTCHA v3 token"""372 if not RECAPTCHA_SECRET_KEY:373 logger.warning("RECAPTCHA_SECRET_KEY not found. Skipping verification.")374 return True375 376 try:377 response = requests.post(378 'https://www.google.com/recaptcha/api/siteverify',379 data={380 'secret': RECAPTCHA_SECRET_KEY,381 'response': token382 },383 timeout=10384 )385 result = response.json()386 387 if result.get('success') and result.get('action') == action:388 score = result.get('score', 0)389 if score >= 0.5:390 logger.info(f"reCAPTCHA verified for action '{action}' with score {score}")391 return True392 else:393 logger.warning(f"reCAPTCHA score too low: {score} for action '{action}'")394 return False395 396 logger.warning(f"reCAPTCHA verification failed: {result}")397 return False398 except Exception as e:399 logger.error(f"reCAPTCHA verification error: {e}")400 return False401 402# --- JWT Verification ---403def get_authenticated_user(authorization: str | None) -> dict:404 """405 Core authentication helper to verify Firebase token.406 Returns a dictionary with {'user_id': uid, 'email': email}.407 Includes a development bypass if DEV_ALLOW_PROFILE_NO_AUTH is set.408 """409 # Development bypass410 dev_bypass = os.environ.get('DEV_ALLOW_PROFILE_NO_AUTH', '').lower() in ('1', 'true', 'yes')411 if dev_bypass:412 uid, email = 'dev-user', 'dev@example.com'413 if authorization and authorization.lower().startswith('bearer '):414 token = authorization.split(' ', 1)[1].strip()415 if ':' in token:416 parts = token.split(':', 1)417 uid = parts[0] or uid418 if len(parts) > 1: email = parts[1] or email419 return {'user_id': uid, 'email': email}420 421 if not authorization or not authorization.lower().startswith('bearer '):422 raise HTTPException(status_code=401, detail='Missing or invalid Authorization header')423 424 if not FIREBASE_INITIALIZED:425 raise HTTPException(status_code=500, detail="Firebase not initialized")426 427 token = authorization.split(' ', 1)[1].strip()428 429 try:430 decoded_token = firebase_auth.verify_id_token(token)431 uid = decoded_token.get('uid')432 if not uid:433 raise HTTPException(status_code=401, detail="Invalid token: missing uid")434 435 # Check for email verification436 user_record = firebase_auth.get_user(uid)437 438 # Check if they have a trusted provider (like Google)439 providers = [p.provider_id for p in user_record.provider_data]440 is_trusted_provider = 'google.com' in providers441 442 if not user_record.email_verified and not is_trusted_provider:443 logger.warning(f"Blocking unverified user: {uid} (Providers: {providers})")444 raise HTTPException(status_code=403, detail='Email not verified. Please check your inbox.')445 446 return {'user_id': uid, 'email': decoded_token.get('email')}447 except HTTPException as he:448 raise he449 except firebase_admin.exceptions.ExpiredIdTokenError:450 raise HTTPException(status_code=401, detail="Token expired")451 except Exception as e:452 logger.error(f"Token verification failed: {e}")453 raise HTTPException(status_code=401, detail="Token verification failed")454 455def verify_firebase_token(authorization: str | None) -> str:456 """457 Legacy helper that returns only the UID string.458 Used for S3 path construction and general auth checks.459 """460 user = get_authenticated_user(authorization)461 return user['user_id']462 463PROFILES_FILE = os.path.join(CACHE_DIR, "profiles.json")464 465class ProfileIn(BaseModel):466 display_name: str | None = None467 profile_picture: str | None = None468 469class ProfileOut(BaseModel):470 user_id: str471 display_name: str | None = None472 email: str | None = None473 profile_picture: str | None = None474 created_at: str | None = None475 updated_at: str | None = None476 477def load_profiles() -> dict:478 try:479 if os.path.exists(PROFILES_FILE):480 with open(PROFILES_FILE, 'r', encoding='utf-8') as fh:481 return json.loads(fh.read())482 except Exception:483 logger.exception('Failed to load profiles file')484 return {}485 486def save_profiles(data: dict) -> None:487 try:488 with open(PROFILES_FILE, 'w', encoding='utf-8') as fh:489 fh.write(json.dumps(data, ensure_ascii=False, indent=2))490 except Exception:491 logger.exception('Failed to save profiles file')492 493def get_current_user(authorization: str | None = Header(None)):494 """495 Dependency helper for FastAPI routes.496 Returns the full user info dictionary.497 """498 return get_authenticated_user(authorization)499 500def get_user_storage_prefixes(user: dict) -> list[str]:501 """502 Returns the storage prefix for R2 for a given user.503 Uses the Python str(dict) representation as requested: {'user_id': 'uid', 'email': 'email'}504 """505 uid = user['user_id']506 email = user['email']507 508 # User requested to ONLY use this specific format509 prefix_dict = {"user_id": uid, "email": email}510 return [f"uploads/{str(prefix_dict)}/"]511 512@app.get("/api/profile", response_model=ProfileOut)513def get_profile(user=Depends(get_current_user)):514 profiles = load_profiles()515 p = profiles.get(user['user_id'], {})516 return {517 "user_id": user['user_id'],518 "email": user['email'],519 "display_name": p.get("display_name"),520 "profile_picture": p.get("profile_picture"),521 "created_at": p.get("created_at"),522 "updated_at": p.get("updated_at")523 }524 525@app.api_route("/api/profile", methods=["POST", "PUT"], response_model=ProfileOut)526def update_profile(data: ProfileIn, user=Depends(get_current_user)):527 profiles = load_profiles()528 u_id = user['user_id']529 now = datetime.utcnow().isoformat()530 531 if u_id not in profiles:532 profiles[u_id] = {"created_at": now}533 534 p = profiles[u_id]535 536 # Use exclude_unset=True to detect fields explicitly set to None/null in the request537 update_data = data.dict(exclude_unset=True)538 if "display_name" in update_data:539 p["display_name"] = update_data["display_name"]540 if "profile_picture" in update_data:541 p["profile_picture"] = update_data["profile_picture"]542 543 p["updated_at"] = now544 545 save_profiles(profiles)546 return {**p, "user_id": u_id, "email": user['email']}547 548@app.delete("/api/profile")549async def delete_profile(authorization: str = Header(...)):550 """551 Delete the authenticated user's profile and all associated files.552 """553 user = get_authenticated_user(authorization)554 uid = user['user_id']555 556 # 1. Delete all R2 files557 if R2_ENABLED:558 try:559 user_prefixes = get_user_storage_prefixes(user)560 deleted_count = 0561 for prefix in user_prefixes:562 while True:563 response = s3_client.list_objects_v2(564 Bucket=R2_BUCKET_NAME,565 Prefix=prefix566 )567 568 if 'Contents' not in response:569 break570 571 delete_objects = {'Objects': [{'Key': obj['Key']} for obj in response['Contents']]}572 s3_client.delete_objects(573 Bucket=R2_BUCKET_NAME,574 Delete=delete_objects575 )576 deleted_count += len(delete_objects['Objects'])577 578 if not response.get('IsTruncated'):579 break580 logger.info(f"Deleted {deleted_count} files for user {uid} during profile deletion")581 except Exception as e:582 logger.error(f"Failed to delete files during profile deletion key={uid}: {e}")583 # Proceed to delete profile even if storage cleanup fails partially584 pass585 586 # 2. Delete profile data587 profiles = load_profiles()588 if uid in profiles:589 del profiles[uid]590 save_profiles(profiles)591 logger.info(f"Deleted profile data for user {uid}")592 593 return {"message": "Profile and all data deleted successfully"}594 595# --- Authentication & Activation Endpoints ---596 597class SignupRequest(BaseModel):598 email: str599 password: str600 601class ActivateRequest(BaseModel):602 token: str603 604@app.post("/api/signup")605async def signup(request: SignupRequest, req: Request, background_tasks: BackgroundTasks):606 if not FIREBASE_INITIALIZED:607 raise HTTPException(status_code=500, detail="Firebase not initialized")608 609 origin = req.headers.get('origin')610 if origin not in allowed_origins:611 # Fallback to the first allowed origin if not provided or invalid (for safety)612 origin = allowed_origins[0] if allowed_origins else "http://localhost:8080"613 614 # Validate password complexity615 import re616 password_regex = r"^(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*(),.?\":{}|<>]).{8,}$"617 if not re.match(password_regex, request.password):618 raise HTTPException(619 status_code=400, 620 detail="Password must be at least 8 characters long and include an uppercase letter, a number, and a special character."621 )622 623 logger.info(f"Signup request for email {request.email} from origin {origin}")624 try:625 # Create user in Firebase with email_verified=False626 user = firebase_auth.create_user(627 email=request.email,628 password=request.password,629 email_verified=False630 )631 632 # Generate activation token633 token = secrets.token_urlsafe(32)634 expiry = (datetime.utcnow() + timedelta(minutes=15)).isoformat()635 636 activations = load_activations()637 activations[token] = {638 "uid": user.uid,639 "email": request.email,640 "expires_at": expiry641 }642 save_activations(activations)643 644 # Send email in background645 background_tasks.add_task(send_activation_email, request.email, token, origin)646 # Also clean up expired accounts647 background_tasks.add_task(cleanup_unactivated_users)648 649 return {"message": "Signup successful. Please check your email for activation link."}650 except Exception as e:651 logger.error(f"Signup error: {e}")652 raise HTTPException(status_code=400, detail=str(e))653 654@app.post("/api/activate")655async def activate(request: ActivateRequest):656 if not FIREBASE_INITIALIZED:657 raise HTTPException(status_code=500, detail="Firebase not initialized")658 659 activations = load_activations()660 data = activations.get(request.token)661 662 if not data:663 raise HTTPException(status_code=404, detail="Invalid or expired activation token.")664 665 expiry = datetime.fromisoformat(data['expires_at'])666 if datetime.utcnow() > expiry:667 # Cleanup expired user from Firebase668 try:669 uid = data['uid']670 user = firebase_auth.get_user(uid)671 if not user.email_verified:672 logger.info(f"Cleaning up expired user during activation attempt: {uid}")673 firebase_auth.delete_user(uid)674 except Exception as cleanup_err:675 logger.error(f"Failed to cleanup user during activation attempt: {cleanup_err}")676 677 # Remove token678 del activations[request.token]679 save_activations(activations)680 raise HTTPException(status_code=400, detail="Activation token has expired. Please sign up again.")681 682 try:683 # Mark user as verified in Firebase684 firebase_auth.update_user(data['uid'], email_verified=True)685 686 # Remove token687 del activations[request.token]688 save_activations(activations)689 690 return {"message": "Account activated successfully. You can now log in."}691 except Exception as e:692 logger.error(f"Activation error: {e}")693 raise HTTPException(status_code=500, detail="Failed to activate account.")694 695@app.get("/api/check-password-user")696def check_password_user(email: str):697 """698 Check if a user with the given email already has a 'password' provider.699 Used by Google Auth flow to prevent duplicates.700 """701 if not FIREBASE_INITIALIZED:702 raise HTTPException(status_code=500, detail="Firebase not initialized")703 704 try:705 user = firebase_auth.get_user_by_email(email)706 # Check providers on this specific user object707 providers = [p.provider_id for p in user.provider_data]708 logger.info(f"Checking providers for {email}: {providers}")709 710 has_password = 'password' in providers711 return {"has_password": has_password, "providers": providers}712 except firebase_admin.auth.UserNotFoundError:713 return {"has_password": False, "providers": []}714 except Exception as e:715 logger.error(f"Error checking password user: {e}")716 raise HTTPException(status_code=500, detail=str(e))717 718def cleanup_unactivated_users():719 """Delete expired activation tokens and their associated Firebase users."""720 if not FIREBASE_INITIALIZED:721 return722 723 activations = load_activations()724 now = datetime.utcnow()725 tokens_to_remove = []726 727 for token, data in activations.items():728 expiry = datetime.fromisoformat(data['expires_at'])729 if now > expiry:730 uid = data['uid']731 try:732 # Check if user is already verified (maybe by other means, though unlikely here)733 user = firebase_auth.get_user(uid)734 if not user.email_verified:735 logger.info(f"Cleaning up unactivated user: {uid} ({data['email']})")736 firebase_auth.delete_user(uid)737 tokens_to_remove.append(token)738 except Exception as e:739 logger.error(f"Error during cleanup for user {uid}: {e}")740 # Still remove token if user not found741 if "user-not-found" in str(e).lower():742 tokens_to_remove.append(token)743 744 if tokens_to_remove:745 for t in tokens_to_remove:746 del activations[t]747 save_activations(activations)748 logger.info(f"Cleaned up {len(tokens_to_remove)} expired activation(s)")749 750# --- Core Logic ---751 752def run_demucs_and_extract(input_path: str, tmpdir: str, requested_stems: list = None) -> str:753 if requested_stems is None:754 requested_stems = ["vocals", "drums", "bass", "other", "instrumental"]755 756 output_dir = os.path.join(tmpdir, "stems")757 os.makedirs(output_dir, exist_ok=True)758 759 cmd = DEMUCS_CMD + ["-o", output_dir, input_path]760 # Run demucs (suppress verbose output)761 try:762 subprocess.run(cmd, check=True, capture_output=True, text=True)763 except subprocess.CalledProcessError as e:764 logger.error(f"Demucs failed code={e.returncode}")765 logger.error(f"Demucs stderr: {e.stderr}")766 logger.error(f"Demucs stdout: {e.stdout}")767 raise e768 769 mp3_dir = os.path.join(tmpdir, "stems_mp3")770 os.makedirs(mp3_dir, exist_ok=True)771 772 # Walk to find all .wav files (handles various models like htdemucs)773 wav_count = 0774 for root, dirs, files in os.walk(output_dir):775 for f in files:776 if f.endswith('.wav'):777 basename = f.replace('.wav', '').lower()778 # Map Demucs names to our stem names779 is_requested = False780 if basename in requested_stems:781 is_requested = True782 elif basename == 'vocal' and 'vocals' in requested_stems:783 is_requested = True784 elif basename == 'drum' and 'drums' in requested_stems:785 is_requested = True786 787 if not is_requested:788 continue789 790 src = os.path.join(root, f)791 dst = os.path.join(mp3_dir, f.replace('.wav', '.mp3'))792 # Convert WAV to MP3 (suppress ffmpeg output)793 subprocess.run([FFMPEG_PATH, "-y", "-i", src, "-codec:a", "libmp3lame", "-qscale:a", "2", dst], 794 check=True, capture_output=True, text=True)795 wav_count += 1796 797 logger.info(f"Processed {wav_count} stems")798 if wav_count == 0:799 raise Exception("Demucs produced no output files. Check if the model is installed and the input is valid.")800 801 # Create instrumental mix if requested802 if 'instrumental' in requested_stems:803 stems = [os.path.join(mp3_dir, f) for f in os.listdir(mp3_dir) if 'vocal' not in f.lower() and f.endswith('.mp3')]804 if stems:805 inst_path = os.path.join(mp3_dir, 'instrumental.mp3')806 n = len(stems)807 labels = "".join(f"[{i}:a]" for i in range(n))808 subprocess.run([FFMPEG_PATH, "-y"] + [i for s in stems for i in ("-i", s)] + 809 ["-filter_complex", f"{labels}amix=inputs={n}:normalize=0", "-c:a", "libmp3lame", "-qscale:a", "2", inst_path], 810 check=True, capture_output=True, text=True)811 # We don't increment wav_count here since it's an extra file812 813 # Add original audio to the output814 original_mp3 = os.path.join(mp3_dir, 'original.mp3')815 subprocess.run([816 FFMPEG_PATH, "-y", "-i", input_path,817 "-codec:a", "libmp3lame", "-qscale:a", "2",818 original_mp3819 ], check=True, capture_output=True, text=True)820 821 return mp3_dir822 823@app.post("/upload")824async def process_upload(825 file: UploadFile = File(...), 826 tos_agreed: bool = Form(...),827 stems: str = Form("vocals,drums,bass,other,instrumental"),828 recaptcha_token: str = Form(None),829 authorization: str = Header(None),830 background_tasks: BackgroundTasks = None831):832 if not tos_agreed:833 raise HTTPException(status_code=400, detail="You must agree to the Terms of Service.")834 835 # Verify reCAPTCHA836 if recaptcha_token:837 if not verify_recaptcha(recaptcha_token, 'upload'):838 raise HTTPException(status_code=403, detail="Bot detection failed. Please try again.")839 840 # Verify Firebase token and get UID841 uid = verify_firebase_token(authorization) if authorization else None842 843 # Enforce R2 storage limit only if user is authenticated and R2 is enabled844 if R2_ENABLED and uid:845 check_storage_limit()846 847 # Generate unique song ID848 song_id = str(uuid.uuid4())849 850 tmpdir = tempfile.mkdtemp()851 try:852 input_path = os.path.join(tmpdir, file.filename)853 audio_path = os.path.join(tmpdir, "audio.mp3")854 855 with open(input_path, "wb") as f: 856 f.write(file.file.read())857 858 # Convert to standard format859 subprocess.run([FFMPEG_PATH, "-y", "-i", input_path, "-ac", "2", "-ar", "44100", audio_path], 860 check=True, capture_output=True, text=True)861 862 # Parse stems list863 requested_stems = [s.strip().lower() for s in stems.split(',') if s.strip()]864 865 # Process with Demucs866 extracted_dir = run_demucs_and_extract(audio_path, tmpdir, requested_stems)867 868 # If R2 is enabled and user is authenticated, upload to R2869 if R2_ENABLED and uid:870 user = get_authenticated_user(authorization)871 user_prefix = get_user_storage_prefixes(user)[0] # Get the only prefix we now support872 873 logger.info(f"Uploading stems to R2 for user {uid}, song {song_id} to prefix {user_prefix}")874 875 # Upload each stem to R2 with metadata876 uploaded_files = []877 for root, dirs, files in os.walk(extracted_dir):878 for filename in files:879 if filename.endswith('.mp3'):880 file_path = os.path.join(root, filename)881 # Construct key using the prefix: uploads/{dict_str}/{song_id}/{filename}882 s3_key = f"{user_prefix}{song_id}/{filename}"883 884 # Store original filename as metadata on all files for robustness885 metadata = {'original-filename': file.filename}886 887 with open(file_path, 'rb') as f:888 s3_client.upload_fileobj(889 f,890 R2_BUCKET_NAME,891 s3_key,892 ExtraArgs={893 'ContentType': 'audio/mpeg',894 'Metadata': metadata895 }896 )897 uploaded_files.append(filename)898 logger.info(f"Uploaded {filename} to R2")899 900 # Clean up temp directory901 if background_tasks:902 background_tasks.add_task(shutil.rmtree, tmpdir, True)903 904 return {905 "song_id": song_id,906 "uid": uid,907 "files": uploaded_files,908 "title": file.filename,909 "message": "Upload successful. Files stored in R2."910 }911 912 else:913 # Fallback to local ZIP storage (backward compatibility)914 logger.info(f"Using local storage for song {song_id}")915 final_zip_path = os.path.join(tmpdir, "extracted_stems.zip")916 with zipfile.ZipFile(final_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:917 for root, dirs, files in os.walk(extracted_dir):918 for file in files:919 file_path = os.path.join(root, file)920 arcname = os.path.relpath(file_path, extracted_dir)921 zipf.write(file_path, arcname)922 923 digest = hashlib.sha256(open(final_zip_path, 'rb').read()).hexdigest()924 cache_path = os.path.join(CACHE_DIR, f"{digest}.zip")925 shutil.copyfile(final_zip_path, cache_path)926 927 if background_tasks: 928 background_tasks.add_task(shutil.rmtree, tmpdir, True)929 930 return FileResponse(931 cache_path, 932 filename="extracted_stems.zip", 933 media_type="application/zip", 934 headers={"X-Cache-Key": digest}935 )936 937 except HTTPException as he:938 # Keep tmpdir for debugging instead of clearing it on error939 logger.error(f"HTTPException in process_upload (tmpdir: {tmpdir}): {he.detail}")940 raise he941 except Exception as e:942 # Keep tmpdir for debugging instead of clearing it on error943 error_details = traceback.format_exc()944 logger.error(f"Upload processing error (tmpdir: {tmpdir}):\n{error_details}")945 raise HTTPException(status_code=500, detail=str(e))946 947@app.post("/upload/extracted")948def process_upload_extracted(file: UploadFile = File(...), tos_agreed: bool = Form(...)):949 if not tos_agreed:950 raise HTTPException(status_code=400, detail="You must agree to the Terms of Service to upload content.")951 952 tmpdir = tempfile.mkdtemp()953 try:954 input_path = os.path.join(tmpdir, file.filename)955 audio_path = os.path.join(tmpdir, "audio.mp3")956 with open(input_path, "wb") as f: f.write(file.file.read())957 subprocess.run([FFMPEG_PATH, "-y", "-i", input_path, "-ac", "2", "-ar", "44100", audio_path], check=True)958 extracted_dir = run_demucs_and_extract(audio_path, tmpdir)959 960 files = []961 for f in os.listdir(extracted_dir):962 files.append({"filename": f, "path": os.path.join(extracted_dir, f)})963 964 return {"message": "Success", "extracted_files": files, "extracted_directory": extracted_dir}965 except Exception as e:966 # Keep tmpdir for debugging instead of clearing it on error967 error_details = traceback.format_exc()968 logger.error(f"Error in process_upload_extracted (tmpdir: {tmpdir}):\n{error_details}")969 raise HTTPException(status_code=500, detail=str(e))970 971@app.get('/cache/{key}')972def get_cache(key: str):973 path = os.path.join(CACHE_DIR, f"{key}.zip")974 if not os.path.exists(path): raise HTTPException(status_code=404, detail="Not found")975 return FileResponse(path, filename="extracted_stems.zip", media_type="application/zip")976 977@app.get('/stems/{key}/{filename:path}')978def get_stem(key: str, filename: str):979 zip_path = os.path.join(CACHE_DIR, f"{key}.zip")980 if not os.path.exists(zip_path):981 raise HTTPException(status_code=404, detail="Cache not found")982 983 try:984 with zipfile.ZipFile(zip_path, 'r') as zf:985 target = filename986 if target not in zf.namelist():987 found = None988 for name in zf.namelist():989 if os.path.basename(name) == filename:990 found = name991 break992 if not found:993 raise HTTPException(status_code=404, detail="Stem not found in archive")994 target = found995 996 data = zf.read(target)997 return Response(content=data, media_type="audio/mpeg", headers={"Content-Disposition": f"attachment; filename={filename}"})998 except Exception as e:999 logger.error(f"Error extracting stem: {e}")1000 raise HTTPException(status_code=500, detail="Failed to extract stem from archive")1001 1002# --- R2 Presigned URL Endpoints ---1003class PresignedUrlRequest(BaseModel):1004 song_id: str1005 filename: str1006 1007@app.post("/presigned-url")1008async def get_presigned_url(1009 request: PresignedUrlRequest,1010 authorization: str = Header(...)1011):1012 """1013 Generate a presigned URL for accessing a file in R2.1014 Requires Firebase authentication.1015 """1016 if not R2_ENABLED:1017 raise HTTPException(status_code=503, detail="R2 storage not configured")1018 1019 # Verify token and get user1020 user = get_authenticated_user(authorization)1021 uid = user['user_id']1022 1023 # candidate prefixes1024 prefixes = get_user_storage_prefixes(user)1025 1026 found_key = None1027 for prefix in prefixes:1028 s3_key = f"{prefix}{request.song_id}/{request.filename}"1029 try:1030 # Check if file exists1031 s3_client.head_object(Bucket=R2_BUCKET_NAME, Key=s3_key)1032 found_key = s3_key1033 break1034 except:1035 continue1036 1037 if not found_key:1038 raise HTTPException(status_code=404, detail="File not found")1039 1040 try:1041 presigned_url = s3_client.generate_presigned_url(1042 'get_object',1043 Params={1044 'Bucket': R2_BUCKET_NAME,1045 'Key': found_key,1046 'ResponseContentDisposition': f'attachment; filename="{request.filename}"'1047 },1048 ExpiresIn=3600 # 1 hour1049 )1050 1051 return {1052 "url": presigned_url,1053 "expires_in": 3600, # 1 hour1054 "filename": request.filename1055 }1056 except Exception as e:1057 logger.error(f"Failed to generate presigned URL: {e}")1058 raise HTTPException(status_code=500, detail="Failed to generate presigned URL")1059 1060 1061@app.get("/list-stems/{song_id}")1062async def list_stems(1063 song_id: str,1064 authorization: str = Header(...)1065):1066 """1067 List all stem files for a given song ID.1068 Requires Firebase authentication.1069 """1070 if not R2_ENABLED:1071 raise HTTPException(status_code=503, detail="R2 storage not configured")1072 1073 # Verify token and get user1074 user = get_authenticated_user(authorization)1075 1076 # List objects in R2 with both potential prefixes1077 prefixes = get_user_storage_prefixes(user)1078 1079 files = []1080 found_song = False1081 1082 try:1083 for user_prefix in prefixes:1084 prefix = f"{user_prefix}{song_id}/"1085 response = s3_client.list_objects_v2(1086 Bucket=R2_BUCKET_NAME,1087 Prefix=prefix1088 )1089 1090 if 'Contents' in response:1091 found_song = True1092 for obj in response['Contents']:1093 # Extract filename from key1094 filename = obj['Key'].split('/')[-1]1095 if filename: # Skip directory markers1096 files.append({1097 "filename": filename,1098 "size": obj['Size'],1099 "last_modified": obj['LastModified'].isoformat()1100 })1101 # If we found files in any prefix, we stop (avoiding duplicates if it exists in both, which shouldn't happen)1102 if files:1103 break1104 1105 if not found_song and not files:1106 raise HTTPException(status_code=404, detail="Song not found")1107 1108 return {1109 "song_id": song_id,1110 "files": files,1111 "count": len(files)1112 }1113 except Exception as e:1114 logger.error(f"Failed to list stems: {e}")1115 raise HTTPException(status_code=500, detail="Failed to list stems")1116 1117@app.get("/songs/{song_id}/zip")1118async def get_song_zip(1119 song_id: str,1120 background_tasks: BackgroundTasks,1121 authorization: str = Header(...)1122):1123 """1124 Download all stems for a song as a single ZIP archive.1125 Stems are fetched from R2 and zipped on the fly.1126 """1127 if not R2_ENABLED:1128 raise HTTPException(status_code=503, detail="R2 storage not configured")1129 1130 # Verify auth1131 user = get_authenticated_user(authorization)1132 1133 # 1. Find the stems1134 prefixes = get_user_storage_prefixes(user)1135 found_keys = []1136 for user_prefix in prefixes:1137 prefix = f"{user_prefix}{song_id}/"1138 try:1139 response = s3_client.list_objects_v2(Bucket=R2_BUCKET_NAME, Prefix=prefix)1140 if 'Contents' in response:1141 for obj in response['Contents']:1142 if obj['Key'].endswith('.mp3'):1143 found_keys.append(obj['Key'])1144 if found_keys:1145 break1146 except Exception as e:1147 logger.error(f"Error listing objects for ZIP: {e}")1148 continue1149 1150 if not found_keys:1151 raise HTTPException(status_code=404, detail="No stems found for this song")1152 1153 # 2. Create ZIP1154 temp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")1155 try:1156 with zipfile.ZipFile(temp_zip.name, 'w', zipfile.ZIP_DEFLATED) as zf:1157 for key in found_keys:1158 filename = os.path.basename(key)1159 try:1160 obj = s3_client.get_object(Bucket=R2_BUCKET_NAME, Key=key)1161 zf.writestr(filename, obj['Body'].read())1162 except Exception as e:1163 logger.error(f"Failed to add {key} to ZIP: {e}")1164 continue1165 1166 # Add cleanup task1167 background_tasks.add_task(os.remove, temp_zip.name)1168 1169 return FileResponse(1170 temp_zip.name, 1171 filename=f"stems_{song_id[:8]}.zip", 1172 media_type="application/zip"1173 )1174 except Exception as e:1175 if os.path.exists(temp_zip.name):1176 os.remove(temp_zip.name)1177 logger.error(f"Failed to create ZIP archive: {e}")1178 raise HTTPException(status_code=500, detail="Failed to create ZIP archive")1179 1180 1181@app.get("/my-songs")1182async def list_my_songs(1183 authorization: str = Header(...)1184):1185 """1186 List all songs for the authenticated user.1187 Returns song_id and metadata for each song.1188 """1189 if not R2_ENABLED:1190 raise HTTPException(status_code=503, detail="R2 storage not configured")1191 1192 # Verify token and get UID1193 user = get_authenticated_user(authorization)1194 uid = user['user_id']1195 1196 # List candidate user prefixes1197 user_prefixes = get_user_storage_prefixes(user)1198 1199 songs = []1200 