Update main.py
Browse files
main.py
CHANGED
|
@@ -891,7 +891,7 @@ def submit_quiz_attempt(quiz_id):
|
|
| 891 |
|
| 892 |
|
| 893 |
# Modified speak_notes endpoint with ElevenLabs Studio API and chunking
|
| 894 |
-
@app.route('/api/tutor/notes/<uuid:notes_id>/speak', methods=['POST'])
|
| 895 |
def speak_notes(notes_id):
|
| 896 |
user, error = verify_token(request.headers.get('Authorization'))
|
| 897 |
if error:
|
|
@@ -901,81 +901,103 @@ def speak_notes(notes_id):
|
|
| 901 |
return jsonify({'error': 'Backend service unavailable'}), 503
|
| 902 |
|
| 903 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
# Check user status and credits
|
| 905 |
-
profile_res = supabase.table('profiles')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
if profile_res.data['suspended']:
|
| 907 |
return jsonify({'error': 'Account suspended'}), 403
|
| 908 |
if profile_res.data['credits'] < 5:
|
| 909 |
return jsonify({'error': 'Insufficient credits (Need 5)'}), 402
|
| 910 |
|
| 911 |
-
# Fetch notes content
|
| 912 |
-
notes_res = supabase.table('notes').select('content, tts_audio_url').eq('id', notes_id).single().execute()
|
| 913 |
-
if not notes_res.data:
|
| 914 |
-
return jsonify({'error': 'Notes not found'}), 404
|
| 915 |
-
|
| 916 |
# Return existing audio if available
|
| 917 |
-
if
|
| 918 |
return jsonify({
|
| 919 |
'success': True,
|
| 920 |
-
'audio_url':
|
| 921 |
'message': 'Using existing audio file'
|
| 922 |
})
|
| 923 |
|
| 924 |
-
notes_content =
|
| 925 |
if not notes_content:
|
| 926 |
return jsonify({'error': 'Notes content is empty'}), 400
|
| 927 |
|
| 928 |
# --- Generate TTS Audio with chunking ---
|
| 929 |
-
|
| 930 |
-
logging.info(f"Generating TTS for user {user.id}, notes: {notes_id}")
|
| 931 |
-
|
| 932 |
-
# Chunk text if too long (ElevenLabs limit is ~2500 chars for best quality)
|
| 933 |
-
CHUNK_SIZE = 2000 # Conservative chunk size
|
| 934 |
chunks = [notes_content[i:i+CHUNK_SIZE] for i in range(0, len(notes_content), CHUNK_SIZE)]
|
| 935 |
|
| 936 |
audio_bytes = b""
|
| 937 |
for chunk in chunks:
|
| 938 |
try:
|
| 939 |
-
# Using ElevenLabs Studio API with streaming
|
| 940 |
chunk_audio = elevenlabs_client.generate(
|
| 941 |
text=chunk,
|
| 942 |
-
voice="Rachel",
|
| 943 |
model="eleven_multilingual_v2",
|
| 944 |
stream=True
|
| 945 |
)
|
| 946 |
audio_bytes += b"".join(chunk_audio)
|
| 947 |
except Exception as e:
|
| 948 |
logging.error(f"Error generating chunk: {str(e)}")
|
| 949 |
-
raise RuntimeError(f"
|
| 950 |
|
| 951 |
if not audio_bytes:
|
| 952 |
raise RuntimeError("Generated empty audio file")
|
| 953 |
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
# --- Save to Supabase Storage ---
|
| 957 |
bucket_name = 'notes-audio'
|
| 958 |
-
|
| 959 |
|
| 960 |
-
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as tmp_file:
|
| 961 |
-
tmp_file.write(audio_bytes)
|
| 962 |
-
tmp_path = tmp_file.name
|
| 963 |
-
|
| 964 |
try:
|
| 965 |
-
# Upload with
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 971 |
)
|
| 972 |
-
|
| 973 |
-
# Update database
|
| 974 |
-
supabase.table('notes').update({'tts_audio_url': audio_url}).eq('id', notes_id).execute()
|
| 975 |
|
| 976 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 977 |
new_credits = profile_res.data['credits'] - 5
|
| 978 |
-
supabase.table('profiles')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
|
| 980 |
return jsonify({
|
| 981 |
'success': True,
|
|
@@ -983,11 +1005,13 @@ def speak_notes(notes_id):
|
|
| 983 |
'remaining_credits': new_credits
|
| 984 |
})
|
| 985 |
|
| 986 |
-
|
| 987 |
-
|
|
|
|
|
|
|
| 988 |
|
| 989 |
except Exception as e:
|
| 990 |
-
logging.error(f"
|
| 991 |
return jsonify({'error': str(e)}), 500
|
| 992 |
|
| 993 |
# New endpoint to view existing audio URL
|
|
|
|
| 891 |
|
| 892 |
|
| 893 |
# Modified speak_notes endpoint with ElevenLabs Studio API and chunking
|
| 894 |
+
@app.route('/api/tutor/notes/<uuid:notes_id>/speak', methods=['POST'])
|
| 895 |
def speak_notes(notes_id):
|
| 896 |
user, error = verify_token(request.headers.get('Authorization'))
|
| 897 |
if error:
|
|
|
|
| 901 |
return jsonify({'error': 'Backend service unavailable'}), 503
|
| 902 |
|
| 903 |
try:
|
| 904 |
+
# Verify note ownership first
|
| 905 |
+
note_res = supabase.table('notes') \
|
| 906 |
+
.select('user_id, content, tts_audio_url') \
|
| 907 |
+
.eq('id', notes_id) \
|
| 908 |
+
.eq('user_id', user.id) \ # Critical RLS check
|
| 909 |
+
.maybe_single() \
|
| 910 |
+
.execute()
|
| 911 |
+
|
| 912 |
+
if not note_res.data:
|
| 913 |
+
return jsonify({'error': 'Note not found or unauthorized'}), 404
|
| 914 |
+
|
| 915 |
# Check user status and credits
|
| 916 |
+
profile_res = supabase.table('profiles') \
|
| 917 |
+
.select('credits, suspended') \
|
| 918 |
+
.eq('id', user.id) \
|
| 919 |
+
.single().execute()
|
| 920 |
+
|
| 921 |
if profile_res.data['suspended']:
|
| 922 |
return jsonify({'error': 'Account suspended'}), 403
|
| 923 |
if profile_res.data['credits'] < 5:
|
| 924 |
return jsonify({'error': 'Insufficient credits (Need 5)'}), 402
|
| 925 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
# Return existing audio if available
|
| 927 |
+
if note_res.data.get('tts_audio_url'):
|
| 928 |
return jsonify({
|
| 929 |
'success': True,
|
| 930 |
+
'audio_url': note_res.data['tts_audio_url'],
|
| 931 |
'message': 'Using existing audio file'
|
| 932 |
})
|
| 933 |
|
| 934 |
+
notes_content = note_res.data['content']
|
| 935 |
if not notes_content:
|
| 936 |
return jsonify({'error': 'Notes content is empty'}), 400
|
| 937 |
|
| 938 |
# --- Generate TTS Audio with chunking ---
|
| 939 |
+
CHUNK_SIZE = 2000
|
|
|
|
|
|
|
|
|
|
|
|
|
| 940 |
chunks = [notes_content[i:i+CHUNK_SIZE] for i in range(0, len(notes_content), CHUNK_SIZE)]
|
| 941 |
|
| 942 |
audio_bytes = b""
|
| 943 |
for chunk in chunks:
|
| 944 |
try:
|
|
|
|
| 945 |
chunk_audio = elevenlabs_client.generate(
|
| 946 |
text=chunk,
|
| 947 |
+
voice="Rachel",
|
| 948 |
model="eleven_multilingual_v2",
|
| 949 |
stream=True
|
| 950 |
)
|
| 951 |
audio_bytes += b"".join(chunk_audio)
|
| 952 |
except Exception as e:
|
| 953 |
logging.error(f"Error generating chunk: {str(e)}")
|
| 954 |
+
raise RuntimeError(f"Audio generation failed: {str(e)}")
|
| 955 |
|
| 956 |
if not audio_bytes:
|
| 957 |
raise RuntimeError("Generated empty audio file")
|
| 958 |
|
| 959 |
+
# --- Save to Supabase Storage with RLS compliance ---
|
|
|
|
|
|
|
| 960 |
bucket_name = 'notes-audio'
|
| 961 |
+
file_path = f'{user.id}/{notes_id}.mp3'
|
| 962 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 963 |
try:
|
| 964 |
+
# Upload with owner metadata for RLS
|
| 965 |
+
upload_res = supabase.storage.from_(bucket_name).upload(
|
| 966 |
+
path=file_path,
|
| 967 |
+
file=audio_bytes,
|
| 968 |
+
file_options={
|
| 969 |
+
'content-type': 'audio/mpeg',
|
| 970 |
+
'cache-control': '3600',
|
| 971 |
+
'upsert': 'true',
|
| 972 |
+
'owner': user.id # Essential for RLS policies
|
| 973 |
+
}
|
| 974 |
)
|
|
|
|
|
|
|
|
|
|
| 975 |
|
| 976 |
+
if upload_res.error:
|
| 977 |
+
raise ConnectionError(upload_res.error.message)
|
| 978 |
+
|
| 979 |
+
# Get public URL
|
| 980 |
+
audio_url = supabase.storage.from_(bucket_name).get_public_url(file_path)
|
| 981 |
+
|
| 982 |
+
# Update notes table with verification
|
| 983 |
+
update_res = supabase.table('notes') \
|
| 984 |
+
.update({'tts_audio_url': audio_url}) \
|
| 985 |
+
.eq('id', notes_id) \
|
| 986 |
+
.eq('user_id', user.id) \ # Double-check ownership
|
| 987 |
+
.execute()
|
| 988 |
+
|
| 989 |
+
if update_res.error:
|
| 990 |
+
raise ConnectionError(update_res.error.message)
|
| 991 |
+
|
| 992 |
+
# Deduct credits with verification
|
| 993 |
new_credits = profile_res.data['credits'] - 5
|
| 994 |
+
credit_res = supabase.table('profiles') \
|
| 995 |
+
.update({'credits': new_credits}) \
|
| 996 |
+
.eq('id', user.id) \
|
| 997 |
+
.execute()
|
| 998 |
+
|
| 999 |
+
if credit_res.error:
|
| 1000 |
+
raise ConnectionError(credit_res.error.message)
|
| 1001 |
|
| 1002 |
return jsonify({
|
| 1003 |
'success': True,
|
|
|
|
| 1005 |
'remaining_credits': new_credits
|
| 1006 |
})
|
| 1007 |
|
| 1008 |
+
except Exception as upload_error:
|
| 1009 |
+
# Clean up failed upload
|
| 1010 |
+
supabase.storage.from_(bucket_name).remove([file_path])
|
| 1011 |
+
raise upload_error
|
| 1012 |
|
| 1013 |
except Exception as e:
|
| 1014 |
+
logging.error(f"Speak error: {traceback.format_exc()}")
|
| 1015 |
return jsonify({'error': str(e)}), 500
|
| 1016 |
|
| 1017 |
# New endpoint to view existing audio URL
|