-
Notifications
You must be signed in to change notification settings - Fork 0
feat(audio): add rename-title endpoint and inline edit UI #24
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| """rename_audio_entry — extracted from service.py to stay under the 300-line limit.""" | ||
|
|
||
| import logging | ||
| from uuid import UUID | ||
|
|
||
| from app.core.config import settings | ||
| from app.db.supabase import get_supabase_client | ||
| from app.features.audio.schemas import AudioEntryOut | ||
| from app.features.audio.service import ( | ||
| _SELECT_COLS, | ||
| _SIGNED_URL_TTL, | ||
| _row_to_entry, | ||
| ) | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| async def rename_audio_entry( | ||
| entry_id: UUID, user_id: str, title: str | ||
| ) -> AudioEntryOut | None: | ||
| """Rename an audio entry's title. | ||
|
|
||
| Returns the updated AudioEntryOut, or None if the entry was not found. | ||
| Raises RuntimeError on DB or Storage failure. | ||
| """ | ||
| client = await get_supabase_client() | ||
|
|
||
| # UPDATE — postgrest-py's FilterRequestBuilder has no .select() after .eq(), | ||
| # so we do the update and re-fetch in a separate query. | ||
| try: | ||
| await ( | ||
| client.table("audio_entries") | ||
| .update({"title": title}) | ||
| .eq("id", str(entry_id)) | ||
| .eq("user_id", user_id) | ||
| .execute() | ||
| ) | ||
| except Exception: | ||
| logger.exception("[rename_audio_entry] UPDATE failed id=%s", entry_id) | ||
| raise RuntimeError("Failed to rename audio entry.") from None | ||
|
|
||
| # Re-fetch the updated row (also confirms the entry belongs to this user). | ||
| try: | ||
| fetch = ( | ||
| await client.table("audio_entries") | ||
| .select(_SELECT_COLS) | ||
| .eq("id", str(entry_id)) | ||
| .eq("user_id", user_id) | ||
| .execute() | ||
| ) | ||
| except Exception: | ||
| logger.exception( | ||
| "[rename_audio_entry] SELECT after UPDATE failed id=%s", entry_id | ||
| ) | ||
| raise RuntimeError("Failed to fetch renamed audio entry.") from None | ||
|
|
||
| if not fetch.data: | ||
| return None | ||
|
|
||
| row = fetch.data[0] | ||
| filename: str = row["filename"] | ||
| safe_log = filename.split("/")[-1] if "/" in filename else filename | ||
|
|
||
| try: | ||
| result = await client.storage.from_(settings.audio_bucket).create_signed_url( | ||
| filename, _SIGNED_URL_TTL | ||
| ) | ||
| signed_url: str = result["signedURL"] | ||
| except Exception: | ||
| logger.exception("[rename_audio_entry] signed URL failed uuid=%s", safe_log) | ||
| raise RuntimeError("Title updated but failed to generate signed URL.") from None | ||
|
|
||
| logger.info("[rename_audio_entry] renamed uuid=%s", safe_log) | ||
| return _row_to_entry(row, signed_url) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,16 @@ | |
| generate_tts(text, user_id) -> tuple[bytes, str] | ||
| Call OpenRouter TTS endpoint, return (mp3_bytes, storage_filename). | ||
|
|
||
| store_temp_audio(audio_bytes) -> str | ||
| Write mp3 bytes to a system temp file and return a token (UUID). | ||
| TTL: 3600 s. Survives uvicorn --reload (file system is persistent). | ||
|
|
||
| retrieve_temp_audio(token) -> bytes | None | ||
| Return bytes for the given token, or None if missing / expired. | ||
|
|
||
| delete_temp_audio(token) -> None | ||
| Remove an entry from the temp store (called after successful upload). | ||
|
|
||
| upload_audio(audio_bytes, filename) -> str | ||
| Upload mp3 bytes to Supabase Storage and return a signed URL (1 week). | ||
|
|
||
|
|
@@ -23,13 +33,15 @@ | |
|
|
||
| import asyncio | ||
| import logging | ||
| import tempfile | ||
| import time | ||
| from pathlib import Path | ||
| from uuid import UUID, uuid4 | ||
| from typing import Any | ||
|
|
||
| import httpx | ||
|
|
||
| from app.core.config import settings | ||
| from app.core.privacy import mask_pii | ||
| from app.db.supabase import get_supabase_client | ||
| from app.features.audio.schemas import AudioEntryOut | ||
|
|
||
|
|
@@ -41,6 +53,45 @@ | |
| # Signed URL expiry: 7 days in seconds. | ||
| _SIGNED_URL_TTL = 7 * 24 * 60 * 60 # 604 800 s | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Temp audio store — bytes written to the system temp directory. | ||
| # Survives uvicorn --reload (file system is persistent across restarts). | ||
| # TTL enforced at retrieval time via file mtime. | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| _TEMP_DIR = Path(tempfile.gettempdir()) | ||
| _TEMP_PREFIX = "axon_audio_" | ||
| _TEMP_TTL = 3600 # 1 hour | ||
|
|
||
|
|
||
| async def store_temp_audio(audio_bytes: bytes) -> str: | ||
| """Write mp3 bytes to a temp file and return a UUID token.""" | ||
| token = str(uuid4()) | ||
| path = _TEMP_DIR / f"{_TEMP_PREFIX}{token}.mp3" | ||
| await asyncio.to_thread(path.write_bytes, audio_bytes) | ||
| return token | ||
|
|
||
|
|
||
| async def retrieve_temp_audio(token: str) -> bytes | None: | ||
| """Return mp3 bytes for the given token, or None if missing / expired.""" | ||
| path = _TEMP_DIR / f"{_TEMP_PREFIX}{token}.mp3" | ||
|
|
||
| def _read() -> bytes | None: | ||
| if not path.exists(): | ||
| return None | ||
| if time.time() - path.stat().st_mtime > _TEMP_TTL: | ||
| path.unlink(missing_ok=True) | ||
| return None | ||
| return path.read_bytes() | ||
|
|
||
| return await asyncio.to_thread(_read) | ||
|
|
||
|
|
||
| async def delete_temp_audio(token: str) -> None: | ||
| """Delete the temp file after a successful Storage upload.""" | ||
| path = _TEMP_DIR / f"{_TEMP_PREFIX}{token}.mp3" | ||
| await asyncio.to_thread(lambda: path.unlink(missing_ok=True)) | ||
|
|
||
|
|
||
| async def generate_tts(text: str, user_id: str) -> tuple[bytes, str]: | ||
| """Call OpenRouter TTS endpoint and return (mp3_bytes, storage_filename). | ||
|
|
@@ -66,13 +117,10 @@ async def generate_tts(text: str, user_id: str) -> tuple[bytes, str]: | |
| if not text.strip(): | ||
| raise RuntimeError("Cannot generate TTS for empty text.") | ||
|
|
||
| # Mask PII before forwarding text to the external TTS provider. | ||
| masked_text = await mask_pii(text) | ||
|
|
||
| # OpenRouter /audio/speech endpoint — returns raw MP3 bytes (not JSON). | ||
| payload = { | ||
| "model": settings.tts_model, | ||
| "input": masked_text, | ||
| "input": text, | ||
| "voice": settings.tts_voice, | ||
| "response_format": "mp3", | ||
|
Comment on lines
121
to
125
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reintroduce PII masking before sending text to OpenRouter. Line 119 sends raw user text to an external provider. That creates a privacy/compliance gap and regresses existing protections. Proposed fix+from app.core.privacy import mask_pii
...
- payload = {
+ masked_text = mask_pii(text)
+ if not masked_text.strip():
+ raise RuntimeError("Cannot generate TTS for empty text after masking.")
+
+ payload = {
"model": settings.tts_model,
- "input": text,
+ "input": masked_text,
"voice": settings.tts_voice,
"response_format": "mp3",
}As per coding guidelines: "NEVER send raw user input to OpenAI embeddings or OpenRouter without first calling 🤖 Prompt for AI Agents |
||
| } | ||
|
|
@@ -345,3 +393,6 @@ async def delete_audio_entry(entry_id: UUID, user_id: str) -> bool: | |
|
|
||
| logger.info("[delete_audio_entry] deleted uuid=%s", safe_log_name) | ||
| return True | ||
|
|
||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.