- 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
81 lines
2.6 KiB
Python
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",
|
|
},
|
|
)
|