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
This commit is contained in:
commit
76c867fca4
136 changed files with 21099 additions and 0 deletions
25
backend/shonar/api/v1/__init__.py
Normal file
25
backend/shonar/api/v1/__init__.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""API v1 router aggregation."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from shonar.api.v1 import (
|
||||
auth,
|
||||
health,
|
||||
models,
|
||||
provider_info,
|
||||
recordings,
|
||||
search_exports,
|
||||
users,
|
||||
)
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(health.router)
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(recordings.router)
|
||||
api_router.include_router(models.router)
|
||||
api_router.include_router(provider_info.router)
|
||||
api_router.include_router(search_exports.router)
|
||||
|
||||
# Included as later milestones land:
|
||||
# - tags (M9 follow-on)
|
||||
91
backend/shonar/api/v1/auth.py
Normal file
91
backend/shonar/api/v1/auth.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Auth endpoints (rate-limited)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from shonar.api.deps import CurrentUser, SessionDep
|
||||
from shonar.api.schemas_common import (
|
||||
DeleteAccountRequest,
|
||||
LoginRequest,
|
||||
LogoutRequest,
|
||||
RefreshRequest,
|
||||
RegisterRequest,
|
||||
TokenPair,
|
||||
UserOut,
|
||||
)
|
||||
from shonar.core.config import get_settings
|
||||
from shonar.core.ratelimit import auth_limit
|
||||
from shonar.core.security import create_access_token
|
||||
from shonar.services import auth as auth_service
|
||||
from shonar.services.auth import AuthError
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/auth/register", response_model=TokenPair, status_code=201)
|
||||
@auth_limit
|
||||
async def register(body: RegisterRequest, request: Request, session: SessionDep):
|
||||
settings = get_settings()
|
||||
if not settings.allow_registration:
|
||||
raise HTTPException(403, "Registration is disabled on this server.") from None
|
||||
try:
|
||||
user = await auth_service.register_user(
|
||||
session, body.email, body.password, body.display_name
|
||||
)
|
||||
except AuthError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
access, ttl = create_access_token(user.id)
|
||||
from shonar.services.auth import issue_refresh_token
|
||||
|
||||
refresh, _ = await issue_refresh_token(session, user.id, None, None)
|
||||
return TokenPair(access_token=access, expires_in=ttl, refresh_token=refresh)
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=TokenPair)
|
||||
@auth_limit
|
||||
async def login(body: LoginRequest, request: Request, session: SessionDep):
|
||||
try:
|
||||
user, refresh, device = await auth_service.login(
|
||||
session, body.email, body.password, body.device_name, body.platform
|
||||
)
|
||||
except AuthError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
access, ttl = create_access_token(user.id, device.id)
|
||||
return TokenPair(
|
||||
access_token=access, expires_in=ttl, refresh_token=refresh, device_id=device.id
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/refresh", response_model=TokenPair)
|
||||
@auth_limit
|
||||
async def refresh(body: RefreshRequest, request: Request, session: SessionDep):
|
||||
try:
|
||||
user, new_refresh, device_id = await auth_service.rotate_refresh_token(
|
||||
session, body.refresh_token
|
||||
)
|
||||
except AuthError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
access, ttl = create_access_token(user.id, device_id)
|
||||
return TokenPair(
|
||||
access_token=access, expires_in=ttl, refresh_token=new_refresh, device_id=device_id
|
||||
)
|
||||
|
||||
|
||||
@router.post("/auth/logout", status_code=204)
|
||||
async def logout(body: LogoutRequest, session: SessionDep):
|
||||
await auth_service.logout(session, body.refresh_token)
|
||||
|
||||
|
||||
@router.get("/auth/me", response_model=UserOut)
|
||||
async def me(user: CurrentUser):
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/auth/delete-account", status_code=202)
|
||||
async def delete_account(body: DeleteAccountRequest, user: CurrentUser, session: SessionDep):
|
||||
try:
|
||||
await auth_service.delete_account(session, user, body.password)
|
||||
except AuthError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
return {"detail": "Account scheduled for deletion.", "grace_days": 30}
|
||||
65
backend/shonar/api/v1/health.py
Normal file
65
backend/shonar/api/v1/health.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Health and system endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import text
|
||||
|
||||
from shonar.api.deps import SessionDep
|
||||
from shonar.core.config import get_settings
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
_STARTED = time.monotonic()
|
||||
|
||||
|
||||
@router.get("/healthz")
|
||||
async def healthz() -> dict:
|
||||
"""Liveness: process up. No auth, no dependencies."""
|
||||
return {"status": "ok", "uptime_seconds": round(time.monotonic() - _STARTED, 1)}
|
||||
|
||||
|
||||
@router.get("/readyz")
|
||||
async def readyz(session: SessionDep) -> dict:
|
||||
"""Readiness: database reachable. Config warnings surfaced for admins
|
||||
via /api/v1/system/status instead of failing readiness."""
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception:
|
||||
db_ok = False
|
||||
status_code_body = {"status": "ok" if db_ok else "degraded", "database": db_ok}
|
||||
return status_code_body
|
||||
|
||||
|
||||
@router.get("/system/status")
|
||||
async def system_status() -> dict:
|
||||
"""Public-ish status: which AI features are enabled (never any secrets).
|
||||
The app uses this to show honest AI-processing state to users."""
|
||||
settings = get_settings()
|
||||
return {
|
||||
"app": settings.app_name,
|
||||
"registration_enabled": settings.allow_registration,
|
||||
"ai": {
|
||||
"transcription_provider": settings.transcription_provider,
|
||||
"transcription_enabled": settings.transcription_provider != "none",
|
||||
"llm_provider": settings.llm_provider,
|
||||
"llm_enabled": settings.llm_provider != "none",
|
||||
# Explicit disclosure: are any external (non-local) calls made?
|
||||
"external_ai_in_use": (
|
||||
settings.transcription_provider == "whisper_http"
|
||||
and "localhost" not in settings.transcription_base_url
|
||||
and "127.0.0.1" not in settings.transcription_base_url
|
||||
)
|
||||
or (
|
||||
settings.llm_provider == "openai_compat"
|
||||
and "localhost" not in settings.llm_base_url
|
||||
and "127.0.0.1" not in settings.llm_base_url
|
||||
),
|
||||
},
|
||||
"storage_backend": settings.storage_backend,
|
||||
"audio_conversion_enabled": settings.audio_conversion_enabled,
|
||||
"config_warnings": settings.validate_production(),
|
||||
}
|
||||
132
backend/shonar/api/v1/models.py
Normal file
132
backend/shonar/api/v1/models.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Transcription model registry + global default (Stage 1).
|
||||
|
||||
- GET /models — supported models with display metadata, download and
|
||||
availability status, and which is the default.
|
||||
- GET /models/default — the current global default model name.
|
||||
- PUT /models/default — change the global default (affects future
|
||||
recordings only; saved per-recording models are never rewritten).
|
||||
- POST /models/{name}/download — fetch a model into the local cache
|
||||
(needs internet once; runs synchronously and may take minutes for
|
||||
large models).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from shonar.api.deps import CurrentUser, SessionDep
|
||||
from shonar.services.ai import ProviderConfigError
|
||||
from shonar.services.ai.model_registry import (
|
||||
SUPPORTED_TRANSCRIPTION_MODELS,
|
||||
get_global_default_model,
|
||||
is_faster_whisper_installed,
|
||||
is_model_downloaded,
|
||||
set_global_default_model,
|
||||
validate_model_name,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["models"])
|
||||
|
||||
|
||||
class TranscriptionModelOut(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
description: str
|
||||
params: str
|
||||
approx_memory: str
|
||||
relative_speed: str
|
||||
is_default: bool
|
||||
downloaded: bool
|
||||
available: bool
|
||||
|
||||
|
||||
class ModelsOut(BaseModel):
|
||||
default_model: str
|
||||
faster_whisper_installed: bool
|
||||
models: list[TranscriptionModelOut]
|
||||
|
||||
|
||||
class DefaultModelUpdate(BaseModel):
|
||||
model: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class DefaultModelOut(BaseModel):
|
||||
default_model: str
|
||||
|
||||
|
||||
async def _models_out(session: SessionDep) -> ModelsOut:
|
||||
default = await get_global_default_model(session)
|
||||
installed = is_faster_whisper_installed()
|
||||
return ModelsOut(
|
||||
default_model=default,
|
||||
faster_whisper_installed=installed,
|
||||
models=[
|
||||
TranscriptionModelOut(
|
||||
name=info.name,
|
||||
display_name=info.display_name,
|
||||
description=info.description,
|
||||
params=info.params,
|
||||
approx_memory=info.approx_memory,
|
||||
relative_speed=info.relative_speed,
|
||||
is_default=info.name == default,
|
||||
downloaded=is_model_downloaded(info.name),
|
||||
available=installed and is_model_downloaded(info.name),
|
||||
)
|
||||
for info in SUPPORTED_TRANSCRIPTION_MODELS.values()
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/models", response_model=ModelsOut)
|
||||
async def list_models(user: CurrentUser, session: SessionDep):
|
||||
return await _models_out(session)
|
||||
|
||||
|
||||
@router.get("/models/default", response_model=DefaultModelOut)
|
||||
async def get_default_model(user: CurrentUser, session: SessionDep):
|
||||
return DefaultModelOut(default_model=await get_global_default_model(session))
|
||||
|
||||
|
||||
@router.put("/models/default", response_model=DefaultModelOut)
|
||||
async def put_default_model(body: DefaultModelUpdate, user: CurrentUser, session: SessionDep):
|
||||
try:
|
||||
name = await set_global_default_model(session, body.model)
|
||||
except ProviderConfigError as e:
|
||||
raise HTTPException(422, str(e)) from None
|
||||
return DefaultModelOut(default_model=name)
|
||||
|
||||
|
||||
@router.post("/models/{name}/download", response_model=TranscriptionModelOut)
|
||||
async def download_model(name: str, user: CurrentUser, session: SessionDep):
|
||||
try:
|
||||
clean = validate_model_name(name)
|
||||
except ProviderConfigError as e:
|
||||
raise HTTPException(422, str(e)) from None
|
||||
if not is_faster_whisper_installed():
|
||||
raise HTTPException(
|
||||
501,
|
||||
"faster-whisper is not installed on this server "
|
||||
"(pip install shonar-backend[faster-whisper]).",
|
||||
)
|
||||
from shonar.services.ai.faster_whisper import _load_model
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_load_model, clean)
|
||||
except Exception as e: # noqa: BLE001 — surface download failures plainly
|
||||
raise HTTPException(500, f"Model download failed: {e}") from None
|
||||
default = await get_global_default_model(session)
|
||||
info = SUPPORTED_TRANSCRIPTION_MODELS[clean]
|
||||
return TranscriptionModelOut(
|
||||
name=info.name,
|
||||
display_name=info.display_name,
|
||||
description=info.description,
|
||||
params=info.params,
|
||||
approx_memory=info.approx_memory,
|
||||
relative_speed=info.relative_speed,
|
||||
is_default=info.name == default,
|
||||
downloaded=is_model_downloaded(clean),
|
||||
available=is_model_downloaded(clean),
|
||||
)
|
||||
38
backend/shonar/api/v1/provider_info.py
Normal file
38
backend/shonar/api/v1/provider_info.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""Public provider handshake.
|
||||
|
||||
`GET /api/v1/provider-info` lets SHONAR clients (and platform probes on
|
||||
Start9/Umbrel) identify this server as SHONAR-compatible and learn its
|
||||
capabilities BEFORE any credentials are involved. Returns static deployment
|
||||
metadata only — never user data, never configuration secrets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from shonar import __version__
|
||||
from shonar.core.config import get_settings
|
||||
|
||||
router = APIRouter(tags=["provider"])
|
||||
|
||||
|
||||
@router.get("/provider-info")
|
||||
async def provider_info() -> dict:
|
||||
"""Static, unauthenticated deployment identity for client probing."""
|
||||
settings = get_settings()
|
||||
# Capability flags reflect what this deployment can actually do right now.
|
||||
transcription = settings.transcription_provider != "none"
|
||||
llm = settings.llm_provider != "none"
|
||||
return {
|
||||
"kind": "shonar",
|
||||
"version": __version__,
|
||||
"api_version": "v1",
|
||||
"capabilities": {
|
||||
"chunked_upload": True,
|
||||
"server_transcription": transcription,
|
||||
"server_summary": llm,
|
||||
"account_deletion": True,
|
||||
},
|
||||
# Storage backend family (never a path): "local" | "s3"
|
||||
"storage_backend": settings.storage_backend,
|
||||
}
|
||||
528
backend/shonar/api/v1/recordings.py
Normal file
528
backend/shonar/api/v1/recordings.py
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
"""Upload sessions + recordings CRUD.
|
||||
|
||||
Every endpoint enforces ownership server-side; missing/foreign resources
|
||||
return 404 (no existence leaks). Storage keys are never exposed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request, Response
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from shonar.api.deps import CurrentUser, SessionDep
|
||||
from shonar.api.schemas_recordings import (
|
||||
ProcessingJobOut,
|
||||
RecordingFinalize,
|
||||
RecordingListOut,
|
||||
RecordingOut,
|
||||
RecordingUpdate,
|
||||
SummaryOut,
|
||||
SummaryUpdate,
|
||||
TranscriptOut,
|
||||
TranscriptUpdate,
|
||||
UploadSessionCreate,
|
||||
UploadSessionOut,
|
||||
UploadStatusOut,
|
||||
)
|
||||
from shonar.db.models import (
|
||||
Asset,
|
||||
AssetKind,
|
||||
ProcessingJob,
|
||||
Recording,
|
||||
RecordingTag,
|
||||
Summary,
|
||||
Tag,
|
||||
Transcript,
|
||||
utcnow,
|
||||
)
|
||||
from shonar.services import uploads as up
|
||||
|
||||
router = APIRouter(tags=["uploads", "recordings"])
|
||||
|
||||
|
||||
async def _recording_out(session, rec: Recording) -> RecordingOut: # noqa: ANN001
|
||||
original = await session.scalar(
|
||||
select(Asset).where(Asset.recording_id == rec.id, Asset.kind == AssetKind.original)
|
||||
)
|
||||
tags = await session.scalars(
|
||||
select(Tag.name)
|
||||
.join(RecordingTag, RecordingTag.tag_id == Tag.id)
|
||||
.where(RecordingTag.recording_id == rec.id)
|
||||
.order_by(Tag.name)
|
||||
)
|
||||
return RecordingOut(
|
||||
id=rec.id,
|
||||
title=rec.title,
|
||||
recorded_at=rec.recorded_at,
|
||||
duration_seconds=rec.duration_seconds,
|
||||
notes=rec.notes,
|
||||
latitude=rec.latitude,
|
||||
longitude=rec.longitude,
|
||||
processing_status=rec.processing_status.value,
|
||||
processing_error=rec.processing_error,
|
||||
transcription_model=rec.transcription_model,
|
||||
has_audio=original is not None,
|
||||
mime_type=original.mime_type if original else None,
|
||||
size_bytes=original.size_bytes if original else None,
|
||||
tags=list(tags),
|
||||
created_at=rec.created_at,
|
||||
updated_at=rec.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# --- upload sessions ---------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/uploads", response_model=UploadSessionOut, status_code=201)
|
||||
async def create_upload(body: UploadSessionCreate, user: CurrentUser, session: SessionDep):
|
||||
try:
|
||||
us = await up.create_session(
|
||||
session, user.id, body.declared_mime_type, body.declared_size_bytes,
|
||||
body.title, body.client_recording_id, body.transcription_model,
|
||||
)
|
||||
except up.UploadError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
return us
|
||||
|
||||
|
||||
@router.get("/uploads/{session_id}", response_model=UploadStatusOut)
|
||||
async def upload_status(session_id: uuid.UUID, user: CurrentUser, session: SessionDep):
|
||||
try:
|
||||
us = await up.get_owned_session(session, user.id, session_id)
|
||||
indexes = await up.received_indexes(session, user.id, session_id)
|
||||
except up.UploadError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
return UploadStatusOut(id=us.id, status=us.status.value, received_chunk_indexes=indexes)
|
||||
|
||||
|
||||
@router.put("/uploads/{session_id}/chunks/{chunk_index}", status_code=201)
|
||||
async def put_chunk(
|
||||
session_id: uuid.UUID,
|
||||
chunk_index: int,
|
||||
request: Request,
|
||||
user: CurrentUser,
|
||||
session: SessionDep,
|
||||
x_chunk_sha256: str | None = Header(default=None),
|
||||
):
|
||||
data = await request.body()
|
||||
try:
|
||||
chunk = await up.put_chunk(session, user.id, session_id, chunk_index, data, x_chunk_sha256)
|
||||
except up.UploadError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
return {"chunk_index": chunk.chunk_index, "size_bytes": chunk.size_bytes}
|
||||
|
||||
|
||||
@router.post("/uploads/{session_id}/finalize", response_model=RecordingOut, status_code=201)
|
||||
async def finalize_upload(
|
||||
session_id: uuid.UUID, body: RecordingFinalize, user: CurrentUser, session: SessionDep
|
||||
):
|
||||
# Location is stored ONLY with explicit per-user consent.
|
||||
lat = body.latitude if user.location_storage_enabled else None
|
||||
lon = body.longitude if user.location_storage_enabled else None
|
||||
acc = body.location_accuracy_m if user.location_storage_enabled else None
|
||||
try:
|
||||
_us, rec, _asset = await up.finalize(
|
||||
session, user.id, session_id,
|
||||
recorded_at=body.recorded_at, duration_seconds=body.duration_seconds,
|
||||
latitude=lat, longitude=lon, location_accuracy_m=acc, notes=body.notes,
|
||||
transcription_model=body.transcription_model,
|
||||
)
|
||||
except up.UploadError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
return await _recording_out(session, rec)
|
||||
|
||||
|
||||
@router.delete("/uploads/{session_id}", status_code=204)
|
||||
async def abort_upload(session_id: uuid.UUID, user: CurrentUser, session: SessionDep):
|
||||
try:
|
||||
await up.abort(session, user.id, session_id)
|
||||
except up.UploadError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
|
||||
|
||||
# --- recordings ----------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/recordings", response_model=RecordingListOut)
|
||||
async def list_recordings(
|
||||
user: CurrentUser,
|
||||
session: SessionDep,
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
sort: str = Query(
|
||||
default="recorded_at",
|
||||
pattern="^(recorded_at|created_at|duration_seconds|title)$",
|
||||
),
|
||||
order: str = Query(default="desc", pattern="^(asc|desc)$"),
|
||||
# M9 filters (AND-combined).
|
||||
tag: str | None = Query(default=None, max_length=80),
|
||||
status: str | None = Query(default=None, max_length=20),
|
||||
from_date: datetime | None = Query(default=None, description="recorded_at >= (UTC)"),
|
||||
to_date: datetime | None = Query(default=None, description="recorded_at <= (UTC)"),
|
||||
):
|
||||
where = [Recording.user_id == user.id, Recording.deleted_at.is_(None)]
|
||||
if tag:
|
||||
from shonar.db.models import RecordingTag as RT
|
||||
from shonar.db.models import Tag as T
|
||||
|
||||
where.append(
|
||||
Recording.id.in_(
|
||||
select(RT.recording_id)
|
||||
.join(T, T.id == RT.tag_id)
|
||||
.where(T.user_id == user.id, T.name == tag.strip().lower())
|
||||
)
|
||||
)
|
||||
if status:
|
||||
from shonar.db.models import ProcessingStatus
|
||||
|
||||
try:
|
||||
where.append(Recording.processing_status == ProcessingStatus(status))
|
||||
except ValueError:
|
||||
raise HTTPException(422, f"Unknown status: {status}") from None
|
||||
if from_date is not None:
|
||||
where.append(Recording.recorded_at >= from_date)
|
||||
if to_date is not None:
|
||||
where.append(Recording.recorded_at <= to_date)
|
||||
total = await session.scalar(select(func.count(Recording.id)).where(*where))
|
||||
col = getattr(Recording, sort)
|
||||
col = col.desc() if order == "desc" else col.asc()
|
||||
rows = await session.scalars(
|
||||
select(Recording).where(*where).order_by(col).limit(limit).offset(offset)
|
||||
)
|
||||
items = [await _recording_out(session, r) for r in rows]
|
||||
return RecordingListOut(items=items, total=total or 0, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/recordings/{recording_id}", response_model=RecordingOut)
|
||||
async def get_recording(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep):
|
||||
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")
|
||||
return await _recording_out(session, rec)
|
||||
|
||||
|
||||
@router.patch("/recordings/{recording_id}", response_model=RecordingOut)
|
||||
async def update_recording(
|
||||
recording_id: uuid.UUID, body: RecordingUpdate, user: CurrentUser, session: SessionDep
|
||||
):
|
||||
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")
|
||||
if body.title is not None:
|
||||
rec.title = body.title
|
||||
if body.notes is not None:
|
||||
rec.notes = body.notes
|
||||
if body.latitude is not None and user.location_storage_enabled:
|
||||
rec.latitude = body.latitude
|
||||
if body.longitude is not None and user.location_storage_enabled:
|
||||
rec.longitude = body.longitude
|
||||
if body.tags is not None:
|
||||
# Replace tag set. Tags are per-user, created on demand.
|
||||
names = sorted(dict.fromkeys(t.strip().lower() for t in body.tags if t.strip()))[:20]
|
||||
existing = list(
|
||||
await session.scalars(select(Tag).where(Tag.user_id == user.id, Tag.name.in_(names)))
|
||||
)
|
||||
by_name = {t.name: t for t in existing}
|
||||
for name in names:
|
||||
if name not in by_name:
|
||||
t = Tag(user_id=user.id, name=name)
|
||||
session.add(t)
|
||||
await session.flush()
|
||||
by_name[name] = t
|
||||
# Deterministic replace: drop all links for this recording, re-add.
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
await session.execute(
|
||||
sql_delete(RecordingTag).where(RecordingTag.recording_id == rec.id)
|
||||
)
|
||||
for name in names:
|
||||
session.add(RecordingTag(recording_id=rec.id, tag_id=by_name[name].id))
|
||||
await session.flush()
|
||||
return await _recording_out(session, rec)
|
||||
|
||||
|
||||
@router.delete("/recordings/{recording_id}", status_code=204)
|
||||
async def delete_recording(
|
||||
recording_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
session: SessionDep,
|
||||
purge: bool = Query(default=False, description="true also deletes stored audio"),
|
||||
):
|
||||
"""Soft-delete by default; ?purge=true removes rows + stored files now."""
|
||||
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")
|
||||
if not purge:
|
||||
rec.deleted_at = utcnow()
|
||||
await session.flush()
|
||||
return Response(status_code=204)
|
||||
|
||||
from shonar.storage import get_storage
|
||||
|
||||
storage = get_storage()
|
||||
assets = list(
|
||||
await session.scalars(select(Asset).where(Asset.recording_id == rec.id))
|
||||
)
|
||||
await session.delete(rec) # cascades to assets/transcripts/summaries/jobs
|
||||
await session.flush()
|
||||
for a in assets:
|
||||
with contextlib.suppress(Exception): # best effort
|
||||
await storage.delete(a.storage_key)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/recordings/{recording_id}/audio")
|
||||
async def download_audio(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep):
|
||||
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")
|
||||
original = await session.scalar(
|
||||
select(Asset).where(Asset.recording_id == rec.id, Asset.kind == AssetKind.original)
|
||||
)
|
||||
if original is None:
|
||||
raise HTTPException(404, "No audio stored for this recording")
|
||||
from fastapi.responses import Response as RawResponse
|
||||
|
||||
from shonar.storage import get_storage
|
||||
|
||||
data = await get_storage().get(original.storage_key)
|
||||
ext = original.storage_key[original.storage_key.rfind(".") :]
|
||||
filename = f"{rec.recorded_at:%Y%m%d-%H%M%S}{ext}"
|
||||
return RawResponse(
|
||||
content=data,
|
||||
media_type=original.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# --- AI outputs (M7): latest transcript / summary / job states --------------
|
||||
|
||||
|
||||
async def _owned_recording(
|
||||
session: SessionDep, user: CurrentUser, recording_id: uuid.UUID
|
||||
) -> Recording:
|
||||
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")
|
||||
return rec
|
||||
|
||||
|
||||
@router.get("/recordings/{recording_id}/transcript", response_model=TranscriptOut)
|
||||
async def get_transcript(
|
||||
recording_id: uuid.UUID, user: CurrentUser, session: SessionDep
|
||||
):
|
||||
rec = await _owned_recording(session, user, recording_id)
|
||||
row = await session.scalar(
|
||||
select(Transcript)
|
||||
.where(
|
||||
Transcript.recording_id == rec.id,
|
||||
Transcript.superseded_at.is_(None),
|
||||
)
|
||||
.order_by(Transcript.version.desc())
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(404, "No transcript yet")
|
||||
return _transcript_out(row)
|
||||
|
||||
|
||||
@router.get("/recordings/{recording_id}/summary", response_model=SummaryOut)
|
||||
async def get_summary(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep):
|
||||
rec = await _owned_recording(session, user, recording_id)
|
||||
row = await session.scalar(
|
||||
select(Summary)
|
||||
.where(
|
||||
Summary.recording_id == rec.id,
|
||||
Summary.superseded_at.is_(None),
|
||||
)
|
||||
.order_by(Summary.version.desc())
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(404, "No summary yet")
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/recordings/{recording_id}/jobs", response_model=list[ProcessingJobOut])
|
||||
async def list_jobs(recording_id: uuid.UUID, user: CurrentUser, session: SessionDep):
|
||||
rec = await _owned_recording(session, user, recording_id)
|
||||
rows = await session.scalars(
|
||||
select(ProcessingJob)
|
||||
.where(ProcessingJob.recording_id == rec.id)
|
||||
.order_by(ProcessingJob.id)
|
||||
)
|
||||
return list(rows)
|
||||
|
||||
|
||||
@router.post("/recordings/{recording_id}/reprocess", response_model=list[ProcessingJobOut])
|
||||
async def reprocess_recording(
|
||||
recording_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
session: SessionDep,
|
||||
job: str = Query(default="summarize", pattern="^(transcribe|summarize)$"),
|
||||
model: str | None = Query(default=None, max_length=64),
|
||||
):
|
||||
"""Force one pipeline stage to run again (Summarize / Re-transcribe).
|
||||
|
||||
Unlike the enqueue-on-finalize path, this ignores prior success: a
|
||||
summary the user wants regenerated (better model, new prompt) is a
|
||||
deliberate request. Running jobs are left alone (409 instead of a
|
||||
duplicate).
|
||||
"""
|
||||
from shonar.db.models import JobStatus, ProcessingStatus
|
||||
from shonar.db.models import JobType as JT
|
||||
from shonar.services import processing as proc
|
||||
|
||||
rec = await _owned_recording(session, user, recording_id)
|
||||
job_type = JT(job)
|
||||
if job_type is JT.summarize and await proc.latest_transcript_text(
|
||||
session, rec.id
|
||||
) is None:
|
||||
raise HTTPException(409, "Transcribe first — there is nothing to summarize.")
|
||||
existing = await session.scalar(
|
||||
select(ProcessingJob)
|
||||
.where(
|
||||
ProcessingJob.recording_id == rec.id,
|
||||
ProcessingJob.job_type == job_type,
|
||||
)
|
||||
.order_by(ProcessingJob.id.desc())
|
||||
)
|
||||
if existing is not None and existing.status in (
|
||||
JobStatus.queued,
|
||||
JobStatus.running,
|
||||
):
|
||||
raise HTTPException(409, "That stage is already running.")
|
||||
if existing is None:
|
||||
session.add(ProcessingJob(recording_id=rec.id, job_type=job_type))
|
||||
else:
|
||||
existing.status = JobStatus.queued
|
||||
existing.attempt = 0
|
||||
existing.error = None
|
||||
existing.stage = None
|
||||
existing.progress = None
|
||||
existing.started_at = None
|
||||
existing.finished_at = None
|
||||
if job_type is JT.transcribe:
|
||||
if model is not None:
|
||||
# A re-transcribe may switch models; the saved per-recording
|
||||
# override is what the worker reads, so persist it here.
|
||||
from shonar.services.ai import ProviderConfigError
|
||||
from shonar.services.ai.model_registry import validate_model_name
|
||||
|
||||
try:
|
||||
rec.transcription_model = validate_model_name(model)
|
||||
except ProviderConfigError as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
rec.processing_status = ProcessingStatus.processing
|
||||
rec.processing_error = None
|
||||
await session.flush()
|
||||
await proc.transport_enqueue(job_type, rec.id)
|
||||
rows = await session.scalars(
|
||||
select(ProcessingJob)
|
||||
.where(ProcessingJob.recording_id == rec.id)
|
||||
.order_by(ProcessingJob.id)
|
||||
)
|
||||
return list(rows)
|
||||
|
||||
|
||||
# --- M8: user edits (new version, edited_by_user=True; pipeline won't clobber)
|
||||
def _transcript_out(row: Transcript) -> TranscriptOut:
|
||||
return TranscriptOut(
|
||||
version=row.version,
|
||||
language=row.language,
|
||||
provider=row.provider,
|
||||
model=row.model,
|
||||
text=row.text,
|
||||
segments=[
|
||||
s for s in (row.segments or []) if isinstance(s, dict)
|
||||
],
|
||||
edited_by_user=row.edited_by_user,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/recordings/{recording_id}/transcript", response_model=TranscriptOut)
|
||||
async def update_transcript(
|
||||
recording_id: uuid.UUID, body: TranscriptUpdate, user: CurrentUser, session: SessionDep
|
||||
):
|
||||
from sqlalchemy import func as sql_func
|
||||
|
||||
rec = await _owned_recording(session, user, recording_id)
|
||||
existing = list(
|
||||
await session.scalars(
|
||||
select(Transcript)
|
||||
.where(
|
||||
Transcript.recording_id == rec.id,
|
||||
Transcript.superseded_at.is_(None),
|
||||
)
|
||||
.order_by(Transcript.version.desc())
|
||||
)
|
||||
)
|
||||
now = utcnow()
|
||||
max_version = await session.scalar(
|
||||
select(sql_func.max(Transcript.version)).where(Transcript.recording_id == rec.id)
|
||||
)
|
||||
for row in existing:
|
||||
row.superseded_at = now
|
||||
row = Transcript(
|
||||
recording_id=rec.id,
|
||||
version=(max_version or 0) + 1,
|
||||
language=body.language,
|
||||
provider="user",
|
||||
model=None,
|
||||
text=body.text,
|
||||
segments=(
|
||||
[
|
||||
{"start": s.start, "end": s.end, "text": s.text, "speaker": s.speaker}
|
||||
for s in (body.segments or [])
|
||||
]
|
||||
if body.segments is not None
|
||||
else None
|
||||
),
|
||||
edited_by_user=True,
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return _transcript_out(row)
|
||||
|
||||
|
||||
@router.put("/recordings/{recording_id}/summary", response_model=SummaryOut)
|
||||
async def update_summary(
|
||||
recording_id: uuid.UUID, body: SummaryUpdate, user: CurrentUser, session: SessionDep
|
||||
):
|
||||
from sqlalchemy import func as sql_func
|
||||
|
||||
rec = await _owned_recording(session, user, recording_id)
|
||||
existing = list(
|
||||
await session.scalars(
|
||||
select(Summary)
|
||||
.where(
|
||||
Summary.recording_id == rec.id,
|
||||
Summary.superseded_at.is_(None),
|
||||
)
|
||||
.order_by(Summary.version.desc())
|
||||
)
|
||||
)
|
||||
now = utcnow()
|
||||
max_version = await session.scalar(
|
||||
select(sql_func.max(Summary.version)).where(Summary.recording_id == rec.id)
|
||||
)
|
||||
for row in existing:
|
||||
row.superseded_at = now
|
||||
row = Summary(
|
||||
recording_id=rec.id,
|
||||
version=(max_version or 0) + 1,
|
||||
provider="user",
|
||||
model=None,
|
||||
content=body.content,
|
||||
edited_by_user=True,
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
return row
|
||||
81
backend/shonar/api/v1/search_exports.py
Normal file
81
backend/shonar/api/v1/search_exports.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""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",
|
||||
},
|
||||
)
|
||||
54
backend/shonar/api/v1/users.py
Normal file
54
backend/shonar/api/v1/users.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Users and devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from shonar.api.deps import CurrentUser, SessionDep
|
||||
from shonar.api.schemas_common import DeviceOut, UserOut, UserUpdate
|
||||
from shonar.db.models import Device, RefreshToken, utcnow
|
||||
|
||||
router = APIRouter(tags=["users", "devices"])
|
||||
|
||||
|
||||
@router.get("/users/me", response_model=UserOut)
|
||||
async def get_me(user: CurrentUser):
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/users/me", response_model=UserOut)
|
||||
async def update_me(body: UserUpdate, user: CurrentUser, session: SessionDep):
|
||||
if body.display_name is not None:
|
||||
user.display_name = body.display_name
|
||||
if body.location_storage_enabled is not None:
|
||||
user.location_storage_enabled = body.location_storage_enabled
|
||||
await session.flush()
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/devices", response_model=list[DeviceOut])
|
||||
async def list_devices(user: CurrentUser, session: SessionDep):
|
||||
rows = await session.scalars(
|
||||
select(Device).where(Device.user_id == user.id).order_by(Device.last_seen_at.desc())
|
||||
)
|
||||
return list(rows)
|
||||
|
||||
|
||||
@router.delete("/devices/{device_id}", status_code=204)
|
||||
async def revoke_device(device_id: uuid.UUID, user: CurrentUser, session: SessionDep):
|
||||
device = await session.get(Device, device_id)
|
||||
# Ownership check — no cross-user access, and 404 (not 403) to avoid
|
||||
# leaking existence.
|
||||
if device is None or device.user_id != user.id:
|
||||
raise HTTPException(404, "Device not found")
|
||||
device.revoked_at = utcnow()
|
||||
# Revoke this device's live refresh tokens.
|
||||
await session.execute(
|
||||
update(RefreshToken)
|
||||
.where(RefreshToken.device_id == device.id, RefreshToken.revoked_at.is_(None))
|
||||
.values(revoked_at=utcnow())
|
||||
)
|
||||
return None
|
||||
Loading…
Add table
Add a link
Reference in a new issue