CoolFace
Apppublic

nifty-coder/stemsplit-backend

sourceHugging Faceupdated 8mo agoView on Hugging Face
0likes
main.py1370 linesDownload Raw Back to root
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    

Showing the first 1,200 of 1370 lines. Download the file for the rest.