S.H.O.N.A.R._Desktop_Companion/backend/shonar/api/v1/search_exports.py
avi 76c867fca4 Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar
- shared/ = portable Android-origin sources vendored from deferred/desktop-server
  (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only)
- backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the
  old checkout, PYTHONPATH pins THIS backend's code over any editable install
- repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh
  watches shared/ + backend/
- Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010,
  self-migrates, /healthz ok
2026-09-14 17:14:54 -05:00

81 lines
2.6 KiB
Python

"""Search + exports endpoints (M9).
Ownership is enforced everywhere: a search only ever sees the caller's own
non-deleted recordings, and an export 404s on anything the caller doesn't
own (no existence leak).
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from shonar.api.deps import CurrentUser, SessionDep
from shonar.api.schemas_search import SearchHitOut, SearchOut
from shonar.db.models import Recording
from shonar.services import exports as ex
from shonar.services import search as sr
router = APIRouter(tags=["search", "exports"])
# --- search -------------------------------------------------------------------
@router.get("/search", response_model=SearchOut)
async def search(
user: CurrentUser,
session: SessionDep,
q: str = Query(..., min_length=1, max_length=200, description="Search text"),
scope: str = Query(
default="all",
pattern="^(all|title|notes|transcript|summary|tag)$",
description="Restrict the search to one field (default: all)",
),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
hits, total = await sr.search_recordings(
session, user.id, q, scope=scope, limit=limit, offset=offset
)
items = [
SearchHitOut(
id=h.recording.id,
title=h.recording.title,
field=h.field,
snippet=h.snippet,
)
for h in hits
]
return SearchOut(query=q, scope=scope, items=items, total=total, limit=limit, offset=offset)
# --- exports ------------------------------------------------------------------
@router.get("/recordings/{recording_id}/export")
async def export_recording(
recording_id: uuid.UUID,
user: CurrentUser,
session: SessionDep,
fmt: str = Query(default="zip", pattern="^(audio|txt|md|zip)$"),
):
"""Return one export artifact inline (audio / txt / md / zip bundle)."""
rec = await session.get(Recording, recording_id)
if rec is None or rec.user_id != user.id or rec.deleted_at is not None:
raise HTTPException(404, "Recording not found")
try:
result = await ex.build_export(session, rec, fmt)
except ex.ExportError as e:
raise HTTPException(e.status_code, e.message) from None
await ex.record_export(session, user.id, rec, fmt, result)
return Response(
content=result.data,
media_type=result.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{result.filename}"',
"Cache-Control": "private, no-store",
},
)