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:
avi 2026-09-14 17:14:54 -05:00
commit 76c867fca4
136 changed files with 21099 additions and 0 deletions

View file

View file

@ -0,0 +1,49 @@
"""FastAPI dependencies: current user, session, rate limiting."""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from shonar.core.security import TokenError, decode_access_token
from shonar.db.models import User
from shonar.db.session import get_session
SessionDep = Annotated[AsyncSession, Depends(get_session)]
_bearer = HTTPBearer(auto_error=False)
async def get_current_user(
request: Request,
session: SessionDep,
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
) -> User:
if creds is None or not creds.credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
) from None
try:
payload = decode_access_token(creds.credentials)
except TokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
) from None
user = await session.get(User, uuid.UUID(payload["sub"]))
if user is None or not user.is_active or user.deleted_at is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Account unavailable"
) from None
request.state.user_id = user.id
return user
CurrentUser = Annotated[User, Depends(get_current_user)]

View file

@ -0,0 +1,69 @@
"""Shared Pydantic schemas."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
# --- auth ---
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=10, max_length=256)
display_name: str | None = Field(default=None, max_length=120)
class LoginRequest(BaseModel):
email: EmailStr
password: str
device_name: str | None = Field(default=None, max_length=120)
platform: str = Field(default="android", max_length=40)
class TokenPair(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
refresh_token: str
device_id: uuid.UUID | None = None
class RefreshRequest(BaseModel):
refresh_token: str
class LogoutRequest(BaseModel):
refresh_token: str
class UserOut(ORMModel):
id: uuid.UUID
email: EmailStr
display_name: str | None
location_storage_enabled: bool
created_at: datetime
class UserUpdate(BaseModel):
display_name: str | None = Field(default=None, max_length=120)
location_storage_enabled: bool | None = None
class DeviceOut(ORMModel):
id: uuid.UUID
name: str
platform: str
last_seen_at: datetime
revoked_at: datetime | None
class DeleteAccountRequest(BaseModel):
password: str

View file

@ -0,0 +1,149 @@
"""Recording / upload schemas."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class ORMModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class UploadSessionCreate(BaseModel):
declared_mime_type: str = Field(max_length=100)
declared_size_bytes: int = Field(gt=0)
title: str | None = Field(default=None, max_length=300)
client_recording_id: str | None = Field(default=None, max_length=64)
# Optional per-recording transcription model override ("Use default"
# when omitted). Validated against the model registry at finalize time.
transcription_model: str | None = Field(default=None, max_length=32)
class UploadSessionOut(ORMModel):
id: uuid.UUID
status: str
chunk_size_bytes: int
declared_mime_type: str
declared_size_bytes: int
expires_at: datetime
class UploadStatusOut(BaseModel):
id: uuid.UUID
status: str
received_chunk_indexes: list[int]
class RecordingFinalize(BaseModel):
recorded_at: datetime | None = None
duration_seconds: float = Field(default=0.0, ge=0)
# Location accepted ONLY if the user has location storage enabled
# (enforced in the route; silently dropped otherwise).
latitude: float | None = Field(default=None, ge=-90, le=90)
longitude: float | None = Field(default=None, ge=-180, le=180)
location_accuracy_m: float | None = Field(default=None, ge=0)
notes: str | None = Field(default=None, max_length=20000)
# Optional per-recording transcription model override. When both the
# session and the finalize body specify one, the finalize body wins.
transcription_model: str | None = Field(default=None, max_length=32)
class RecordingOut(ORMModel):
id: uuid.UUID
title: str
recorded_at: datetime
duration_seconds: float
notes: str | None
latitude: float | None
longitude: float | None
processing_status: str
processing_error: str | None
# Effective transcription model saved at finalize time (override or the
# global default then in force). None for pre-model rows.
transcription_model: str | None
has_audio: bool
mime_type: str | None
size_bytes: int | None
tags: list[str]
created_at: datetime
updated_at: datetime
class RecordingUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=300)
notes: str | None = Field(default=None, max_length=20000)
tags: list[str] | None = Field(default=None, max_length=20)
latitude: float | None = Field(default=None, ge=-90, le=90)
longitude: float | None = Field(default=None, ge=-180, le=180)
class RecordingListOut(BaseModel):
items: list[RecordingOut]
total: int
limit: int
offset: int
class SegmentOut(BaseModel):
start: float
end: float
text: str
speaker: str | None = None
class TranscriptOut(ORMModel):
version: int
language: str | None
provider: str
model: str | None
text: str
segments: list[SegmentOut]
edited_by_user: bool
created_at: datetime
updated_at: datetime
class SummaryOut(ORMModel):
version: int
provider: str
model: str | None
content: dict
edited_by_user: bool
created_at: datetime
updated_at: datetime
class ProcessingJobOut(ORMModel):
job_type: str
status: str
attempt: int
max_attempts: int
error: str | None
# Display-only phase within a running job ("loading-model",
# "transcribing") plus 0-100 progress when known.
stage: str | None
progress: int | None
started_at: datetime | None
finished_at: datetime | None
class TranscriptUpdate(BaseModel):
"""User edit: stored as a new version with edited_by_user=True.
The AI pipeline never overwrites a newest user-edited row, so edits
are verdicts. Segments are optional — when omitted the previous
segments are dropped (plain-text correction).
"""
text: str = Field(min_length=1, max_length=200000)
segments: list[SegmentOut] | None = Field(default=None, max_length=2000)
language: str | None = Field(default=None, max_length=16)
class SummaryUpdate(BaseModel):
"""User edit for the structured summary (same versioning rule)."""
content: dict = Field(max_length=100)

View file

@ -0,0 +1,23 @@
"""Search + export schemas (M9)."""
from __future__ import annotations
import uuid
from pydantic import BaseModel
class SearchHitOut(BaseModel):
id: uuid.UUID
title: str
field: str # title | tag | notes | summary | transcript
snippet: str
class SearchOut(BaseModel):
query: str
scope: str
items: list[SearchHitOut]
total: int
limit: int
offset: int

View 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)

View 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}

View 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(),
}

View 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),
)

View 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,
}

View 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

View 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",
},
)

View 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