M8 + T1: detail screen with transcript/summary editing; transcription model registry

Backend:
- PUT /recordings/{id}/transcript and /summary — user-edited, versioned;
  the pipeline never clobbers user versions
- Model registry (tiny/base/small/medium/large-v3), global default via
  PUT /models/default (future rows only), per-recording override on
  finalize/upload-session (finalize wins), GET /api/v1/models
- processing_jobs gain stage/progress; faster-whisper model cache +
  fail-fast on unavailable models; migration m8models000001
- 10 model tests + 5 transcript-edit tests (backend suite 64 green, ruff clean)

Android:
- detail/{id} route: transcript/summary/status tabs, tap-to-seek, speed
  control, edit dialogs, title rename via CustomShonarProvider AI methods
- pure AiContent parsing/sync mapping; honest empty states for
  local-only/unsynced rows; 19 new unit tests (Android suite green)
This commit is contained in:
avi 2026-09-12 22:33:16 -05:00
commit 8873c759e1
41 changed files with 4396 additions and 134 deletions

View file

@ -9,6 +9,16 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Folder picker (sync-folder provider): browse your own storage to
choose where recordings live. API 30+ uses all-files access granted
in Settings; older releases use the read permission. SHONAR is
sideloaded/self-hosted — no Play policy governs this, and the app
touches only the folder you pick. -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application <application
android:name=".ShonarApplication" android:name=".ShonarApplication"
android:allowBackup="false" android:allowBackup="false"

View file

@ -8,9 +8,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.shonar.ui.detail.DetailsScreen
import com.shonar.ui.folder.FolderBrowserScreen
import com.shonar.ui.home.HomeScreen import com.shonar.ui.home.HomeScreen
import com.shonar.ui.provider.ProviderSelectionScreen import com.shonar.ui.provider.ProviderSelectionScreen
import com.shonar.ui.provider.StorageScreen import com.shonar.ui.provider.StorageScreen
@ -32,6 +37,16 @@ class MainActivity : ComponentActivity() {
HomeScreen( HomeScreen(
onOpenSettings = { nav.navigate("settings") }, onOpenSettings = { nav.navigate("settings") },
onOpenStorage = { nav.navigate("storage") }, onOpenStorage = { nav.navigate("storage") },
onOpenDetail = { id -> nav.navigate("detail/$id") },
)
}
composable(
"detail/{id}",
arguments = listOf(navArgument("id") { type = androidx.navigation.NavType.StringType }),
) { backStack ->
DetailsScreen(
recordingId = backStack.arguments?.getString("id").orEmpty(),
onBack = { nav.popBackStack() },
) )
} }
composable("storage") { composable("storage") {
@ -40,10 +55,29 @@ class MainActivity : ComponentActivity() {
onSwitchProvider = { nav.navigate("provider") }, onSwitchProvider = { nav.navigate("provider") },
) )
} }
composable("provider") { composable("provider") { entry ->
val pick by entry.savedStateHandle
.getStateFlow<String?>("picked_path", null)
.collectAsState()
ProviderSelectionScreen( ProviderSelectionScreen(
onDone = { nav.popBackStack() }, onDone = { nav.popBackStack() },
onBack = { nav.popBackStack() }, onBack = { nav.popBackStack() },
browsePick = pick,
onPickConsumed = {
entry.savedStateHandle.remove<String>("picked_path")
},
onBrowse = { nav.navigate("folderBrowser") },
)
}
composable("folderBrowser") {
FolderBrowserScreen(
onPick = { path ->
nav.previousBackStackEntry
?.savedStateHandle
?.set("picked_path", path)
nav.popBackStack()
},
onBack = { nav.popBackStack() },
) )
} }
composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) } composable("settings") { SettingsScreen(onBack = { nav.popBackStack() }) }

View file

@ -28,7 +28,19 @@ class ShonarApplication : Application() {
} }
val recordingRepository: RecordingRepository by lazy { val recordingRepository: RecordingRepository by lazy {
RecordingRepository(this, database.recordingDao()) RecordingRepository(filesDir, database.recordingDao()) {
// New recordings belong in the picked folder (local-only
// provider), or the app-private library by default / for
// server-backed setups. Never throws — null means default.
runCatching {
settingsManager.ensureLoaded()
val pid = settingsManager.string(
com.shonar.settings.BuiltInSettings.PROVIDER_ID
).ifBlank { "local-only" }
(providerRegistry.provider(pid) as? com.shonar.provider.LocalOnlyProvider)
?.currentRoot()
}.getOrNull()
}
} }
val secureStore: SecureSettingsStore by lazy { SecureSettingsStore(this) } val secureStore: SecureSettingsStore by lazy { SecureSettingsStore(this) }
@ -63,6 +75,7 @@ class ShonarApplication : Application() {
tlsPolicy = com.shonar.provider.TlsPolicy( tlsPolicy = com.shonar.provider.TlsPolicy(
tofu = tofu, tofu = tofu,
bodiesEnabled = bodyLogging::get, bodiesEnabled = bodyLogging::get,
sink = { msg -> android.util.Log.d("ShonarNet", msg) },
), ),
) )
} }
@ -73,6 +86,8 @@ class ShonarApplication : Application() {
settingsManager.ensureLoaded() settingsManager.ensureLoaded()
// TOFU pins must be in cache before any TLS handshake needs them. // TOFU pins must be in cache before any TLS handshake needs them.
tofu.refresh() tofu.refresh()
// Point new recordings at the picked folder (if any).
runCatching { recordingRepository.refreshRoot() }
syncBodyLoggingFlag() syncBodyLoggingFlag()
scheduleSync() scheduleSync()
settingsManager.valuesChanged.collect { settingsManager.valuesChanged.collect {

View file

@ -40,10 +40,10 @@ import okhttp3.Response
* refresh, and concurrent 401s serialize on [refreshMutex] — two parallel * refresh, and concurrent 401s serialize on [refreshMutex] — two parallel
* refreshes would look like token reuse and burn the whole family. * refreshes would look like token reuse and burn the whole family.
* *
* Sidecars: the backend has no sidecar endpoints until M7 (transcripts), * Sidecars: file cache under [sidecarRoot] keyed by remote recording id
* so transcript/summary JSON is cached in app-private storage keyed by the * (same layout as LocalOnlyProvider). AI content (transcript/summary/jobs)
* remote recording id. Same layout as LocalOnlyProvider, so the M7 * goes through the real M7 endpoints instead — see [fetchTranscript] and
* migration is a file walk, not a format change. * friends, consumed by the M8 details screen.
* *
* Cancellation safety: a cancelled upload leaves an open server session * Cancellation safety: a cancelled upload leaves an open server session
* with some chunks stored — invisible until finalize, resumable via the * with some chunks stored — invisible until finalize, resumable via the
@ -55,6 +55,9 @@ class CustomShonarProvider(
private val sidecarRoot: File, private val sidecarRoot: File,
private val client: OkHttpClient = defaultClient(), private val client: OkHttpClient = defaultClient(),
private val handshake: ShonarHandshake = ShonarHandshake(), private val handshake: ShonarHandshake = ShonarHandshake(),
/** Identifies this client to the server (login device_name/platform). */
private val deviceName: String = "SHONAR Android",
private val platform: String = "android",
) : ShonarProvider { ) : ShonarProvider {
override val descriptor = ProviderDescriptor( override val descriptor = ProviderDescriptor(
@ -283,6 +286,127 @@ class CustomShonarProvider(
} }
} }
// ---- AI content (M7 endpoints, consumed by the M8 details screen) ----
//
// The recording id here is the server-side UUID — the local row's
// [com.shonar.recording.RecordingEntity.remoteKey] after upload.
// 404 surfaces as [ProviderError.NotFound] ("no transcript yet" is a
// normal state, not a failure). Raw JSON comes back so parsing stays in
// the testable `recording.AiContent` helpers, not in this IO class.
suspend fun fetchTranscript(recordingId: String): String = getAiJson(
"/api/v1/recordings/$recordingId/transcript", recordingId
)
suspend fun fetchSummary(recordingId: String): String = getAiJson(
"/api/v1/recordings/$recordingId/summary", recordingId
)
suspend fun fetchJobs(recordingId: String): String = getAiJson(
"/api/v1/recordings/$recordingId/jobs", recordingId
)
/** User edit → new server version with edited_by_user=true (pipeline won't clobber). */
suspend fun updateTranscript(
recordingId: String,
payloadJson: String,
): String = withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token -> put(base, "/api/v1/recordings/$recordingId/transcript", token, payloadJson) }
.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
404 -> throw ProviderError.NotFound(recordingId)
else -> throw ProviderError.Transient("Transcript update failed (HTTP ${resp.code})")
}
}
}
suspend fun updateSummary(recordingId: String, payloadJson: String): String =
withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token -> put(base, "/api/v1/recordings/$recordingId/summary", token, payloadJson) }
.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
404 -> throw ProviderError.NotFound(recordingId)
else -> throw ProviderError.Transient("Summary update failed (HTTP ${resp.code})")
}
}
}
/** Title/notes rename from the details screen (server + Room updated by caller). */
suspend fun patchRecording(recordingId: String, payloadJson: String): String =
withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token -> patch(base, "/api/v1/recordings/$recordingId", token, payloadJson) }
.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
404 -> throw ProviderError.NotFound(recordingId)
else -> throw ProviderError.Transient("Recording update failed (HTTP ${resp.code})")
}
}
}
private suspend fun getAiJson(path: String, recordingId: String): String =
withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token -> get(base, path, token) }.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
404 -> throw ProviderError.NotFound(recordingId)
else -> throw ProviderError.Transient("Request failed (HTTP ${resp.code})")
}
}
}
// ---- transcription models (Stage 1 endpoints, desktop Settings) --------
// Raw JSON throughout; parsing lives in the testable AiContent helpers.
suspend fun fetchModels(): String = withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token -> get(base, "/api/v1/models", token) }.use { resp ->
if (resp.code != 200) throw ProviderError.Transient("Models request failed (HTTP ${resp.code})")
resp.body?.string().orEmpty()
}
}
suspend fun setDefaultModel(model: String): String = withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
val payload = """{"model":${jsonStr(model)}}"""
executeAuthed(base) { token -> put(base, "/api/v1/models/default", token, payload) }
.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
422 -> throw ProviderError.InvalidUrl("Unsupported model: $model")
else -> throw ProviderError.Transient("Default model update failed (HTTP ${resp.code})")
}
}
}
/** Fetch a model into the server cache (needs internet once; may take minutes). */
suspend fun downloadModel(model: String): String = withContext(Dispatchers.IO) {
ensureConnected()
val base = currentOrigin()
executeAuthed(base) { token ->
post(base, "/api/v1/models/$model/download", token, "{}")
}.use { resp ->
when (resp.code) {
200 -> resp.body?.string().orEmpty()
422 -> throw ProviderError.InvalidUrl("Unsupported model: $model")
501 -> throw ProviderError.Transient("faster-whisper is not installed on the server")
else -> throw ProviderError.Transient("Model download failed (HTTP ${resp.code})")
}
}
}
// ---- sidecars (local cache until the backend gains endpoints in M7) ---- // ---- sidecars (local cache until the backend gains endpoints in M7) ----
private fun sidecarDir(ref: RemoteRef) = File(sidecarRoot, ref.key) private fun sidecarDir(ref: RemoteRef) = File(sidecarRoot, ref.key)
@ -338,7 +462,7 @@ class CustomShonarProvider(
private suspend fun login(base: String, email: String, password: String) { private suspend fun login(base: String, email: String, password: String) {
val body = """{"email":${jsonStr(email)},"password":${jsonStr(password)},""" + val body = """{"email":${jsonStr(email)},"password":${jsonStr(password)},""" +
""""device_name":"SHONAR Android","platform":"android"}""" """"device_name":${jsonStr(deviceName)},"platform":${jsonStr(platform)}}"""
postUnauthed(base, "/api/v1/auth/login", body).use { resp -> postUnauthed(base, "/api/v1/auth/login", body).use { resp ->
when (resp.code) { when (resp.code) {
200 -> { 200 -> {
@ -445,6 +569,16 @@ class CustomShonarProvider(
.post(json.toRequestBody("application/json; charset=utf-8".toMediaType())) .post(json.toRequestBody("application/json; charset=utf-8".toMediaType()))
.header("Authorization", "Bearer $access").build() .header("Authorization", "Bearer $access").build()
private fun put(base: String, path: String, access: String, json: String): Request =
Request.Builder().url(base + path)
.put(json.toRequestBody("application/json; charset=utf-8".toMediaType()))
.header("Authorization", "Bearer $access").build()
private fun patch(base: String, path: String, access: String, json: String): Request =
Request.Builder().url(base + path)
.patch(json.toRequestBody("application/json; charset=utf-8".toMediaType()))
.header("Authorization", "Bearer $access").build()
private fun postUnauthed(base: String, path: String, json: String): Response { private fun postUnauthed(base: String, path: String, json: String): Response {
val req = Request.Builder().url(base + path) val req = Request.Builder().url(base + path)
.post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build() .post(json.toRequestBody("application/json; charset=utf-8".toMediaType())).build()
@ -453,10 +587,16 @@ class CustomShonarProvider(
// Upload-session flow returns Triple(sessionId, chunkSize, declaredMime echo not needed). // Upload-session flow returns Triple(sessionId, chunkSize, declaredMime echo not needed).
private suspend fun createSession(base: String, draft: RecordingDraft): Pair<String, Int> { private suspend fun createSession(base: String, draft: RecordingDraft): Pair<String, Int> {
val body = """{"declared_mime_type":${jsonStr(draft.mime)},""" + val body = buildString {
""""declared_size_bytes":${draft.sizeBytes},""" + append("""{"declared_mime_type":${jsonStr(draft.mime)},""")
""""title":${jsonStr(draft.title)},""" + append(""""declared_size_bytes":${draft.sizeBytes},""")
""""client_recording_id":${jsonStr(draft.id)}}""" append(""""title":${jsonStr(draft.title)},""")
append(""""client_recording_id":${jsonStr(draft.id)}""")
if (draft.transcriptionModel != null) {
append(""","transcription_model":${jsonStr(draft.transcriptionModel)}""")
}
append("}")
}
executeAuthed(base) { token -> post(base, "/api/v1/uploads", token, body) }.use { resp -> executeAuthed(base) { token -> post(base, "/api/v1/uploads", token, body) }.use { resp ->
when (resp.code) { when (resp.code) {
201 -> { 201 -> {
@ -494,8 +634,14 @@ class CustomShonarProvider(
private suspend fun finalize(base: String, sessionId: String, draft: RecordingDraft): String { private suspend fun finalize(base: String, sessionId: String, draft: RecordingDraft): String {
val recordedAt = Instant.ofEpochMilli(draft.createdAtEpochMs).toString() val recordedAt = Instant.ofEpochMilli(draft.createdAtEpochMs).toString()
val body = """{"recorded_at":${jsonStr(recordedAt)},""" + val body = buildString {
""""duration_seconds":${draft.durationMs / 1000.0}}""" append("""{"recorded_at":${jsonStr(recordedAt)},""")
append(""""duration_seconds":${draft.durationMs / 1000.0}""")
if (draft.transcriptionModel != null) {
append(""","transcription_model":${jsonStr(draft.transcriptionModel)}""")
}
append("}")
}
executeAuthed(base) { token -> post(base, "/api/v1/uploads/$sessionId/finalize", token, body) } executeAuthed(base) { token -> post(base, "/api/v1/uploads/$sessionId/finalize", token, body) }
.use { resp -> .use { resp ->
if (resp.code != 201) { if (resp.code != 201) {

View file

@ -73,6 +73,39 @@ class FolderSyncProvider(
_authState.value = AuthState.CONNECTED _authState.value = AuthState.CONNECTED
} }
/**
* Create the folder, then connect. Safety rules (a typo must never
* spray directories across storage):
* - no `..` segments, never a blank path;
* - the direct parent must already exist, be a directory, and be
* writable — only the final segment is ever created;
* - when the folder already exists this is just [connect].
*/
suspend fun createRoot(rawPath: String) = withContext(Dispatchers.IO) {
val trimmed = rawPath.trim()
if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty")
val dir = File(trimmed)
if (".." in dir.path.split(File.separatorChar)) {
throw ProviderError.InvalidUrl("Folder path must not contain '..'")
}
if (!dir.exists()) {
val parent = dir.absoluteFile.parentFile
?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed")
if (!parent.isDirectory) throw ProviderError.InvalidUrl(
"Parent is not a folder: ${parent.path}"
)
if (!parent.canWrite()) throw ProviderError.InvalidUrl(
"Parent folder is not writable: ${parent.path}"
)
if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl(
"Could not create folder: ${dir.path}"
)
}
val checked = checkDir(dir)
pathStore.putString(KEY_ROOT, checked.canonicalPath)
_authState.value = AuthState.CONNECTED
}
override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) { override suspend fun reconnect(): AuthState = withContext(Dispatchers.IO) {
val root = storedRoot() val root = storedRoot()
if (root == null) { if (root == null) {
@ -146,10 +179,11 @@ class FolderSyncProvider(
val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList() val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList()
StorageLocation( StorageLocation(
headline = "Sync folder", headline = "Sync folder",
detail = "${r.path} · ${audio.size} recordings — synced by your sync tool, not by this app.", detail = "${audio.size} recordings — synced by your sync tool, not by this app.",
syncedCount = 0, syncedCount = 0,
localOnlyCount = audio.size, localOnlyCount = audio.size,
bytesUsed = audio.sumOf { it.length() }, bytesUsed = audio.sumOf { it.length() },
path = r.path,
) )
} }

View file

@ -8,9 +8,17 @@ import java.io.File
* Local-only provider: everything stays in app-private storage. First-class * Local-only provider: everything stays in app-private storage. First-class
* so the app has no "no server" special case. No networking code paths at * so the app has no "no server" special case. No networking code paths at
* all — provable by construction (this file imports no HTTP library). * all — provable by construction (this file imports no HTTP library).
*
* The storage root is the app-private [defaultRoot], unless the user picked
* their own folder ([connect]/[createRoot] with a [ProviderCredential.FolderPath]):
* then files live there and [deleteAccountAndData] only forgets the path —
* a user folder is never deleted by this app.
*/ */
class LocalOnlyProvider( class LocalOnlyProvider(
private val root: File, private val defaultRoot: File,
private val pathStore: com.shonar.settings.SettingsStore =
com.shonar.settings.InMemorySettingsStore(),
private val rootKey: String = KEY_ROOT,
) : ShonarProvider { ) : ShonarProvider {
override val descriptor = ProviderDescriptor( override val descriptor = ProviderDescriptor(
@ -22,17 +30,93 @@ class LocalOnlyProvider(
private val _authState = MutableStateFlow(AuthState.CONNECTED) private val _authState = MutableStateFlow(AuthState.CONNECTED)
override val authState: StateFlow<AuthState> = _authState override val authState: StateFlow<AuthState> = _authState
private suspend fun storedRoot(): File? {
val raw = pathStore.getString(rootKey) ?: return null
return File(raw).takeIf { it.exists() }
}
/** App-private dir by default; the user's own folder once picked. */
private suspend fun effectiveRoot(): File = storedRoot() ?: defaultRoot
/** Where new recordings belong right now. Read by the recording repository. */
suspend fun currentRoot(): File = effectiveRoot()
private fun checkDir(dir: File): File {
if (".." in dir.path.split(File.separatorChar)) {
throw ProviderError.InvalidUrl("Folder path must not contain '..'")
}
if (!dir.exists()) throw ProviderError.InvalidUrl(
"Folder does not exist: ${dir.path}."
)
if (!dir.isDirectory) throw ProviderError.InvalidUrl("Not a folder: ${dir.path}")
if (!dir.canRead() || !dir.canWrite()) throw ProviderError.InvalidUrl(
"Folder is not readable and writable: ${dir.path}"
)
return dir
}
override suspend fun probe(baseUrl: ServerUrl): ProbeResult = override suspend fun probe(baseUrl: ServerUrl): ProbeResult =
ProbeResult.Incompatible // local-only never talks to servers ProbeResult.Incompatible // local-only never talks to servers
override suspend fun connect(credential: ProviderCredential) { override suspend fun connect(credential: ProviderCredential) {
if (credential != ProviderCredential.None) throw ProviderError.InvalidUrl( when (credential) {
"Local-only storage takes no credentials" ProviderCredential.None -> _authState.value = AuthState.CONNECTED
) is ProviderCredential.FolderPath -> {
val dir = checkDir(File(credential.path))
pathStore.putString(rootKey, dir.canonicalPath)
_authState.value = AuthState.CONNECTED
}
else -> throw ProviderError.InvalidUrl(
"Local-only storage takes no credentials"
)
}
}
/**
* Use the app-private folder again (forget a previously picked folder;
* its files stay where they are).
*/
suspend fun useDefaultRoot() {
pathStore.remove(rootKey)
_authState.value = AuthState.CONNECTED _authState.value = AuthState.CONNECTED
} }
override suspend fun reconnect(): AuthState = AuthState.CONNECTED.also { _authState.value = it } /**
* Create the folder, then use it. Same safety rules as the sync
* folder: only the final segment is ever created, the parent must
* already exist, no `..`. When it exists this is just [connect].
*/
suspend fun createRoot(rawPath: String) {
val trimmed = rawPath.trim()
if (trimmed.isEmpty()) throw ProviderError.InvalidUrl("Folder path is empty")
val dir = File(trimmed)
if (".." in dir.path.split(File.separatorChar)) {
throw ProviderError.InvalidUrl("Folder path must not contain '..'")
}
if (!dir.exists()) {
val parent = dir.absoluteFile.parentFile
?: throw ProviderError.InvalidUrl("Cannot create folder here: $trimmed")
if (!parent.isDirectory) throw ProviderError.InvalidUrl(
"Parent is not a folder: ${parent.path}"
)
if (!parent.canWrite()) throw ProviderError.InvalidUrl(
"Parent folder is not writable: ${parent.path}"
)
if (!dir.mkdirs() && !dir.isDirectory) throw ProviderError.InvalidUrl(
"Could not create folder: ${dir.path}"
)
}
val checked = checkDir(dir)
pathStore.putString(rootKey, checked.canonicalPath)
_authState.value = AuthState.CONNECTED
}
override suspend fun reconnect(): AuthState {
// A picked folder may have been deleted out from under us; fall
// back to the app-private dir rather than stranding recordings.
_authState.value = AuthState.CONNECTED
return _authState.value
}
override suspend fun disconnect(revokeOnServer: Boolean) { override suspend fun disconnect(revokeOnServer: Boolean) {
// Nothing to revoke; DISCONNECTED means "user left local mode" — the // Nothing to revoke; DISCONNECTED means "user left local mode" — the
@ -41,19 +125,25 @@ class LocalOnlyProvider(
} }
override suspend fun deleteAccountAndData() { override suspend fun deleteAccountAndData() {
if (root.exists()) root.deleteRecursively() if (storedRoot() != null) {
// A user-picked folder belongs to the user — forget it, never
// delete it.
pathStore.remove(rootKey)
} else if (defaultRoot.exists()) {
defaultRoot.deleteRecursively()
}
_authState.value = AuthState.DISCONNECTED _authState.value = AuthState.DISCONNECTED
} }
// ---- storage ----------------------------------------------------------- // ---- storage -----------------------------------------------------------
private fun audioFile(ref: RemoteRef) = File(root, ref.key) private suspend fun audioFile(ref: RemoteRef) = File(effectiveRoot(), ref.key)
private fun sidecarFile(ref: RemoteRef, kind: SidecarKind) = private suspend fun sidecarFile(ref: RemoteRef, kind: SidecarKind) =
File(root, "sidecars/${ref.key}/${kind.fileName}") File(effectiveRoot(), "sidecars/${ref.key}/${kind.fileName}")
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef { override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef {
val key = "audio/${draft.id}" val key = "audio/${draft.id}"
val dest = File(root, key) val dest = File(effectiveRoot(), key)
dest.parentFile?.mkdirs() dest.parentFile?.mkdirs()
// "Upload" locally = copy; report progress in slices so UI behaves uniformly // "Upload" locally = copy; report progress in slices so UI behaves uniformly
draft.sourceFile.inputStream().use { input -> draft.sourceFile.inputStream().use { input ->
@ -82,11 +172,11 @@ class LocalOnlyProvider(
override suspend fun delete(ref: RemoteRef) { override suspend fun delete(ref: RemoteRef) {
audioFile(ref).delete() audioFile(ref).delete()
File(root, "sidecars/${ref.key}").deleteRecursively() File(effectiveRoot(), "sidecars/${ref.key}").deleteRecursively()
} }
override suspend fun list(cursor: String?): Page<RemoteRecording> { override suspend fun list(cursor: String?): Page<RemoteRecording> {
val audioRoot = File(root, "audio") val audioRoot = File(effectiveRoot(), "audio")
val files = audioRoot.listFiles()?.filter { it.isFile } ?: emptyList() val files = audioRoot.listFiles()?.filter { it.isFile } ?: emptyList()
// single page; no paging for local storage // single page; no paging for local storage
val items = files.map { f -> val items = files.map { f ->
@ -113,18 +203,35 @@ class LocalOnlyProvider(
} }
override suspend fun storageLocationSummary(): StorageLocation { override suspend fun storageLocationSummary(): StorageLocation {
val audio = File(root, "audio").listFiles()?.filter { it.isFile } ?: emptyList() val raw = pathStore.getString(rootKey)
val custom = storedRoot()
if (raw != null && custom == null) {
return StorageLocation(
headline = "Folder unavailable",
detail = "Your picked folder is gone or unreadable. " +
"Reconnect storage or pick again — new recordings use the app folder meanwhile.",
syncedCount = 0,
localOnlyCount = 0,
bytesUsed = 0,
path = raw,
)
}
val r = custom ?: defaultRoot
val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList()
return StorageLocation( return StorageLocation(
headline = "On this device only", headline = if (custom != null) "Your folder" else "On this device only",
detail = "Recordings and transcripts never leave your phone.", detail = if (custom != null) "Recordings stay in your folder and never leave your phone."
else "Recordings and transcripts never leave your phone.",
syncedCount = 0, syncedCount = 0,
localOnlyCount = audio.size, localOnlyCount = audio.size,
bytesUsed = audio.sumOf { it.length() }, bytesUsed = audio.sumOf { it.length() },
path = r.absolutePath,
) )
} }
companion object { companion object {
const val ID = "local-only" const val ID = "local-only"
const val KEY_ROOT = "provider.local-only.root"
private fun File.sha256Hex(): String { private fun File.sha256Hex(): String {
val md = java.security.MessageDigest.getInstance("SHA-256") val md = java.security.MessageDigest.getInstance("SHA-256")

View file

@ -44,8 +44,9 @@ class ProviderRegistry(
const val SYNC_FOLDER_ID = FolderSyncProvider.ID const val SYNC_FOLDER_ID = FolderSyncProvider.ID
/** Behaviour-identical default: system trust, logging off, no pins. */ /** Behaviour-identical default: system trust, logging off, no pins. */
fun defaultTls(): TlsPolicy = TlsPolicy( fun defaultTls(sink: (String) -> Unit = {}): TlsPolicy = TlsPolicy(
tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())), tofu = TofuManager(TofuStore(com.shonar.settings.InMemorySettingsStore())),
sink = sink,
) )
/** /**
@ -77,7 +78,12 @@ class ProviderRegistry(
val folder = FolderSyncProvider(pathStore = plainStore) val folder = FolderSyncProvider(pathStore = plainStore)
return ProviderRegistry( return ProviderRegistry(
mapOf( mapOf(
LocalOnlyProvider.ID to { LocalOnlyProvider(java.io.File(appFilesDir, "shonar-local")) }, LocalOnlyProvider.ID to {
LocalOnlyProvider(
java.io.File(appFilesDir, "shonar-local"),
pathStore = plainStore,
)
},
CUSTOM_SHONAR_ID to { custom }, CUSTOM_SHONAR_ID to { custom },
NEXTCLOUD_ID to { nextcloud }, NEXTCLOUD_ID to { nextcloud },
SYNC_FOLDER_ID to { folder }, SYNC_FOLDER_ID to { folder },

View file

@ -180,6 +180,8 @@ data class RecordingDraft(
val mime: String, // audio/mp4 | audio/wav | audio/ogg val mime: String, // audio/mp4 | audio/wav | audio/ogg
val sourceFile: File, val sourceFile: File,
val sizeBytes: Long, val sizeBytes: Long,
/** Optional per-recording transcription model override (null = server default). */
val transcriptionModel: String? = null,
) )
enum class SidecarKind(val fileName: String) { enum class SidecarKind(val fileName: String) {
@ -200,6 +202,8 @@ data class StorageLocation(
val syncedCount: Int, val syncedCount: Int,
val localOnlyCount: Int, val localOnlyCount: Int,
val bytesUsed: Long, val bytesUsed: Long,
/** Concrete on-device path, when the files live in a real folder. */
val path: String? = null,
) )
/** Provider-level errors. Messages must never contain credentials/tokens. */ /** Provider-level errors. Messages must never contain credentials/tokens. */

View file

@ -9,11 +9,15 @@ import okhttp3.Response
* P5: builds every provider HTTP client with the TOFU trust manager and the * P5: builds every provider HTTP client with the TOFU trust manager and the
* redacting logger (docs/server-providers.md §4). One place, so no call * redacting logger (docs/server-providers.md §4). One place, so no call
* site can accidentally build a client that skips either. * site can accidentally build a client that skips either.
*
* The log [sink] is an explicit constructor argument (no platform default)
* so this file compiles on Android and JVM desktop alike; each platform
* passes its own sink (Logcat vs stdout).
*/ */
class TlsPolicy( class TlsPolicy(
val tofu: TofuManager, val tofu: TofuManager,
private val bodiesEnabled: () -> Boolean = { false }, private val bodiesEnabled: () -> Boolean = { false },
private val sink: (String) -> Unit = { msg -> android.util.Log.d("ShonarNet", msg) }, private val sink: (String) -> Unit,
) { ) {
fun newClient( fun newClient(
connectTimeoutS: Long, connectTimeoutS: Long,

View file

@ -0,0 +1,225 @@
package com.shonar.recording
import org.json.JSONArray
import org.json.JSONObject
/**
* M8: AI content models for the recording details screen.
*
* Pure Kotlin + org.json — no Android imports, so this file runs under
* plain JVM unit tests. The backend shapes mirror
* `backend/shonar/api/schemas_recordings.py`:
* - transcript: {version, language, provider, model, text,
* segments: [{start, end, text, speaker}], edited_by_user}
* - summary: {version, provider, model, content: {short, detailed,
* key_points, decisions, action_items, questions}, edited_by_user}
* - jobs: [{job_type, status, attempt, max_attempts, error, ...}]
*/
data class TranscriptSegment(
val startSec: Double,
val endSec: Double,
val text: String,
val speaker: String? = null,
)
data class TranscriptData(
val version: Int,
val language: String? = null,
val provider: String = "",
val model: String? = null,
val text: String = "",
val segments: List<TranscriptSegment> = emptyList(),
val editedByUser: Boolean = false,
)
data class SummaryData(
val version: Int,
val provider: String = "",
val model: String? = null,
/** Raw content dict; known keys read via [summaryString]/[summaryList]. */
val content: Map<String, Any?> = emptyMap(),
val editedByUser: Boolean = false,
) {
val short: String get() = summaryString(content, "short")
val detailed: String get() = summaryString(content, "detailed")
fun list(key: String): List<String> = summaryList(content, key)
}
data class JobInfo(
val jobType: String,
val status: String,
val attempt: Int = 0,
val maxAttempts: Int = 0,
val error: String? = null,
/** Display-only phase ("loading-model", "transcribing") + 0-100 progress. */
val stage: String? = null,
val progress: Int? = null,
)
/** Summary content keys, in display order. */
val SUMMARY_LIST_KEYS = listOf("key_points", "decisions", "action_items", "questions")
/**
* Index of the segment playing at [positionMs]: the last segment whose
* start is at or before the position. -1 when there are no segments or
* playback precedes the first one. End times are ignored on purpose —
* gaps between segments keep the last-spoken line highlighted instead of
* flickering to nothing.
*/
fun activeSegmentIndex(segments: List<TranscriptSegment>, positionMs: Long): Int {
val posSec = positionMs / 1000.0
var active = -1
for (i in segments.indices) {
if (segments[i].startSec <= posSec) active = i else break
}
return active
}
fun parseTranscript(raw: String): TranscriptData? = runCatching {
val json = JSONObject(raw)
val segments = mutableListOf<TranscriptSegment>()
json.optJSONArray("segments")?.let { arr ->
for (i in 0 until arr.length()) {
val s = arr.optJSONObject(i) ?: continue
segments += TranscriptSegment(
startSec = s.optDouble("start", 0.0),
endSec = s.optDouble("end", 0.0),
text = s.optString("text", ""),
speaker = s.optString("speaker", null).takeUnless { it.isNullOrBlank() },
)
}
}
segments.sortBy { it.startSec }
TranscriptData(
version = json.optInt("version", 0),
language = json.optString("language", null).takeUnless { it.isNullOrBlank() },
provider = json.optString("provider", ""),
model = json.optString("model", null).takeUnless { it.isNullOrBlank() },
text = json.optString("text", ""),
segments = segments,
editedByUser = json.optBoolean("edited_by_user", false),
)
}.getOrNull()
fun parseSummary(raw: String): SummaryData? = runCatching {
val json = JSONObject(raw)
val content = mutableMapOf<String, Any?>()
json.optJSONObject("content")?.let { obj ->
for (key in obj.keys()) content[key] = obj.get(key)
}
SummaryData(
version = json.optInt("version", 0),
provider = json.optString("provider", ""),
model = json.optString("model", null).takeUnless { it.isNullOrBlank() },
content = content,
editedByUser = json.optBoolean("edited_by_user", false),
)
}.getOrNull()
fun parseJobs(raw: String): List<JobInfo> = runCatching {
val arr = JSONArray(raw)
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
JobInfo(
jobType = o.optString("job_type", ""),
status = o.optString("status", ""),
attempt = o.optInt("attempt", 0),
maxAttempts = o.optInt("max_attempts", 0),
error = o.optString("error", null).takeUnless { it.isNullOrBlank() },
stage = o.optString("stage", null).takeUnless { it.isNullOrBlank() },
progress = if (o.has("progress") && !o.isNull("progress")) o.optInt("progress") else null,
)
}
}.getOrDefault(emptyList())
/** Payload for PUT /recordings/{id}/transcript. Null segments = text-only correction. */
fun transcriptUpdatePayload(text: String, segments: List<TranscriptSegment>? = null): String {
val obj = JSONObject().put("text", text)
if (segments != null) {
obj.put(
"segments",
JSONArray().also { arr ->
segments.forEach { s ->
arr.put(
JSONObject()
.put("start", s.startSec)
.put("end", s.endSec)
.put("text", s.text)
.let { o -> if (s.speaker != null) o.put("speaker", s.speaker) else o },
)
}
},
)
}
return obj.toString()
}
/** Payload for PUT /recordings/{id}/summary. */
fun summaryUpdatePayload(content: Map<String, Any?>): String =
JSONObject().put("content", JSONObject(content)).toString()
/** Payload for PATCH /recordings/{id} (title/notes rename from details). */
fun recordingPatchPayload(title: String? = null, notes: String? = null): String {
val obj = JSONObject()
if (title != null) obj.put("title", title)
if (notes != null) obj.put("notes", notes)
return obj.toString()
}
// ---- transcription models (Stage 1: GET /api/v1/models) ------------------------
data class TranscriptionModelInfo(
val name: String,
val displayName: String,
val description: String,
val params: String = "",
val approxMemory: String = "",
val relativeSpeed: String = "",
val isDefault: Boolean = false,
val downloaded: Boolean = false,
val available: Boolean = false,
)
data class ModelsData(
val defaultModel: String,
val fasterWhisperInstalled: Boolean,
val models: List<TranscriptionModelInfo> = emptyList(),
) {
fun byName(name: String): TranscriptionModelInfo? = models.firstOrNull { it.name == name }
}
fun parseModels(raw: String): ModelsData? = runCatching {
val json = JSONObject(raw)
val models = mutableListOf<TranscriptionModelInfo>()
json.optJSONArray("models")?.let { arr ->
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
models += TranscriptionModelInfo(
name = o.optString("name", ""),
displayName = o.optString("display_name", o.optString("name", "")),
description = o.optString("description", ""),
params = o.optString("params", ""),
approxMemory = o.optString("approx_memory", ""),
relativeSpeed = o.optString("relative_speed", ""),
isDefault = o.optBoolean("is_default", false),
downloaded = o.optBoolean("downloaded", false),
available = o.optBoolean("available", false),
)
}
}
ModelsData(
defaultModel = json.optString("default_model", "base"),
fasterWhisperInstalled = json.optBoolean("faster_whisper_installed", false),
models = models.filter { it.name.isNotBlank() },
)
}.getOrNull()
private fun summaryString(content: Map<String, Any?>, key: String): String =
(content[key] as? String).orEmpty()
private fun summaryList(content: Map<String, Any?>, key: String): List<String> = when (val v = content[key]) {
is JSONArray -> (0 until v.length()).mapNotNull { v.optString(it, null)?.takeIf { it.isNotBlank() } }
is List<*> -> v.mapNotNull { (it as? String)?.takeIf { it.isNotBlank() } }
else -> emptyList()
}

View file

@ -1,6 +1,5 @@
package com.shonar.recording package com.shonar.recording
import android.content.Context
import com.shonar.provider.RecordingDraft import com.shonar.provider.RecordingDraft
import com.shonar.provider.RemoteRef import com.shonar.provider.RemoteRef
import com.shonar.provider.SyncState import com.shonar.provider.SyncState
@ -11,18 +10,82 @@ import java.util.Date
import java.util.UUID import java.util.UUID
class RecordingRepository( class RecordingRepository(
private val context: Context, appFilesDir: File,
private val dao: RecordingDao, private val dao: RecordingDao,
/**
* Where new recordings go. Defaults to the app-private library; the app
* wires the active local provider's picked folder here. Cached in
* [libraryRoot] (refreshed via [refreshRoot]) so the recording service —
* which must hand MediaRecorder a path synchronously — never suspends.
*/
private val rootResolver: (suspend () -> File?)? = null,
) { ) {
private val slots = SyncSlots(dao) private val slots = SyncSlots(dao)
private val recordingsDir = File(context.filesDir, "recordings").apply { mkdirs() } private val sandboxDir: File = appFilesDir
private val tempDir = File(recordingsDir, ".in-progress").apply { mkdirs() } private val legacyDir = File(sandboxDir, "recordings").apply { mkdirs() }
@Volatile private var libraryRoot: File = legacyDir
private val recordingsDir: File get() = libraryRoot
private fun tempDir(): File = File(libraryRoot, ".in-progress").apply { mkdirs() }
val recordings: Flow<List<RecordingEntity>> = dao.observeAll() val recordings: Flow<List<RecordingEntity>> = dao.observeAll()
fun newRecordingId(): String = UUID.randomUUID().toString() fun newRecordingId(): String = UUID.randomUUID().toString()
fun tempFile(id: String): File = File(tempDir, "$id.m4a") /** Re-read the destination (call at startup and after a location change). */
suspend fun refreshRoot() {
libraryRoot = runCatching { rootResolver?.invoke() }.getOrNull() ?: legacyDir
libraryRoot.mkdirs()
importExistingFiles()
}
/**
* Adopt audio files already sitting in the library folder (put there by
* another app, USB, …). Flat scan, top level only; dotfiles and the
* in-progress dir are skipped. Files already tracked by path are left
* alone — renaming a file re-imports it as new, which is honest.
* Nothing is copied, moved, or modified.
*/
suspend fun importExistingFiles() {
val root = libraryRoot
val known = dao.getAll().mapNotNullTo(mutableSetOf()) {
runCatching { File(it.filePath).canonicalPath }.getOrNull()
}
val files = root.listFiles()
?.filter { f ->
f.isFile && !f.name.startsWith(".") &&
f.extension.lowercase() in AUDIO_MIME_BY_EXTENSION &&
runCatching { f.canonicalPath }.getOrNull()?.let { it !in known } == true
}
?.sortedBy { it.name.lowercase() }
.orEmpty()
for (f in files) {
val ext = f.extension.lowercase()
dao.insert(
RecordingEntity(
id = UUID.randomUUID().toString(),
title = f.nameWithoutExtension,
createdAtEpochMs = f.lastModified().takeIf { it > 0 }
?: System.currentTimeMillis(),
durationMs = audioDurationMs(f).coerceAtLeast(1L),
filePath = f.absolutePath,
mimeType = AUDIO_MIME_BY_EXTENSION[ext] ?: "audio/mp4",
sizeBytes = f.length(),
),
)
known += runCatching { f.canonicalPath }.getOrNull() ?: f.absolutePath
}
}
private fun audioDurationMs(f: File): Long = runCatching {
android.media.MediaMetadataRetriever().use { r ->
r.setDataSource(f.absolutePath)
r.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull() ?: 0L
}
}.getOrDefault(0L)
fun tempFile(id: String): File = File(tempDir(), "$id.m4a")
suspend fun finish( suspend fun finish(
id: String, id: String,
@ -49,10 +112,45 @@ class RecordingRepository(
) )
} }
/**
* Rename the library entry AND its audio file (same folder, extension
* kept). A colliding name gets a " (2)" suffix — nothing is ever
* overwritten. Throws on a blank/invalid name or a missing file.
*/
suspend fun rename(recording: RecordingEntity, newTitle: String): RecordingEntity {
val stem = sanitizeFileStem(newTitle)
?: throw IllegalArgumentException("Not a valid name.")
val current = File(recording.filePath)
require(current.isFile) { "Original file is gone." }
val parent = current.parentFile
?: throw IllegalArgumentException("Cannot rename here.")
val ext = current.extension.ifBlank { "m4a" }
var target = File(parent, "$stem.$ext")
var n = 1
while (target.exists() && target.canonicalPath != current.canonicalPath) {
n++
target = File(parent, "$stem ($n).$ext")
}
if (target.canonicalPath != current.canonicalPath && !current.renameTo(target)) {
throw java.io.IOException("Could not rename the file.")
}
val updated = recording.copy(
title = target.nameWithoutExtension,
filePath = target.absolutePath,
sizeBytes = target.length(),
)
dao.update(updated)
return updated
}
suspend fun delete(recording: RecordingEntity) { suspend fun delete(recording: RecordingEntity) {
val canonicalRoot = recordingsDir.canonicalFile
val canonicalFile = File(recording.filePath).canonicalFile val canonicalFile = File(recording.filePath).canonicalFile
require(canonicalFile.toPath().startsWith(canonicalRoot.toPath())) { "Invalid recording path" } // Deletable when it lives under the app sandbox or the current
// library folder (older clips may predate a location change).
val allowed = listOf(sandboxDir.canonicalFile, libraryRoot.canonicalFile)
require(allowed.any { canonicalFile.toPath().startsWith(it.toPath()) }) {
"Invalid recording path"
}
canonicalFile.delete() canonicalFile.delete()
dao.delete(recording) dao.delete(recording)
} }
@ -73,4 +171,30 @@ class RecordingRepository(
* nothing remote is touched — forgetting is metadata-only. * nothing remote is touched — forgetting is metadata-only.
*/ */
suspend fun forgetAllRemotes(newOriginId: String) = slots.forgetAllRemotes(newOriginId) suspend fun forgetAllRemotes(newOriginId: String) = slots.forgetAllRemotes(newOriginId)
companion object {
/** Extensions adopted by [importExistingFiles] (mirrors backend magic check). */
val AUDIO_MIME_BY_EXTENSION = mapOf( "m4a" to "audio/mp4",
"mp4" to "audio/mp4",
"aac" to "audio/aac",
"wav" to "audio/wav",
"ogg" to "audio/ogg",
"opus" to "audio/ogg",
"webm" to "audio/webm",
"mp3" to "audio/mpeg",
)
/**
* Clean a user-typed name into a safe single file stem, or null when
* it has nothing usable. Pure — unit-tested.
*/
fun sanitizeFileStem(raw: String): String? {
var stem = raw.trim().replace(Regex("[/\\\\]"), "_")
.replace(Regex("\\p{Cntrl}"), "")
.replace(Regex("\\s+"), " ").trim()
if (stem.isEmpty() || stem == "." || stem == "..") return null
if (stem.length > 120) stem = stem.take(120).trimEnd()
return stem.ifEmpty { null }
}
}
} }

View file

@ -0,0 +1,507 @@
package com.shonar.ui.detail
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.shonar.ShonarApplication
import com.shonar.recording.SUMMARY_LIST_KEYS
import com.shonar.recording.SummaryData
import com.shonar.recording.TranscriptData
import com.shonar.recording.activeSegmentIndex
/**
* M8 recording details: playback synced to the transcript, summary with
* action items, processing status, and editing. Everything server-side
* degrades to an honest empty state — playback and rename always work.
*/
@Composable
fun DetailsScreen(recordingId: String, onBack: () -> Unit) {
val context = LocalContext.current
val app = context.applicationContext as ShonarApplication
val vm: DetailsViewModel = viewModel(factory = DetailsViewModel.factory(app, recordingId))
val uiState by vm.state.collectAsState()
Scaffold { padding ->
Column(
Modifier.fillMaxSize().padding(padding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
when (val s = uiState) {
is DetailsViewModel.UiState.ReadyState -> TitleRow(
title = s.ready.recording.title,
onRename = vm::rename,
)
else -> Text("Details", style = MaterialTheme.typography.titleLarge)
}
}
when (val s = uiState) {
DetailsViewModel.UiState.Loading -> LoadingRow()
is DetailsViewModel.UiState.Error -> Text(
s.message, color = MaterialTheme.colorScheme.error
)
is DetailsViewModel.UiState.ReadyState -> ReadyBody(vm = vm, ready = s.ready)
}
}
}
}
@Composable
private fun RowScope.TitleRow(title: String, onRename: (String) -> Unit) {
var editing by remember { mutableStateOf(false) }
var draft by remember(title) { mutableStateOf(title) }
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(
title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { editing = true }) {
Icon(Icons.Filled.Edit, contentDescription = "Rename")
}
}
if (editing) {
AlertDialog(
onDismissRequest = { editing = false },
title = { Text("Rename recording") },
text = {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(onClick = { onRename(draft); editing = false }) { Text("Save") }
},
dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun ColumnScope.ReadyBody(vm: DetailsViewModel, ready: DetailsViewModel.Ready) {
var tab by remember { mutableIntStateOf(0) }
PlaybackCard(ready = ready, onToggle = vm::togglePlayback,
onSeek = vm::seekTo, onSpeed = vm::cycleSpeed)
ready.playbackError?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
TabRow(selectedTabIndex = tab) {
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Transcript") })
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Summary") })
Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Status") })
}
when (tab) {
0 -> TranscriptTab(
transcript = ready.transcript,
ai = ready.ai,
positionMs = ready.positionMs,
canEdit = ready.remoteId != null,
busy = ready.busy,
saveError = ready.saveError,
onSeek = vm::seekTo,
onSave = vm::saveTranscript,
onDismissError = vm::dismissSaveError,
)
1 -> SummaryTab(
summary = ready.summary,
ai = ready.ai,
canEdit = ready.remoteId != null,
busy = ready.busy,
saveError = ready.saveError,
onSave = vm::saveSummary,
onDismissError = vm::dismissSaveError,
)
else -> StatusTab(ready = ready)
}
}
@Composable
private fun PlaybackCard(
ready: DetailsViewModel.Ready,
onToggle: () -> Unit,
onSeek: (Long) -> Unit,
onSpeed: () -> Unit,
) {
Card(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onToggle) {
Icon(
if (ready.playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (ready.playing) "Pause" else "Play",
)
}
Column(Modifier.weight(1f)) {
Slider(
value = ready.positionMs.toFloat(),
onValueChange = { onSeek(it.toLong()) },
valueRange = 0f..ready.durationMs.coerceAtLeast(1).toFloat(),
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(formatMs(ready.positionMs), style = MaterialTheme.typography.bodySmall)
Text(formatMs(ready.durationMs), style = MaterialTheme.typography.bodySmall)
}
}
TextButton(onClick = onSpeed) { Text("${ready.speed}×") }
}
}
}
@Composable
private fun ColumnScope.TranscriptTab(
transcript: TranscriptData?,
ai: DetailsViewModel.AiState,
positionMs: Long,
canEdit: Boolean,
busy: String?,
saveError: String?,
onSeek: (Long) -> Unit,
onSave: (String) -> Unit,
onDismissError: () -> Unit,
) {
var editing by remember { mutableStateOf(false) }
when {
transcript == null -> AiEmptyState(ai, what = "transcript")
transcript.segments.isEmpty() -> {
Text(transcript.text.ifBlank { "Transcript is empty." })
EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true })
}
else -> {
val active = activeSegmentIndex(transcript.segments, positionMs)
val listState = rememberLazyListState()
LaunchedEffect(active) {
if (active >= 0) listState.animateScrollToItem(active)
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
(if (transcript.editedByUser) "Edited by you" else "Auto transcript") +
" · v${transcript.version}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true })
}
LazyColumn(
Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
itemsIndexed(transcript.segments, key = { i, s -> "$i-${s.startSec}" }) { i, seg ->
val isActive = i == active
Card(
colors = if (isActive) {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
} else {
CardDefaults.cardColors()
},
modifier = Modifier.fillMaxWidth()
.clickable { onSeek((seg.startSec * 1000).toLong()) },
) {
Column(Modifier.padding(10.dp)) {
Text(
formatMs((seg.startSec * 1000).toLong()) +
(seg.speaker?.let { " · $it" } ?: ""),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(seg.text, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
}
saveError?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.clickable { onDismissError() })
}
if (editing && transcript != null) {
var draft by remember(transcript.version) { mutableStateOf(transcript.text) }
AlertDialog(
onDismissRequest = { editing = false },
title = { Text("Edit transcript") },
text = {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.fillMaxWidth().height(220.dp),
)
},
confirmButton = {
TextButton(
onClick = { onSave(draft); editing = false },
enabled = draft.isNotBlank(),
) { Text("Save") }
},
dismissButton = { TextButton(onClick = { editing = false }) { Text("Cancel") } },
)
}
}
@Composable
private fun ColumnScope.SummaryTab(
summary: SummaryData?,
ai: DetailsViewModel.AiState,
canEdit: Boolean,
busy: String?,
saveError: String?,
onSave: (Map<String, Any?>) -> Unit,
onDismissError: () -> Unit,
) {
var editing by remember { mutableStateOf(false) }
if (summary == null) {
AiEmptyState(ai, what = "summary")
return
}
LazyColumn(
Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
(if (summary.editedByUser) "Edited by you" else "AI summary") +
" · v${summary.version}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
EditRow(canEdit = canEdit, busy = busy, onEdit = { editing = true })
}
}
if (summary.short.isNotBlank()) {
item {
SummarySection(title = "Summary", body = summary.short)
}
}
if (summary.detailed.isNotBlank()) {
item {
SummarySection(title = "Details", body = summary.detailed)
}
}
SUMMARY_LIST_KEYS.forEach { key ->
val items = summary.list(key)
if (items.isNotEmpty()) {
item {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
key.replace('_', ' ').replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.titleSmall,
)
items.forEach { Bullet(it) }
}
}
}
}
}
saveError?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.clickable { onDismissError() })
}
if (editing) {
SummaryEditDialog(summary = summary, onDismiss = { editing = false }, onSave = {
onSave(it); editing = false
})
}
}
@Composable
private fun SummaryEditDialog(
summary: SummaryData,
onDismiss: () -> Unit,
onSave: (Map<String, Any?>) -> Unit,
) {
var short by remember { mutableStateOf(summary.short) }
var detailed by remember { mutableStateOf(summary.detailed) }
var actionItems by remember { mutableStateOf(summary.list("action_items").joinToString("\n")) }
var keyPoints by remember { mutableStateOf(summary.list("key_points").joinToString("\n")) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit summary") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = short, onValueChange = { short = it },
label = { Text("Short") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = detailed, onValueChange = { detailed = it },
label = { Text("Detailed") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = actionItems, onValueChange = { actionItems = it },
label = { Text("Action items (one per line)") },
modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = keyPoints, onValueChange = { keyPoints = it },
label = { Text("Key points (one per line)") },
modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
TextButton(onClick = {
val content = summary.content.toMutableMap()
content["short"] = short
content["detailed"] = detailed
content["action_items"] = linesOf(actionItems)
content["key_points"] = linesOf(keyPoints)
onSave(content)
}) { Text("Save") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
private fun linesOf(raw: String): List<String> =
raw.lines().map { it.trim() }.filter { it.isNotEmpty() }
@Composable
private fun StatusTab(ready: DetailsViewModel.Ready) {
val jobs = (ready.ai as? DetailsViewModel.AiState.Ready)?.jobs.orEmpty()
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Sync: ${ready.recording.syncState}" +
(ready.recording.syncReason?.let { " ($it)" } ?: ""))
if (jobs.isEmpty()) {
Text("No processing jobs reported.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
jobs.forEach { job ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp)) {
Text("${job.jobType}: ${job.status}",
style = MaterialTheme.typography.titleSmall)
if (job.error != null) {
Text(job.error, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error)
} else {
Text("attempt ${job.attempt}/${job.maxAttempts}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
}
@Composable
private fun AiEmptyState(ai: DetailsViewModel.AiState, what: String) {
when (ai) {
DetailsViewModel.AiState.Loading -> LoadingRow()
is DetailsViewModel.AiState.Unavailable -> Text(
ai.reason.ifBlank { "No $what yet." },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
is DetailsViewModel.AiState.Error -> Text(
ai.message,
color = MaterialTheme.colorScheme.error,
)
is DetailsViewModel.AiState.Ready -> Text(
"No $what yet — processing may still be running. Check Status.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(4.dp))
}
@Composable
private fun EditRow(canEdit: Boolean, busy: String?, onEdit: () -> Unit) {
if (!canEdit) return
if (busy != null) {
Text(busy, style = MaterialTheme.typography.labelMedium)
} else {
TextButton(onClick = onEdit) { Text("Edit") }
}
}
@Composable
private fun SummarySection(title: String, body: String) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.titleSmall)
Text(body, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun Bullet(text: String) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("•", style = MaterialTheme.typography.bodyMedium)
Text(text, style = MaterialTheme.typography.bodyMedium)
}
}
@Composable
private fun LoadingRow() {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) { CircularProgressIndicator() }
}
private fun formatMs(ms: Long): String {
val total = (ms / 1000).coerceAtLeast(0)
return "%d:%02d".format(total / 60, total % 60)
}

View file

@ -0,0 +1,356 @@
package com.shonar.ui.detail
import android.media.MediaPlayer
import android.media.PlaybackParams
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.shonar.ShonarApplication
import com.shonar.provider.AuthState
import com.shonar.provider.CustomShonarProvider
import com.shonar.provider.ProviderError
import com.shonar.provider.ProviderRegistry
import com.shonar.recording.JobInfo
import com.shonar.recording.RecordingEntity
import com.shonar.recording.SummaryData
import com.shonar.recording.TranscriptData
import com.shonar.recording.parseJobs
import com.shonar.recording.parseSummary
import com.shonar.recording.parseTranscript
import com.shonar.recording.recordingPatchPayload
import com.shonar.recording.summaryUpdatePayload
import com.shonar.recording.transcriptUpdatePayload
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* M8 details screen: local playback (seek + speed) with the server transcript
* synced to the playhead, summary/action items, processing jobs, and editing.
*
* AI content lives on the custom SHONAR server (M7 endpoints) and is
* addressable only after upload — the local row's [RecordingEntity.remoteKey]
* is the server recording id. Anything else (local-only, not yet synced,
* other providers) gets an honest [AiState.Unavailable] instead of a
* spinner: playback and title/notes editing always work offline.
*/
class DetailsViewModel(
private val app: ShonarApplication,
private val recordingId: String,
) : ViewModel() {
sealed interface AiState {
data object Loading : AiState
data class Ready(val jobs: List<JobInfo>) : AiState
/** No transcript/summary yet, or AI not applicable here. */
data class Unavailable(val reason: String) : AiState
data class Error(val message: String) : AiState
}
data class Ready(
val recording: RecordingEntity,
val playing: Boolean = false,
val positionMs: Long = 0,
val durationMs: Long = 0,
val speed: Float = 1f,
val playbackError: String? = null,
val transcript: TranscriptData? = null,
val summary: SummaryData? = null,
val ai: AiState = AiState.Loading,
/** Null remote id = editing goes to Room only; non-null = also PUT/PATCH server. */
val remoteId: String? = null,
val busy: String? = null,
val saveError: String? = null,
)
sealed interface UiState {
data object Loading : UiState
data class Error(val message: String) : UiState
data class ReadyState(val ready: Ready) : UiState
}
private val dao = app.database.recordingDao()
private val registry = app.providerRegistry
private val settings = app.settingsManager
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
private var player: MediaPlayer? = null
private var pollJob: Job? = null
companion object {
val SPEEDS = listOf(0.75f, 1f, 1.25f, 1.5f, 2f)
fun factory(app: ShonarApplication, recordingId: String): ViewModelProvider.Factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
DetailsViewModel(app, recordingId) as T
}
}
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
_state.value = UiState.Loading
val entity = dao.getById(recordingId)
if (entity == null) {
_state.value = UiState.Error("Recording not found.")
return@launch
}
val ready = Ready(recording = entity, durationMs = entity.durationMs)
_state.value = UiState.ReadyState(ready)
loadAi(entity)
}
}
// ---- AI content ----------------------------------------------------------
private suspend fun loadAi(entity: RecordingEntity) {
val cur = current() ?: return
if (entity.originProviderId != ProviderRegistry.CUSTOM_SHONAR_ID || entity.remoteKey == null) {
val reason = if (entity.remoteKey == null &&
entity.originProviderId == ProviderRegistry.CUSTOM_SHONAR_ID
) {
"Not uploaded yet — transcript and summary appear after sync + processing."
} else {
"AI transcript and summary live on a SHONAR server — " +
"connect one and sync this recording to see them here."
}
_state.value = UiState.ReadyState(cur.copy(ai = AiState.Unavailable(reason)))
return
}
val provider = registry.provider(entity.originProviderId) as? CustomShonarProvider
if (provider == null) {
_state.value = UiState.ReadyState(
cur.copy(ai = AiState.Unavailable("This provider has no AI content."))
)
return
}
// Best effort: stored tokens may have expired while the app was away.
if (provider.authState.value != AuthState.CONNECTED) {
runCatching { provider.reconnect() }
}
if (provider.authState.value != AuthState.CONNECTED) {
_state.value = UiState.ReadyState(
cur.copy(ai = AiState.Error("Not connected — reconnect, then reopen this recording."))
)
return
}
val remoteId = entity.remoteKey
val transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) }
.getOrElse { return setAiError(cur, "Could not load transcript.") }
val summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) }
.getOrElse { return setAiError(cur, "Could not load summary.") }
val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrDefault(emptyList())
_state.value = UiState.ReadyState(
cur.copy(
transcript = transcript,
summary = summary,
ai = AiState.Ready(jobs),
remoteId = remoteId,
)
)
}
private fun setAiError(cur: Ready, message: String) {
_state.value = UiState.ReadyState(cur.copy(ai = AiState.Error(message)))
}
fun saveTranscript(text: String) {
val cur = current() ?: return
val remoteId = cur.remoteId ?: return
val provider = registry.provider(cur.recording.originProviderId) as? CustomShonarProvider
?: return
viewModelScope.launch {
_state.value = UiState.ReadyState(cur.copy(busy = "Saving transcript…", saveError = null))
val result = runCatching {
provider.updateTranscript(remoteId, transcriptUpdatePayload(text.trim()))
}
val parsed = result.getOrNull()?.let { parseTranscript(it) }
if (parsed != null) {
_state.value = UiState.ReadyState(current()!!.copy(transcript = parsed, busy = null))
} else {
_state.value = UiState.ReadyState(
current()!!.copy(busy = null, saveError = "Save failed — kept your text, try again.")
)
}
}
}
fun saveSummary(content: Map<String, Any?>) {
val cur = current() ?: return
val remoteId = cur.remoteId ?: return
val provider = registry.provider(cur.recording.originProviderId) as? CustomShonarProvider
?: return
viewModelScope.launch {
_state.value = UiState.ReadyState(cur.copy(busy = "Saving summary…", saveError = null))
val result = runCatching {
provider.updateSummary(remoteId, summaryUpdatePayload(content))
}
val parsed = result.getOrNull()?.let { parseSummary(it) }
if (parsed != null) {
_state.value = UiState.ReadyState(current()!!.copy(summary = parsed, busy = null))
} else {
_state.value = UiState.ReadyState(
current()!!.copy(busy = null, saveError = "Save failed — try again.")
)
}
}
}
fun rename(title: String) {
val cur = current() ?: return
val trimmed = title.trim()
if (trimmed.isEmpty() || trimmed == cur.recording.title) return
viewModelScope.launch {
// Renames the library entry AND its audio file.
val updated = runCatching {
app.recordingRepository.rename(cur.recording, trimmed)
}.getOrElse {
_state.value = UiState.ReadyState(
cur.copy(saveError = it.message ?: "Rename failed.")
)
return@launch
}
_state.value = UiState.ReadyState(
(current() ?: cur).copy(recording = updated)
)
val remoteId = (current() ?: cur).remoteId ?: return@launch
val provider = registry.provider(updated.originProviderId) as? CustomShonarProvider
?: return@launch
runCatching {
provider.patchRecording(remoteId, recordingPatchPayload(title = updated.title))
}
}
}
fun dismissSaveError() {
val cur = current() ?: return
_state.value = UiState.ReadyState(cur.copy(saveError = null))
}
// ---- playback ------------------------------------------------------------
fun togglePlayback() {
val cur = current() ?: return
val p = player
if (p != null) {
if (p.isPlaying) {
p.pause()
stopPolling()
_state.value = UiState.ReadyState(cur.copy(playing = false, positionMs = p.currentPosition.toLong()))
} else {
applySpeed(p, cur.speed)
p.start()
startPolling()
_state.value = UiState.ReadyState(cur.copy(playing = true))
}
return
}
viewModelScope.launch {
val fresh = (current() ?: return@launch).copy(playbackError = null)
_state.value = UiState.ReadyState(fresh)
val mp = runCatching {
MediaPlayer().apply {
setDataSource(fresh.recording.filePath)
setOnPreparedListener { prepared ->
applySpeed(prepared, fresh.speed)
prepared.start()
startPolling()
_state.value = UiState.ReadyState(
(current() ?: fresh).copy(
playing = true,
durationMs = prepared.duration.toLong().coerceAtLeast(1),
)
)
}
setOnCompletionListener {
stopPolling()
_state.value = UiState.ReadyState(
(current() ?: fresh).copy(playing = false, positionMs = 0)
)
it.seekTo(0)
}
setOnErrorListener { _, _, _ ->
stopPolling()
_state.value = UiState.ReadyState(
(current() ?: fresh).copy(
playing = false,
playbackError = "Could not play this file.",
)
)
true
}
prepareAsync()
}
}.getOrNull()
if (mp == null) {
_state.value = UiState.ReadyState(fresh.copy(playbackError = "Could not play this file."))
} else {
player = mp
}
}
}
fun seekTo(positionMs: Long) {
val cur = current() ?: return
val duration = cur.durationMs.coerceAtLeast(1)
val clamped = positionMs.coerceIn(0, duration)
player?.seekTo(clamped.toInt())
_state.value = UiState.ReadyState(cur.copy(positionMs = clamped))
}
fun cycleSpeed() {
val cur = current() ?: return
val next = SPEEDS[(SPEEDS.indexOf(cur.speed) + 1).coerceAtLeast(0) % SPEEDS.size]
player?.let { applySpeed(it, next) }
_state.value = UiState.ReadyState(cur.copy(speed = next))
}
private fun applySpeed(p: MediaPlayer, speed: Float) {
runCatching {
p.playbackParams = (p.playbackParams ?: PlaybackParams()).apply { this.speed = speed }
}
}
private fun startPolling() {
pollJob?.cancel()
pollJob = viewModelScope.launch {
while (true) {
delay(250)
val p = player ?: break
if (!p.isPlaying) break
val cur = current() ?: break
_state.value = UiState.ReadyState(
cur.copy(
positionMs = p.currentPosition.toLong(),
durationMs = runCatching { p.duration.toLong() }
.getOrDefault(cur.durationMs).coerceAtLeast(1),
)
)
}
}
}
private fun stopPolling() {
pollJob?.cancel()
pollJob = null
}
private fun current(): Ready? = (_state.value as? UiState.ReadyState)?.ready
override fun onCleared() {
stopPolling()
player?.release()
player = null
super.onCleared()
}
}

View file

@ -0,0 +1,223 @@
package com.shonar.ui.folder
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import java.io.File
/**
* Pick one of your own folders: browse shared storage, create a folder
* where you want it, and hand the path back. The caller (provider setup)
* still runs its own validation — the browser never writes recordings.
*/
@Composable
fun FolderBrowserScreen(
onPick: (String) -> Unit,
onBack: () -> Unit,
) {
val context = LocalContext.current
val lifecycle = LocalLifecycleOwner.current.lifecycle
fun hasAccess(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED
}
var access by remember { mutableStateOf(hasAccess()) }
// Re-check when returning from the Settings grant screen.
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) access = hasAccess()
}
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
val settingsLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { access = hasAccess() }
val legacyPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> if (granted) access = true }
fun requestAccess() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
settingsLauncher.launch(
Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:${context.packageName}"),
)
)
} else {
legacyPermission.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
Scaffold { padding ->
Column(
Modifier.fillMaxSize().padding(padding).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text("Choose a folder", style = MaterialTheme.typography.titleLarge)
}
if (!access) {
Text(
"SHONAR needs file access so you can browse your own " +
"folders. Only the folder you pick is ever used.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = ::requestAccess, modifier = Modifier.fillMaxWidth()) {
Text("Grant file access")
}
} else {
BrowserBody(onPick = onPick)
}
}
}
}
@Composable
private fun ColumnScope.BrowserBody(onPick: (String) -> Unit) {
val start = Environment.getExternalStorageDirectory()?.path ?: "/storage/emulated/0"
var current by remember { mutableStateOf(File(start)) }
var entries by remember { mutableStateOf(listChildDirs(current)) }
var error by remember { mutableStateOf<String?>(null) }
var showCreate by remember { mutableStateOf(false) }
fun reload(dir: File = current) {
current = dir
error = null
entries = listChildDirs(dir)
if (!dir.canRead()) error = "Cannot read this folder."
}
Text(
current.absolutePath,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { current.parentFile?.let { reload(it) } },
enabled = current.parentFile != null,
modifier = Modifier.weight(1f),
) { Text("Up") }
OutlinedButton(
onClick = { showCreate = true },
modifier = Modifier.weight(1f),
) {
Icon(Icons.Filled.CreateNewFolder, contentDescription = null)
Text(" New folder")
}
}
error?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
LazyColumn(
Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(entries, key = { it.path }) { entry ->
Row(
Modifier.fillMaxWidth().padding(vertical = 10.dp)
.clickable { reload(File(entry.path)) },
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Folder, contentDescription = null,
tint = MaterialTheme.colorScheme.primary)
Text(entry.name, modifier = Modifier.padding(start = 12.dp).weight(1f))
Icon(Icons.AutoMirrored.Filled.ArrowForward, contentDescription = "Open")
}
}
}
Button(
onClick = { onPick(current.absolutePath) },
modifier = Modifier.fillMaxWidth(),
) { Text("Use this folder") }
if (showCreate) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showCreate = false },
title = { Text("New folder") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Inside:\n${current.absolutePath}",
style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Folder name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = {
createChildDir(current, name)
.onSuccess { reload(it) }
.onFailure { error = it.message }
if (error == null) showCreate = false
},
enabled = isValidFolderName(name),
) { Text("Create") }
},
dismissButton = { TextButton(onClick = { showCreate = false }) { Text("Cancel") } },
)
}
}

View file

@ -0,0 +1,48 @@
package com.shonar.ui.folder
import java.io.File
/**
* Pure filesystem helpers for the in-app folder browser. No Android
* imports — unit-testable on the JVM.
*/
data class DirEntry(val name: String, val path: String)
/** Visible child folders of [dir], sorted case-insensitively. Empty when unreadable. */
fun listChildDirs(dir: File): List<DirEntry> = runCatching {
if (!dir.isDirectory || !dir.canRead()) return emptyList()
dir.listFiles()
?.filter { it.isDirectory && !it.name.startsWith(".") }
?.sortedBy { it.name.lowercase() }
?.map { DirEntry(it.name, it.absolutePath) }
.orEmpty()
}.getOrDefault(emptyList())
/** True when [name] is safe to create as a single folder segment. */
fun isValidFolderName(name: String): Boolean {
val t = name.trim()
return t.isNotEmpty() && '/' !in t && '\\' !in t && t != "." && t != ".."
}
/**
* Create [name] inside [parent]. Returns the new dir, or null with a
* human-readable [reason] when it refuses (bad name, missing parent,
* already-exists-as-file, mkdir failure).
*/
fun createChildDir(parent: File, name: String): Result<File> {
val trimmed = name.trim()
if (!isValidFolderName(trimmed)) {
return Result.failure(IllegalArgumentException("Not a valid folder name."))
}
if (!parent.isDirectory) {
return Result.failure(IllegalArgumentException("Parent is not a folder."))
}
val child = File(parent, trimmed)
if (child.exists()) {
return if (child.isDirectory) Result.success(child)
else Result.failure(IllegalArgumentException("A file with that name already exists."))
}
return if (child.mkdir()) Result.success(child)
else Result.failure(IllegalArgumentException("Could not create the folder."))
}

View file

@ -14,6 +14,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -21,6 +22,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -55,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
fun HomeScreen( fun HomeScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenStorage: () -> Unit, onOpenStorage: () -> Unit,
onOpenDetail: (String) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
val app = context.applicationContext as ShonarApplication val app = context.applicationContext as ShonarApplication
@ -62,9 +65,22 @@ fun HomeScreen(
val recorderState by vm.recorderState.collectAsState() val recorderState by vm.recorderState.collectAsState()
val recordings by vm.recordings.collectAsState() val recordings by vm.recordings.collectAsState()
val playingId by vm.playingId.collectAsState() val playingId by vm.playingId.collectAsState()
val notice by vm.notice.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var consentSeen by remember { mutableStateOf<Boolean?>(null) } var consentSeen by remember { mutableStateOf<Boolean?>(null) }
var storageHeadline by remember { mutableStateOf<String?>(null) } var storageHeadline by remember { mutableStateOf<String?>(null) }
var storagePath by remember { mutableStateOf<String?>(null) }
var headlineTick by remember { mutableStateOf(0) }
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
// Returning from the folder picker resumes this screen without
// recomposition — re-read the headline so it shows the picked folder.
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
val obs = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) headlineTick++
}
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
val permissionLauncher = rememberLauncherForActivityResult( val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { granted -> if (granted) vm.start() } ) { granted -> if (granted) vm.start() }
@ -79,13 +95,20 @@ fun HomeScreen(
} }
} }
LaunchedEffect(Unit) { LaunchedEffect(headlineTick) {
app.settingsManager.ensureLoaded() app.settingsManager.ensureLoaded()
consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT) consentSeen = app.settingsManager.bool(BuiltInSettings.CONSENT)
val pid = app.settingsManager.string(BuiltInSettings.PROVIDER_ID) // Adopt files added outside the app (USB, downloads…) into the library.
runCatching { app.recordingRepository.refreshRoot() }
val pid = app.settingsManager.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" }
val purl = app.settingsManager.string(BuiltInSettings.PROVIDER_URL) val purl = app.settingsManager.string(BuiltInSettings.PROVIDER_URL)
storageHeadline = com.shonar.ui.provider.ProviderSelectionViewModel // Live summary first (shows the picked folder); static label as fallback.
val summary = runCatching {
app.providerRegistry.provider(pid).storageLocationSummary()
}.getOrNull()
storageHeadline = summary?.headline ?: com.shonar.ui.provider.ProviderSelectionViewModel
.headlineFor(pid.ifBlank { "local-only" }, purl) .headlineFor(pid.ifBlank { "local-only" }, purl)
storagePath = summary?.path
} }
Scaffold( Scaffold(
@ -126,7 +149,7 @@ fun HomeScreen(
StorageChip(headline = headline, onClick = onOpenStorage) StorageChip(headline = headline, onClick = onOpenStorage)
} }
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
QuickCard("Choose storage", storageHeadline ?: "Where recordings live", QuickCard("Choose storage", storagePath ?: storageHeadline ?: "Where recordings live",
Icons.Filled.Cloud, onOpenStorage) Icons.Filled.Cloud, onOpenStorage)
QuickCard("Settings", "Appearance, custom settings", QuickCard("Settings", "Appearance, custom settings",
Icons.Filled.Settings, onOpenSettings) Icons.Filled.Settings, onOpenSettings)
@ -141,12 +164,19 @@ fun HomeScreen(
if (recordings.isNotEmpty()) { if (recordings.isNotEmpty()) {
Text("Your recordings", style = MaterialTheme.typography.titleLarge, Text("Your recordings", style = MaterialTheme.typography.titleLarge,
modifier = Modifier.align(Alignment.Start)) modifier = Modifier.align(Alignment.Start))
notice?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.align(Alignment.Start))
}
recordings.forEach { recording -> recordings.forEach { recording ->
RecordingRow( RecordingRow(
recording = recording, recording = recording,
playing = playingId == recording.id, playing = playingId == recording.id,
onPlay = { vm.togglePlayback(recording) }, onPlay = { vm.togglePlayback(recording) },
onDelete = { vm.delete(recording) }, onDelete = { vm.delete(recording) },
onOpen = { onOpenDetail(recording.id) },
onRename = { vm.rename(recording, it) },
) )
} }
} else { } else {
@ -199,8 +229,12 @@ private fun RecordingRow(
playing: Boolean, playing: Boolean,
onPlay: () -> Unit, onPlay: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onOpen: () -> Unit,
onRename: (String) -> Unit,
) { ) {
Card(Modifier.fillMaxWidth()) { var menu by remember { mutableStateOf(false) }
var renaming by remember { mutableStateOf(false) }
Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) {
Row( Row(
Modifier.fillMaxWidth().padding(14.dp), Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -214,9 +248,56 @@ private fun RecordingRow(
) )
} }
TextButton(onClick = onPlay) { Text(if (playing) "Pause" else "Play") } TextButton(onClick = onPlay) { Text(if (playing) "Pause" else "Play") }
TextButton(onClick = onDelete) { Text("Delete", color = MaterialTheme.colorScheme.error) } androidx.compose.material3.IconButton(onClick = { menu = true }) {
androidx.compose.material3.Icon(
androidx.compose.material.icons.Icons.Filled.MoreVert,
contentDescription = "More actions",
)
}
androidx.compose.material3.DropdownMenu(
expanded = menu,
onDismissRequest = { menu = false },
) {
androidx.compose.material3.DropdownMenuItem(
text = { Text("Rename file") },
onClick = { menu = false; renaming = true },
)
androidx.compose.material3.DropdownMenuItem(
text = { Text("Delete", color = MaterialTheme.colorScheme.error) },
onClick = { menu = false; onDelete() },
)
}
} }
} }
if (renaming) {
var draft by remember(recording.id) { mutableStateOf(recording.title) }
AlertDialog(
onDismissRequest = { renaming = false },
title = { Text("Rename file") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Text(
"Renames the audio file too — same folder, same format.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(
onClick = { onRename(draft); renaming = false },
enabled = draft.isNotBlank(),
) { Text("Rename") }
},
dismissButton = { TextButton(onClick = { renaming = false }) { Text("Cancel") } },
)
}
} }
private fun formatDuration(durationMs: Long): String { private fun formatDuration(durationMs: Long): String {

View file

@ -20,6 +20,9 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
val recorderState = RecordingService.snapshot val recorderState = RecordingService.snapshot
private val _playingId = kotlinx.coroutines.flow.MutableStateFlow<String?>(null) private val _playingId = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
val playingId: StateFlow<String?> = _playingId val playingId: StateFlow<String?> = _playingId
private val _notice = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
/** Transient rename/delete failures surface here; cleared on next action. */
val notice: StateFlow<String?> = _notice
private var player: MediaPlayer? = null private var player: MediaPlayer? = null
fun start() = RecordingService.command(app, RecordingService.ACTION_START) fun start() = RecordingService.command(app, RecordingService.ACTION_START)
@ -44,8 +47,20 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
} }
fun delete(recording: RecordingEntity) { fun delete(recording: RecordingEntity) {
_notice.value = null
if (_playingId.value == recording.id) stopPlayback() if (_playingId.value == recording.id) stopPlayback()
viewModelScope.launch { app.recordingRepository.delete(recording) } viewModelScope.launch {
runCatching { app.recordingRepository.delete(recording) }
.onFailure { _notice.value = it.message ?: "Delete failed." }
}
}
fun rename(recording: RecordingEntity, newTitle: String) {
_notice.value = null
viewModelScope.launch {
runCatching { app.recordingRepository.rename(recording, newTitle) }
.onFailure { _notice.value = it.message ?: "Rename failed." }
}
} }
private fun stopPlayback() { private fun stopPlayback() {

View file

@ -22,6 +22,7 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -29,6 +30,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -54,12 +56,35 @@ fun ProviderSelectionScreen(
onDone: () -> Unit, onDone: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
vm: ProviderSelectionViewModel = viewModel(factory = ProviderSelectionViewModel.Factory), vm: ProviderSelectionViewModel = viewModel(factory = ProviderSelectionViewModel.Factory),
browsePick: String? = null,
onPickConsumed: () -> Unit = {},
onBrowse: () -> Unit = {},
) { ) {
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
var selectedId by remember { mutableStateOf<String?>(null) } var selectedId by remember { mutableStateOf<String?>(null) }
var urlInput by remember { mutableStateOf("") } var urlInput by remember { mutableStateOf("") }
var pathInput by remember { mutableStateOf("") } var pathInput by remember { mutableStateOf("") }
var confirmCreate by remember { mutableStateOf(false) }
var showAdvanced by remember { mutableStateOf(false) }
// A path picked in the folder browser saves immediately — the browse
// choice IS the confirmation, no second tap needed. With no card
// tapped, the pick means on-this-phone (device storage is the only
// non-advanced target).
LaunchedEffect(browsePick) {
if (browsePick != null) {
pathInput = browsePick
val localId = com.shonar.provider.LocalOnlyProvider.ID
val target = vm.options.firstOrNull { it.id == selectedId }
?: vm.options.firstOrNull { it.id == localId }
if (target != null && target.needsPath && target.available) {
selectedId = target.id
vm.connectFolder(browsePick, target.id)
}
onPickConsumed()
}
}
Scaffold( Scaffold(
topBar = { topBar = {
@ -79,14 +104,14 @@ fun ProviderSelectionScreen(
) { ) {
item { item {
Text( Text(
"You stay in control: recordings, transcripts, and summaries go where " + "Recordings stay on this phone, in your own folder if you " +
"you choose. You can switch providers later without losing anything.", "like. You can switch later without losing anything.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp), modifier = Modifier.padding(top = 8.dp),
) )
} }
items(vm.options) { opt -> items(vm.simpleOptions) { opt ->
ProviderCard( ProviderCard(
option = opt, option = opt,
selected = selectedId == opt.id, selected = selectedId == opt.id,
@ -95,11 +120,40 @@ fun ProviderSelectionScreen(
if (!opt.available) { if (!opt.available) {
// surface the honest reason immediately // surface the honest reason immediately
vm.choose(opt, "") vm.choose(opt, "")
} else if (opt.allowsBlankPath) {
// Tapping is the choice: persist the default now.
// Picking a folder below upgrades it.
vm.choose(opt, "")
} }
}, },
) )
} }
val sel = vm.options.firstOrNull { it.id == selectedId } val sel = vm.options.firstOrNull { it.id == selectedId }
item {
TextButton(
onClick = { showAdvanced = !showAdvanced },
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (showAdvanced) "Hide self-hosted servers"
else "Self-hosted servers (advanced)"
)
}
}
if (showAdvanced) {
items(vm.advancedOptions) { opt ->
ProviderCard(
option = opt,
selected = selectedId == opt.id,
onSelect = {
selectedId = opt.id
if (!opt.available) {
vm.choose(opt, "")
}
},
)
}
}
if (sel != null && sel.available && sel.needsUrl) { if (sel != null && sel.available && sel.needsUrl) {
item { item {
OutlinedTextField( OutlinedTextField(
@ -123,17 +177,56 @@ fun ProviderSelectionScreen(
OutlinedTextField( OutlinedTextField(
value = pathInput, value = pathInput,
onValueChange = { pathInput = it }, onValueChange = { pathInput = it },
label = { Text("Folder path (created by your sync tool)") }, label = { Text("Folder path (or browse below)") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
item {
OutlinedButton(
onClick = onBrowse,
modifier = Modifier.fillMaxWidth(),
) { Text("Browse my folders…") }
}
item { item {
Button( Button(
onClick = { vm.connectFolder(pathInput) }, onClick = { vm.connectFolder(pathInput, sel.id) },
enabled = state !is SelectionUiState.Probing &&
(pathInput.isNotBlank() || sel.allowsBlankPath),
modifier = Modifier.fillMaxWidth(),
) { Text(if (pathInput.isBlank()) "Use default folder" else "Use this folder") }
}
item {
OutlinedButton(
onClick = { confirmCreate = true },
enabled = state !is SelectionUiState.Probing && pathInput.isNotBlank(), enabled = state !is SelectionUiState.Probing && pathInput.isNotBlank(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Use this folder") } ) { Text("Create folder") }
}
if (confirmCreate) {
item {
AlertDialog(
onDismissRequest = { confirmCreate = false },
title = { Text("Create folder?") },
text = {
Text(
"This creates:\n${pathInput.trim()}\n\n" +
"Only the last part is created — its parent " +
"must already exist. Nothing else on your " +
"device is touched."
)
},
confirmButton = {
TextButton(onClick = {
confirmCreate = false
vm.createFolder(pathInput, sel.id)
}) { Text("Create") }
},
dismissButton = {
TextButton(onClick = { confirmCreate = false }) { Text("Cancel") }
},
)
}
} }
} }
if (state is SelectionUiState.NeedsNcApproval) { if (state is SelectionUiState.NeedsNcApproval) {

View file

@ -24,8 +24,13 @@ data class ProviderOption(
val blurb: String, val blurb: String,
val needsUrl: Boolean, val needsUrl: Boolean,
val available: Boolean, // false -> disabled with "coming soon" val available: Boolean, // false -> disabled with "coming soon"
val needsPath: Boolean = false, // sync-folder style: a local directory path val needsPath: Boolean = false, // local folder choice: a directory path
) val advanced: Boolean = false, // self-hosted servers, hidden by default
) {
/** On-this-phone accepts a blank path (app-private default folder). */
val allowsBlankPath: Boolean
get() = id == com.shonar.provider.LocalOnlyProvider.ID && !needsUrl
}
sealed interface SelectionUiState { sealed interface SelectionUiState {
data object Idle : SelectionUiState data object Idle : SelectionUiState
@ -69,9 +74,6 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
private val nextcloudProvider: com.shonar.provider.ShonarProvider by lazy { private val nextcloudProvider: com.shonar.provider.ShonarProvider by lazy {
app.providerRegistry.provider(ProviderRegistry.NEXTCLOUD_ID) app.providerRegistry.provider(ProviderRegistry.NEXTCLOUD_ID)
} }
private val folderProvider: com.shonar.provider.ShonarProvider by lazy {
app.providerRegistry.provider(ProviderRegistry.SYNC_FOLDER_ID)
}
private var pollJob: kotlinx.coroutines.Job? = null private var pollJob: kotlinx.coroutines.Job? = null
private var migrationJob: kotlinx.coroutines.Job? = null private var migrationJob: kotlinx.coroutines.Job? = null
private var migratingTo: String? = null private var migratingTo: String? = null
@ -87,12 +89,23 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
val state: StateFlow<SelectionUiState> = _state.asStateFlow() val state: StateFlow<SelectionUiState> = _state.asStateFlow()
val options: List<ProviderOption> = listOf( val options: List<ProviderOption> = listOf(
ProviderOption(
id = com.shonar.provider.LocalOnlyProvider.ID,
name = "On this phone",
blurb = "Recordings stay on this phone, in your own folder if you " +
"like — sync it yourself however you want. Playback and " +
"search work fully offline.",
needsUrl = false,
available = true,
needsPath = true,
),
ProviderOption( ProviderOption(
id = ProviderRegistry.NEXTCLOUD_ID, id = ProviderRegistry.NEXTCLOUD_ID,
name = "Nextcloud", name = "Nextcloud",
blurb = "Recommended. Your files on your Nextcloud via WebDAV. Connects with an app password — never your normal password.", blurb = "Your files on your Nextcloud via WebDAV. Connects with an app password — never your normal password.",
needsUrl = true, needsUrl = true,
available = true, // P4 available = true, // P4
advanced = true,
), ),
ProviderOption( ProviderOption(
id = ProviderRegistry.CUSTOM_SHONAR_ID, id = ProviderRegistry.CUSTOM_SHONAR_ID,
@ -100,15 +113,7 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
blurb = "A SHONAR backend you run yourself (this project's server). Sign in with your server account.", blurb = "A SHONAR backend you run yourself (this project's server). Sign in with your server account.",
needsUrl = true, needsUrl = true,
available = true, // P3 available = true, // P3
), advanced = true,
ProviderOption(
id = ProviderRegistry.SYNC_FOLDER_ID,
name = "Sync folder",
blurb = "A folder on this device kept in sync by Syncthing (or the Nextcloud " +
"desktop client, rsync…). The app writes plain files; your sync tool moves them.",
needsUrl = false,
available = true, // P4
needsPath = true,
), ),
ProviderOption( ProviderOption(
id = "start9", id = "start9",
@ -117,6 +122,7 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
"Nextcloud or SHONAR automatically.", "Nextcloud or SHONAR automatically.",
needsUrl = true, needsUrl = true,
available = true, // P6a: generic hosted setup (no platform RPC) available = true, // P6a: generic hosted setup (no platform RPC)
advanced = true,
), ),
ProviderOption( ProviderOption(
id = "umbrel", id = "umbrel",
@ -125,16 +131,16 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
"Nextcloud or SHONAR automatically.", "Nextcloud or SHONAR automatically.",
needsUrl = true, needsUrl = true,
available = true, // P6a: generic hosted setup (no platform RPC) available = true, // P6a: generic hosted setup (no platform RPC)
), advanced = true,
ProviderOption(
id = com.shonar.provider.LocalOnlyProvider.ID,
name = "Local only",
blurb = "Nothing leaves this phone. Recording, playback, and search work fully offline.",
needsUrl = false,
available = true,
), ),
) )
/** Cards shown without opening Advanced. Server choices stay one tap away. */
val simpleOptions: List<ProviderOption> get() = options.filter { !it.advanced }
/** Self-hosted server choices, behind the Advanced disclosure. */
val advancedOptions: List<ProviderOption> get() = options.filter { it.advanced }
fun choose(option: ProviderOption, rawUrl: String) { fun choose(option: ProviderOption, rawUrl: String) {
viewModelScope.launch { viewModelScope.launch {
pollJob?.cancel() pollJob?.cancel()
@ -144,9 +150,14 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
return@launch return@launch
} }
if (!option.needsUrl) { if (!option.needsUrl) {
// Local-only saves straight away; the sync folder has its // Local-only saves straight away (its folder is optional —
// own path step (connectFolder). // the path step below only overrides it). The sync folder
if (option.needsPath) return@launch // has no default, so it waits for its path step.
if (option.needsPath &&
option.id != com.shonar.provider.LocalOnlyProvider.ID
) {
return@launch
}
runCatching { save(option.id, "") } runCatching { save(option.id, "") }
.onFailure { _state.value = SelectionUiState.Error(it.message ?: "Could not save provider") } .onFailure { _state.value = SelectionUiState.Error(it.message ?: "Could not save provider") }
return@launch return@launch
@ -394,22 +405,31 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
} }
/** /**
* P4 sync folder: validate the directory and remember it. The folder * Use a folder for a path-capable provider (on-this-phone or sync
* must already exist (created by you or by Syncthing) — a typo is an * folder). Blank path means "just the default": valid for on-this-phone
* error, never a silently created directory. * (app-private dir), an error for the sync folder, which has no default.
*/ */
fun connectFolder(rawPath: String) { fun connectFolder(rawPath: String, providerId: String = ProviderRegistry.SYNC_FOLDER_ID) {
viewModelScope.launch { viewModelScope.launch {
val path = rawPath.trim() val path = rawPath.trim()
if (path.isEmpty()) { if (path.isEmpty()) {
_state.value = SelectionUiState.Error( if (providerId == com.shonar.provider.LocalOnlyProvider.ID) {
"Enter the folder path — e.g. the Syncthing folder for SHONAR." runCatching { save(providerId, "") }
) .onFailure {
_state.value = SelectionUiState.Error(
it.message ?: "Could not save provider"
)
}
} else {
_state.value = SelectionUiState.Error(
"Enter the folder path — e.g. the Syncthing folder for SHONAR."
)
}
return@launch return@launch
} }
_state.value = SelectionUiState.Probing(path) _state.value = SelectionUiState.Probing(path)
runCatching { runCatching {
folderProvider.connect( registry.provider(providerId).connect(
com.shonar.provider.ProviderCredential.FolderPath( com.shonar.provider.ProviderCredential.FolderPath(
accountLabel = path, accountLabel = path,
path = path, path = path,
@ -419,7 +439,42 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
_state.value = SelectionUiState.Error(it.message ?: "Could not use $path") _state.value = SelectionUiState.Error(it.message ?: "Could not use $path")
return@launch return@launch
} }
save(ProviderRegistry.SYNC_FOLDER_ID, "") save(providerId, "")
}
}
/**
* Create the folder (only the final segment; the parent must already
* exist) and use it for [providerId]. The UI confirms the exact path
* before calling — creation is always explicit, never a typo side
* effect. Blank path falls back to the default, like [connectFolder].
*/
fun createFolder(
rawPath: String,
providerId: String = ProviderRegistry.SYNC_FOLDER_ID,
) {
viewModelScope.launch {
val path = rawPath.trim()
if (path.isEmpty()) {
connectFolder("", providerId)
return@launch
}
_state.value = SelectionUiState.Probing(path)
val provider = runCatching { registry.provider(providerId) }.getOrNull()
val failure = runCatching {
when (provider) {
is com.shonar.provider.LocalOnlyProvider -> provider.createRoot(path)
is com.shonar.provider.FolderSyncProvider -> provider.createRoot(path)
else -> throw com.shonar.provider.ProviderError.InvalidUrl(
"This provider has no folder to create"
)
}
}.exceptionOrNull()
if (failure != null) {
_state.value = SelectionUiState.Error(failure.message ?: "Could not create $path")
return@launch
}
save(providerId, "")
} }
} }
@ -438,6 +493,9 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
"\"" + url.replace("\\", "\\\\").replace("\"", "\\\"") + "\"", "\"" + url.replace("\\", "\\\\").replace("\"", "\\\"") + "\"",
) )
} }
// New recordings follow the picked folder from here on.
// Older clips stay where they were recorded.
runCatching { repository.refreshRoot() }
// P7: switching providers with a non-empty library asks what happens // P7: switching providers with a non-empty library asks what happens
// to it — nothing moves or is forgotten silently. // to it — nothing moves or is forgotten silently.
val count = runCatching { dao.getAll().size }.getOrDefault(0) val count = runCatching { dao.getAll().size }.getOrDefault(0)

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.shonar.provider.AuthState import com.shonar.provider.AuthState
import com.shonar.provider.LocalOnlyProvider
/** /**
* P7 "Where is my data?": live provider summary plus the account * P7 "Where is my data?": live provider summary plus the account
@ -136,6 +137,13 @@ private fun ReadyBody(
) )
ready.summary?.let { ready.summary?.let {
Text(it.headline, style = MaterialTheme.typography.titleMedium) Text(it.headline, style = MaterialTheme.typography.titleMedium)
it.path?.let { path ->
Text(
path,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Text( Text(
it.detail, it.detail,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -159,34 +167,39 @@ private fun ReadyBody(
} }
val enabled = ready.busy == null val enabled = ready.busy == null
if (ready.auth != AuthState.CONNECTED) { val isLocal = ready.providerId == LocalOnlyProvider.ID
Button(onClick = onReconnect, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text("Reconnect")
}
}
Button(onClick = onSwitchProvider, enabled = enabled, modifier = Modifier.fillMaxWidth()) { Button(onClick = onSwitchProvider, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text("Switch provider") Text("Location")
} }
OutlinedButton( // On-device storage has no accounts, servers, or sync jobs — just the
onClick = onPauseSync, // location. Everything below is server/provider lifecycle.
enabled = enabled, if (!isLocal) {
modifier = Modifier.fillMaxWidth(), if (ready.auth != AuthState.CONNECTED) {
) { Text("Pause sync") } Button(onClick = onReconnect, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) { Text("Reconnect")
Checkbox(checked = revoke, onCheckedChange = { revoke = it }, enabled = enabled) }
Text("Also revoke on the server") }
OutlinedButton(
onClick = onPauseSync,
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
) { Text("Pause sync") }
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = revoke, onCheckedChange = { revoke = it }, enabled = enabled)
Text("Also revoke on the server")
}
OutlinedButton(
onClick = { onDisconnect(revoke) },
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
) { Text("Disconnect") }
OutlinedButton(
onClick = onAskDelete,
enabled = enabled,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth(),
) { Text("Delete account & data") }
} }
OutlinedButton(
onClick = { onDisconnect(revoke) },
enabled = enabled,
modifier = Modifier.fillMaxWidth(),
) { Text("Disconnect") }
OutlinedButton(
onClick = onAskDelete,
enabled = enabled,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier.fillMaxWidth(),
) { Text("Delete account & data") }
} }
private fun authLabel(auth: AuthState): String = when (auth) { private fun authLabel(auth: AuthState): String = when (auth) {

View file

@ -0,0 +1,180 @@
package com.shonar.provider
import java.io.File
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.json.JSONArray
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
/**
* M8: CustomShonarProvider AI-content methods against a fake with the M7
* read endpoints plus the M8 PUT edit endpoints. Shapes mirror
* `backend/shonar/api/schemas_recordings.py`.
*/
class CustomShonarAiTest {
private val server = MockWebServer()
private lateinit var dir: File
private lateinit var store: com.shonar.settings.InMemorySettingsStore
private var transcriptText = "auto text"
private var transcriptVersion = 1
private var summaryContent = JSONObject().put("short", "auto summary")
private var summaryVersion = 1
@Before fun setUp() {
dir = File(System.getProperty("java.io.tmpdir"), "custom-ai-${System.nanoTime()}").apply { mkdirs() }
store = com.shonar.settings.InMemorySettingsStore()
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse = handle(request)
}
server.start()
}
@After fun tearDown() {
server.shutdown()
dir.deleteRecursively()
}
private val url: String get() = server.url("/").toString().removeSuffix("/")
private fun provider(): CustomShonarProvider = CustomShonarProvider(
auth = ShonarAuthStore(store),
sidecarRoot = File(dir, "sidecars"),
)
private suspend fun connected(): CustomShonarProvider {
val p = provider()
p.connect(
ProviderCredential.ShonarLogin(
accountLabel = "t@example.com",
serverUrl = ServerUrl.parse(url)!!.getOrThrow(),
email = "t@example.com",
password = "pw",
)
)
return p
}
private fun handle(request: RecordedRequest): MockResponse {
val path = request.path?.substringBefore('?').orEmpty()
val method = request.method.orEmpty()
if (method == "POST" && path == "/api/v1/auth/login") {
return json(200, JSONObject()
.put("access_token", "a").put("token_type", "bearer")
.put("expires_in", 900).put("refresh_token", "r"))
}
if (request.getHeader("Authorization") != "Bearer a") {
return json(401, JSONObject().put("detail", "bad token"))
}
val recId = "rec-1"
return when {
method == "GET" && path == "/api/v1/recordings/$recId/transcript" ->
json(200, transcriptJson())
method == "GET" && path == "/api/v1/recordings/$recId/summary" ->
json(200, summaryJson())
method == "GET" && path == "/api/v1/recordings/$recId/jobs" ->
MockResponse().setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody(JSONArray().apply {
put(JSONObject().put("job_type", "transcribe")
.put("status", "succeeded").put("attempt", 1).put("max_attempts", 3))
}.toString())
method == "PUT" && path == "/api/v1/recordings/$recId/transcript" -> {
val body = JSONObject(request.body.readUtf8())
transcriptText = body.getString("text")
transcriptVersion++
json(200, transcriptJson())
}
method == "PUT" && path == "/api/v1/recordings/$recId/summary" -> {
summaryContent = JSONObject(request.body.readUtf8()).getJSONObject("content")
summaryVersion++
json(200, summaryJson())
}
method == "PATCH" && path == "/api/v1/recordings/$recId" ->
json(200, JSONObject().put("id", recId)
.put("title", JSONObject(request.body.readUtf8()).optString("title", "x")))
method == "GET" && path == "/api/v1/recordings/missing/transcript" ->
json(404, JSONObject().put("detail", "No transcript yet"))
else -> json(404, JSONObject().put("detail", "unknown $method $path"))
}
}
private fun transcriptJson() = JSONObject()
.put("version", transcriptVersion).put("language", "en")
.put("provider", if (transcriptVersion == 1) "faster-whisper" else "user")
.put("text", transcriptText)
.put("segments", JSONArray().apply {
put(JSONObject().put("start", 0.0).put("end", 1.0).put("text", transcriptText))
})
.put("edited_by_user", transcriptVersion > 1)
private fun summaryJson() = JSONObject()
.put("version", summaryVersion)
.put("provider", if (summaryVersion == 1) "ollama" else "user")
.put("content", summaryContent)
.put("edited_by_user", summaryVersion > 1)
private fun json(code: Int, obj: JSONObject) = MockResponse()
.setResponseCode(code).setHeader("Content-Type", "application/json")
.setBody(obj.toString())
@Test fun fetchTranscript_ok() = runBlocking {
val p = connected()
val raw = p.fetchTranscript("rec-1")
assertEquals("auto text", JSONObject(raw).getString("text"))
}
@Test fun fetchTranscript_missingIsNotFound() = runBlocking {
val p = connected()
try {
p.fetchTranscript("missing")
fail("expected NotFound")
} catch (e: ProviderError.NotFound) {
assertTrue(e.message!!.contains("missing"))
}
}
@Test fun updateTranscript_roundTrip() = runBlocking {
val p = connected()
val raw = p.updateTranscript(
"rec-1",
com.shonar.recording.transcriptUpdatePayload("user corrected"),
)
val json = JSONObject(raw)
assertEquals("user corrected", json.getString("text"))
assertEquals(2, json.getInt("version"))
assertEquals(true, json.getBoolean("edited_by_user"))
// server state actually moved
assertEquals("user corrected", JSONObject(p.fetchTranscript("rec-1")).getString("text"))
}
@Test fun updateSummary_roundTrip() = runBlocking {
val p = connected()
val raw = p.updateSummary(
"rec-1",
com.shonar.recording.summaryUpdatePayload(mapOf("short" to "user short")),
)
val json = JSONObject(raw)
assertEquals("user short", json.getJSONObject("content").getString("short"))
assertEquals(2, json.getInt("version"))
}
@Test fun patchRecording_ok() = runBlocking {
val p = connected()
val raw = p.patchRecording(
"rec-1",
com.shonar.recording.recordingPatchPayload(title = "Renamed"),
)
assertEquals("Renamed", JSONObject(raw).getString("title"))
}
}

View file

@ -0,0 +1,71 @@
package com.shonar.provider
import java.io.File
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
/** Folder creation: explicit, leaf-only, typo-safe. Strict connect is untouched. */
class FolderSyncCreateTest {
private lateinit var base: File
private lateinit var store: com.shonar.settings.InMemorySettingsStore
@Before fun setUp() {
base = File(System.getProperty("java.io.tmpdir"), "sync-create-${System.nanoTime()}")
.apply { mkdirs() }
store = com.shonar.settings.InMemorySettingsStore()
}
@After fun tearDown() {
base.deleteRecursively()
}
private fun provider() = FolderSyncProvider(pathStore = store)
@Test fun createRoot_createsLeafAndConnects() = runBlocking {
val p = provider()
val leaf = File(base, "SHONAR-new").path
p.createRoot(leaf)
assertTrue(File(leaf).isDirectory)
assertEquals(AuthState.CONNECTED, p.authState.value)
// second call is just a connect
p.createRoot(leaf)
assertEquals(AuthState.CONNECTED, p.authState.value)
}
@Test fun createRoot_refusesMissingParent() = runBlocking {
val p = provider()
try {
p.createRoot(File(base, "no-such-parent/leaf").path)
fail("missing parent must not create deep trees")
} catch (e: ProviderError.InvalidUrl) {
assertTrue(e.message!!.contains("Parent"))
}
assertTrue(p.authState.value != AuthState.CONNECTED)
}
@Test fun createRoot_refusesTraversal() = runBlocking {
val p = provider()
try {
p.createRoot(base.path + "/../escape")
fail(".. must be rejected")
} catch (e: ProviderError.InvalidUrl) {
assertTrue(e.message!!.contains(".."))
}
}
@Test fun connect_stillRefusesMissing() = runBlocking {
val p = provider()
try {
p.connect(ProviderCredential.FolderPath("x", File(base, "missing").path))
fail("strict connect must keep refusing")
} catch (e: ProviderError.InvalidUrl) {
assertTrue(e.message!!.contains("does not exist"))
}
}
}

View file

@ -0,0 +1,87 @@
package com.shonar.provider
import java.io.File
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
/** Local-only with a user-picked folder: files land there, deletion never touches it. */
class LocalOnlyFolderTest {
private lateinit var appDir: File
private lateinit var userDir: File
private lateinit var store: com.shonar.settings.InMemorySettingsStore
@Before fun setUp() {
val base = File(System.getProperty("java.io.tmpdir"), "local-folder-${System.nanoTime()}")
appDir = File(base, "app").apply { mkdirs() }
userDir = File(base, "user").apply { mkdirs() }
store = com.shonar.settings.InMemorySettingsStore()
}
@After fun tearDown() {
appDir.deleteRecursively()
userDir.deleteRecursively()
}
private fun provider() = LocalOnlyProvider(appDir, pathStore = store)
private fun draft(id: String, dir: File): RecordingDraft {
val src = File(dir, "src-$id.bin")
src.writeBytes(ByteArray(1024) { it.toByte() })
return RecordingDraft(id, "t", 1L, 1000L, "audio/mp4", src, src.length())
}
@Test fun defaultRoot_unchangedWithoutPick() = runBlocking {
val p = provider()
p.connect(ProviderCredential.None)
val ref = p.upload(draft("a", appDir)) { }
assertTrue(File(appDir, ref.key).exists())
assertEquals("On this device only", p.storageLocationSummary().headline)
}
@Test fun pickedFolder_receivesUploads() = runBlocking {
val p = provider()
p.connect(ProviderCredential.FolderPath("mine", userDir.path))
val ref = p.upload(draft("b", appDir)) { }
assertTrue(File(userDir, ref.key).exists())
assertFalse(File(appDir, "audio").exists())
val summary = p.storageLocationSummary()
assertEquals("Your folder", summary.headline)
assertEquals(userDir.canonicalPath, java.io.File(summary.path!!).canonicalPath)
}
@Test fun deleteAccount_keepsUserFolder() = runBlocking {
val p = provider()
p.connect(ProviderCredential.FolderPath("mine", userDir.path))
p.upload(draft("c", appDir)) { }
p.deleteAccountAndData()
assertTrue("user files must survive", File(userDir, "audio").exists())
}
@Test fun createRoot_leafOnly() = runBlocking {
val p = provider()
val leaf = File(userDir, "SHONAR-new").path
p.createRoot(leaf)
assertTrue(File(leaf).isDirectory)
try {
p.createRoot(File(userDir, "no-parent/leaf").path)
fail("missing parent must fail")
} catch (e: ProviderError.InvalidUrl) {
assertTrue(e.message!!.contains("Parent"))
}
}
@Test fun useDefaultRoot_forgetsPick() = runBlocking {
val p = provider()
p.connect(ProviderCredential.FolderPath("mine", userDir.path))
p.useDefaultRoot()
val ref = p.upload(draft("d", appDir)) { }
assertTrue(File(appDir, ref.key).exists())
}
}

View file

@ -0,0 +1,143 @@
package com.shonar.recording
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** M8: transcript/summary parsing, playback-sync mapping, edit payloads. */
class AiContentTest {
private fun seg(start: Double, text: String) =
TranscriptSegment(start, start + 1.0, text)
// ---- activeSegmentIndex ----------------------------------------------------
@Test fun activeSegment_emptyIsMinusOne() {
assertEquals(-1, activeSegmentIndex(emptyList(), 5_000))
}
@Test fun activeSegment_beforeFirstIsMinusOne() {
val segs = listOf(seg(2.0, "a"), seg(4.0, "b"))
assertEquals(-1, activeSegmentIndex(segs, 1_999))
}
@Test fun activeSegment_exactStartSelectsIt() {
val segs = listOf(seg(2.0, "a"), seg(4.0, "b"))
assertEquals(0, activeSegmentIndex(segs, 2_000))
assertEquals(1, activeSegmentIndex(segs, 4_000))
}
@Test fun activeSegment_gapKeepsLastSpoken() {
val segs = listOf(seg(0.0, "a"), seg(10.0, "b"))
assertEquals(0, activeSegmentIndex(segs, 9_999))
}
@Test fun activeSegment_afterLastStaysOnLast() {
val segs = listOf(seg(0.0, "a"), seg(2.0, "b"))
assertEquals(1, activeSegmentIndex(segs, 999_000))
}
// ---- parsing -----------------------------------------------------------------
@Test fun parseTranscript_fullShape() {
val raw = JSONObject()
.put("version", 3)
.put("language", "en")
.put("provider", "faster-whisper")
.put("model", "small")
.put("text", "hello world")
.put("segments", org.json.JSONArray().apply {
put(JSONObject().put("start", 1.5).put("end", 2.5)
.put("text", "world").put("speaker", "S1"))
put(JSONObject().put("start", 0.0).put("end", 1.0).put("text", "hello"))
})
.put("edited_by_user", true)
.toString()
val t = parseTranscript(raw)!!
assertEquals(3, t.version)
assertEquals("en", t.language)
assertEquals("hello world", t.text)
assertTrue(t.editedByUser)
// out-of-order segments arrive sorted by start
assertEquals(listOf("hello", "world"), t.segments.map { it.text })
assertEquals("S1", t.segments[1].speaker)
assertNull(t.segments[0].speaker)
}
@Test fun parseTranscript_minimalShape() {
val t = parseTranscript(JSONObject().put("version", 1).put("text", "x").toString())!!
assertEquals(1, t.version)
assertTrue(t.segments.isEmpty())
assertFalse(t.editedByUser)
}
@Test fun parseTranscript_garbageIsNull() {
assertNull(parseTranscript("not json{"))
}
@Test fun parseSummary_sections() {
val raw = JSONObject()
.put("version", 2)
.put("provider", "ollama")
.put("edited_by_user", false)
.put("content", JSONObject()
.put("short", "Standup.")
.put("detailed", "Long version.")
.put("key_points", org.json.JSONArray().put("a").put("b"))
.put("action_items", org.json.JSONArray().put("ship it"))
.put("decisions", org.json.JSONArray())
.put("questions", org.json.JSONArray()))
.toString()
val s = parseSummary(raw)!!
assertEquals("Standup.", s.short)
assertEquals("Long version.", s.detailed)
assertEquals(listOf("a", "b"), s.list("key_points"))
assertEquals(listOf("ship it"), s.list("action_items"))
assertTrue(s.list("decisions").isEmpty())
}
@Test fun parseJobs_list() {
val raw = org.json.JSONArray().apply {
put(JSONObject().put("job_type", "transcribe").put("status", "succeeded")
.put("attempt", 1).put("max_attempts", 3))
put(JSONObject().put("job_type", "summarize").put("status", "failed")
.put("attempt", 3).put("max_attempts", 3).put("error", "boom"))
}.toString()
val jobs = parseJobs(raw)
assertEquals(2, jobs.size)
assertEquals("succeeded", jobs[0].status)
assertEquals("boom", jobs[1].error)
}
// ---- payloads ------------------------------------------------------------------
@Test fun transcriptPayload_textOnly() {
val payload = JSONObject(transcriptUpdatePayload("fixed text"))
assertEquals("fixed text", payload.getString("text"))
assertFalse(payload.has("segments"))
}
@Test fun transcriptPayload_withSegmentsRoundTrips() {
val segs = listOf(TranscriptSegment(0.0, 1.0, "hi", "S1"))
val payload = JSONObject(transcriptUpdatePayload("hi", segs))
val arr = payload.getJSONArray("segments")
assertEquals(1, arr.length())
assertEquals("S1", arr.getJSONObject(0).getString("speaker"))
}
@Test fun summaryPayload_shape() {
val payload = JSONObject(summaryUpdatePayload(mapOf("short" to "s")))
assertEquals("s", payload.getJSONObject("content").getString("short"))
}
@Test fun patchPayload_titleOnly() {
val payload = JSONObject(recordingPatchPayload(title = "New"))
assertEquals("New", payload.getString("title"))
assertFalse(payload.has("notes"))
assertNotNull(JSONObject(recordingPatchPayload(title = "a", notes = "b")))
}
}

View file

@ -0,0 +1,107 @@
package com.shonar.recording
import java.io.File
import java.nio.file.Files
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
/** Rename: file + entry move together, collisions suffix, names sanitized. */
class RecordingRenameTest {
private class FakeDao : RecordingDao {
val rows = mutableMapOf<String, RecordingEntity>()
private val flow = MutableStateFlow<List<RecordingEntity>>(emptyList())
private fun emit() {
flow.value = rows.values.sortedByDescending { it.createdAtEpochMs }
}
override fun observeAll(): Flow<List<RecordingEntity>> = flow
override suspend fun getAll(): List<RecordingEntity> = rows.values.toList()
override suspend fun getById(id: String): RecordingEntity? = rows[id]
override suspend fun insert(recording: RecordingEntity) {
rows[recording.id] = recording
emit()
}
override suspend fun update(recording: RecordingEntity) {
rows[recording.id] = recording
emit()
}
override suspend fun delete(recording: RecordingEntity) {
rows.remove(recording.id)
emit()
}
}
private fun entityFor(file: File) = RecordingEntity(
id = "id-${file.nameWithoutExtension}",
title = file.nameWithoutExtension,
createdAtEpochMs = 1L,
durationMs = 1000L,
filePath = file.absolutePath,
mimeType = "audio/mp4",
sizeBytes = file.length(),
)
@Test fun sanitizeFileStem_basics() {
assertEquals("hello world", RecordingRepository.sanitizeFileStem(" hello world "))
assertEquals("a_b", RecordingRepository.sanitizeFileStem("a/b"))
assertEquals("a_b", RecordingRepository.sanitizeFileStem("a\\b"))
assertNull(RecordingRepository.sanitizeFileStem(" "))
assertNull(RecordingRepository.sanitizeFileStem(".."))
assertNull(RecordingRepository.sanitizeFileStem("."))
}
@Test fun rename_movesFileAndEntry() = runBlocking {
val dir = Files.createTempDirectory("rename").toFile()
try {
val repo = RecordingRepository(dir, FakeDao())
val src = File(dir, "abc.m4a").apply { writeBytes(ByteArray(16)) }
val updated = repo.rename(entityFor(src), "Big Raccoon")
assertEquals("Big Raccoon", updated.title)
assertTrue(File(dir, "Big Raccoon.m4a").exists())
assertFalse(src.exists())
assertEquals(File(dir, "Big Raccoon.m4a").absolutePath, updated.filePath)
} finally {
dir.deleteRecursively()
}
}
@Test fun rename_collisionSuffixes() = runBlocking {
val dir = Files.createTempDirectory("rename2").toFile()
try {
val repo = RecordingRepository(dir, FakeDao())
File(dir, "Taken.m4a").apply { writeBytes(ByteArray(8)) }
val src = File(dir, "abc.m4a").apply { writeBytes(ByteArray(8)) }
val updated = repo.rename(entityFor(src), "Taken")
assertEquals("Taken (2)", updated.title)
assertTrue(File(dir, "Taken (2).m4a").exists())
} finally {
dir.deleteRecursively()
}
}
@Test fun rename_blankFails() = runBlocking {
val dir = Files.createTempDirectory("rename3").toFile()
try {
val repo = RecordingRepository(dir, FakeDao())
val src = File(dir, "abc.m4a").apply { writeBytes(ByteArray(8)) }
try {
repo.rename(entityFor(src), " ")
fail("blank name must fail")
} catch (e: IllegalArgumentException) {
assertTrue(src.exists())
}
} finally {
dir.deleteRecursively()
}
}
}

View file

@ -0,0 +1,63 @@
package com.shonar.ui.folder
import java.io.File
import java.nio.file.Files
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
class FolderListTest {
private fun tempDir(): File = Files.createTempDirectory("folder-list").toFile()
@Test fun listChildDirs_sortedSkipsHiddenAndFiles() {
val root = tempDir()
try {
File(root, "b").mkdir()
File(root, "A").mkdir()
File(root, ".hidden").mkdir()
File(root, "note.txt").writeText("x")
assertEquals(
listOf("A", "b"),
listChildDirs(root).map { it.name },
)
} finally {
root.deleteRecursively()
}
}
@Test fun listChildDirs_missingIsEmpty() {
assertTrue(listChildDirs(File("/no/such/dir/xyz")).isEmpty())
}
@Test fun folderName_validation() {
assertTrue(isValidFolderName("SHONAR"))
assertTrue(isValidFolderName(" spaced "))
assertFalse(isValidFolderName(""))
assertFalse(isValidFolderName("a/b"))
assertFalse(isValidFolderName(".."))
assertFalse(isValidFolderName("."))
}
@Test fun createChildDir_roundTrip() {
val root = tempDir()
try {
val child = createChildDir(root, "New").getOrThrow()
assertTrue(child.isDirectory)
// idempotent when already a dir
createChildDir(root, "New").getOrThrow()
// refuses file clash
File(root, "f").writeText("x")
try {
createChildDir(root, "f").getOrThrow()
fail("file clash must fail")
} catch (e: IllegalArgumentException) {
assertTrue(e.message!!.contains("file"))
}
} finally {
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,63 @@
"""per-recording transcription model + job progress + app settings
Revision ID: m8models000001
Revises: fts0000000001
- recordings.transcription_model (nullable; NULL = server default)
- processing_jobs.stage / processing_jobs.progress (display-only)
- app_settings table (global default model, …)
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "m8models000001"
down_revision: str | None = "fts0000000001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"recordings",
sa.Column("transcription_model", sa.String(length=32), nullable=True),
)
op.add_column(
"upload_sessions",
sa.Column("transcription_model", sa.String(length=32), nullable=True),
)
op.add_column(
"processing_jobs",
sa.Column("stage", sa.String(length=32), nullable=True),
)
op.add_column(
"processing_jobs",
sa.Column("progress", sa.Integer(), nullable=True),
)
op.create_table(
"app_settings",
sa.Column("key", sa.String(length=120), nullable=False),
sa.Column(
"value",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key"),
)
def downgrade() -> None:
op.drop_table("app_settings")
op.drop_column("processing_jobs", "progress")
op.drop_column("processing_jobs", "stage")
op.drop_column("upload_sessions", "transcription_model")
op.drop_column("recordings", "transcription_model")

View file

@ -17,6 +17,9 @@ class UploadSessionCreate(BaseModel):
declared_size_bytes: int = Field(gt=0) declared_size_bytes: int = Field(gt=0)
title: str | None = Field(default=None, max_length=300) title: str | None = Field(default=None, max_length=300)
client_recording_id: str | None = Field(default=None, max_length=64) 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): class UploadSessionOut(ORMModel):
@ -43,6 +46,9 @@ class RecordingFinalize(BaseModel):
longitude: float | None = Field(default=None, ge=-180, le=180) longitude: float | None = Field(default=None, ge=-180, le=180)
location_accuracy_m: float | None = Field(default=None, ge=0) location_accuracy_m: float | None = Field(default=None, ge=0)
notes: str | None = Field(default=None, max_length=20000) 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): class RecordingOut(ORMModel):
@ -55,6 +61,9 @@ class RecordingOut(ORMModel):
longitude: float | None longitude: float | None
processing_status: str processing_status: str
processing_error: str | None 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 has_audio: bool
mime_type: str | None mime_type: str | None
size_bytes: int | None size_bytes: int | None
@ -113,5 +122,28 @@ class ProcessingJobOut(ORMModel):
attempt: int attempt: int
max_attempts: int max_attempts: int
error: str | None 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 started_at: datetime | None
finished_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

@ -2,13 +2,14 @@
from fastapi import APIRouter from fastapi import APIRouter
from shonar.api.v1 import auth, health, provider_info, recordings, users from shonar.api.v1 import auth, health, models, provider_info, recordings, users
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(health.router) api_router.include_router(health.router)
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(recordings.router) api_router.include_router(recordings.router)
api_router.include_router(models.router)
api_router.include_router(provider_info.router) api_router.include_router(provider_info.router)
# Included as later milestones land: # Included as later milestones land:

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

@ -20,7 +20,9 @@ from shonar.api.schemas_recordings import (
RecordingOut, RecordingOut,
RecordingUpdate, RecordingUpdate,
SummaryOut, SummaryOut,
SummaryUpdate,
TranscriptOut, TranscriptOut,
TranscriptUpdate,
UploadSessionCreate, UploadSessionCreate,
UploadSessionOut, UploadSessionOut,
UploadStatusOut, UploadStatusOut,
@ -61,6 +63,7 @@ async def _recording_out(session, rec: Recording) -> RecordingOut: # noqa: ANN0
longitude=rec.longitude, longitude=rec.longitude,
processing_status=rec.processing_status.value, processing_status=rec.processing_status.value,
processing_error=rec.processing_error, processing_error=rec.processing_error,
transcription_model=rec.transcription_model,
has_audio=original is not None, has_audio=original is not None,
mime_type=original.mime_type if original else None, mime_type=original.mime_type if original else None,
size_bytes=original.size_bytes if original else None, size_bytes=original.size_bytes if original else None,
@ -78,7 +81,7 @@ async def create_upload(body: UploadSessionCreate, user: CurrentUser, session: S
try: try:
us = await up.create_session( us = await up.create_session(
session, user.id, body.declared_mime_type, body.declared_size_bytes, session, user.id, body.declared_mime_type, body.declared_size_bytes,
body.title, body.client_recording_id, body.title, body.client_recording_id, body.transcription_model,
) )
except up.UploadError as e: except up.UploadError as e:
raise HTTPException(e.status_code, e.message) from None raise HTTPException(e.status_code, e.message) from None
@ -125,6 +128,7 @@ async def finalize_upload(
session, user.id, session_id, session, user.id, session_id,
recorded_at=body.recorded_at, duration_seconds=body.duration_seconds, recorded_at=body.recorded_at, duration_seconds=body.duration_seconds,
latitude=lat, longitude=lon, location_accuracy_m=acc, notes=body.notes, latitude=lat, longitude=lon, location_accuracy_m=acc, notes=body.notes,
transcription_model=body.transcription_model,
) )
except up.UploadError as e: except up.UploadError as e:
raise HTTPException(e.status_code, e.message) from None raise HTTPException(e.status_code, e.message) from None
@ -297,19 +301,7 @@ async def get_transcript(
) )
if row is None: if row is None:
raise HTTPException(404, "No transcript yet") raise HTTPException(404, "No transcript yet")
return TranscriptOut( return _transcript_out(row)
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.get("/recordings/{recording_id}/summary", response_model=SummaryOut) @router.get("/recordings/{recording_id}/summary", response_model=SummaryOut)
@ -337,3 +329,101 @@ async def list_jobs(recording_id: uuid.UUID, user: CurrentUser, session: Session
.order_by(ProcessingJob.id) .order_by(ProcessingJob.id)
) )
return list(rows) 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

@ -143,6 +143,11 @@ class Recording(Base, PublicIdMixin):
index=True, index=True,
) )
processing_error: Mapped[str | None] = mapped_column(Text) processing_error: Mapped[str | None] = mapped_column(Text)
# Effective faster-whisper model for this recording (e.g. "base").
# Set at finalize time from the per-recording override or the global
# default; NULL means "server default at processing time" (pre-model
# rows). Never rewritten: changing the default affects future rows only.
transcription_model: Mapped[str | None] = mapped_column(String(32))
# The original audio is the Asset row with kind=original for this # The original audio is the Asset row with kind=original for this
# recording (at most one, enforced by a partial unique index). Keeping # recording (at most one, enforced by a partial unique index). Keeping
# the pointer one-directional avoids a recordings<->assets FK cycle. # the pointer one-directional avoids a recordings<->assets FK cycle.
@ -232,6 +237,10 @@ class UploadSession(Base, PublicIdMixin):
) )
client_recording_id: Mapped[str | None] = mapped_column(String(64)) client_recording_id: Mapped[str | None] = mapped_column(String(64))
title: Mapped[str | None] = mapped_column(String(300)) title: Mapped[str | None] = mapped_column(String(300))
# Optional per-recording transcription model override chosen on the
# upload screen. Validated at finalize time; the finalize body wins when
# both specify one.
transcription_model: Mapped[str | None] = mapped_column(String(32))
declared_mime_type: Mapped[str] = mapped_column(String(100), nullable=False) declared_mime_type: Mapped[str] = mapped_column(String(100), nullable=False)
declared_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) declared_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
chunk_size_bytes: Mapped[int] = mapped_column( chunk_size_bytes: Mapped[int] = mapped_column(
@ -399,6 +408,12 @@ class ProcessingJob(Base, PublicIdMixin):
attempt: Mapped[int] = mapped_column(default=0, nullable=False) attempt: Mapped[int] = mapped_column(default=0, nullable=False)
max_attempts: Mapped[int] = mapped_column(default=3, nullable=False) max_attempts: Mapped[int] = mapped_column(default=3, nullable=False)
error: Mapped[str | None] = mapped_column(Text) error: Mapped[str | None] = mapped_column(Text)
# Fine-grained phase for progress display (e.g. transcribe jobs report
# "loading-model" then "transcribing"). Nullable: older rows predate it.
# Never parsed by pipeline logic — display only.
stage: Mapped[str | None] = mapped_column(String(32))
# 0-100 work estimate within the current stage, when known.
progress: Mapped[int | None] = mapped_column()
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Opaque arq task handle for observability. # Opaque arq task handle for observability.
@ -429,3 +444,13 @@ class ExportJob(Base, PublicIdMixin):
# Ensure full-text search columns exist on Postgres (added via migration as # Ensure full-text search columns exist on Postgres (added via migration as
# tsvector generated columns; see migrations/versions/*_fts.py). # tsvector generated columns; see migrations/versions/*_fts.py).
class AppSetting(Base, PublicIdMixin):
"""Server-wide settings editable at runtime (global transcription model
default, …). Single row per key; the desktop Settings page writes here."""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
value: Mapped[dict] = mapped_column(JSONType, nullable=False, default=dict)

View file

@ -9,9 +9,38 @@ from __future__ import annotations
import asyncio import asyncio
import tempfile import tempfile
import threading
from pathlib import Path from pathlib import Path
from shonar.services.ai import ProviderConfigError, Segment, TranscriptResult from shonar.services.ai import ProviderConfigError, Segment, TranscriptResult
from shonar.services.ai.model_registry import (
download_instructions,
is_model_downloaded,
validate_model_name,
)
# One loaded model per name, shared across jobs in this worker process.
# ctranslate2 inference is thread-safe; a lock serializes first-load only.
_MODEL_CACHE: dict[str, object] = {}
_MODEL_CACHE_LOCK = threading.Lock()
def _load_model(name: str) -> object:
from faster_whisper import WhisperModel
with _MODEL_CACHE_LOCK:
model = _MODEL_CACHE.get(name)
if model is None:
model = WhisperModel(name, device="auto")
_MODEL_CACHE[name] = model
return model
def ensure_model_available(name: str) -> None:
"""Fail fast with download instructions instead of triggering a surprise
multi-GB download inside a transcription job."""
if not is_model_downloaded(name):
raise ProviderConfigError(download_instructions(name))
class FasterWhisperProvider: class FasterWhisperProvider:
@ -19,12 +48,12 @@ class FasterWhisperProvider:
def __init__(self, model: str = "base") -> None: def __init__(self, model: str = "base") -> None:
try: try:
from faster_whisper import WhisperModel # noqa: F401 import faster_whisper # noqa: F401
except ImportError as e: except ImportError as e:
raise ProviderConfigError( raise ProviderConfigError(
"faster-whisper is not installed (pip install shonar-backend[faster-whisper])." "faster-whisper is not installed (pip install shonar-backend[faster-whisper])."
) from e ) from e
self.model = model or "base" self.model = validate_model_name(model or "base")
async def transcribe( async def transcribe(
self, self,
@ -37,15 +66,14 @@ class FasterWhisperProvider:
return await asyncio.to_thread(self._run, audio, language_hint) return await asyncio.to_thread(self._run, audio, language_hint)
def _run(self, audio: bytes, language_hint: str | None) -> TranscriptResult: def _run(self, audio: bytes, language_hint: str | None) -> TranscriptResult:
from faster_whisper import WhisperModel
path: Path | None = None path: Path | None = None
try: try:
with tempfile.NamedTemporaryFile(suffix=".m4a", delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".m4a", delete=False) as f:
f.write(audio) f.write(audio)
path = Path(f.name) path = Path(f.name)
model = WhisperModel(self.model, device="auto") ensure_model_available(self.model)
segments_iter, info = model.transcribe( model = _load_model(self.model)
segments_iter, info = model.transcribe( # type: ignore[union-attr]
str(path), str(path),
beam_size=5, beam_size=5,
language=language_hint, language=language_hint,

View file

@ -0,0 +1,165 @@
"""Transcription model registry (Stage 1).
The supported faster-whisper sizes, their display metadata, validation, and
local availability checks. Nothing here downloads anything: faster-whisper
fetches from HuggingFace on first use, so "downloaded" is answered by
inspecting the HF hub cache, and "available" additionally requires the
faster-whisper package itself.
No silent substitution anywhere: unknown names are rejected, missing
downloads fail fast with instructions.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shonar.db.models import AppSetting
from shonar.services.ai import ProviderConfigError
DEFAULT_MODEL = "base"
DEFAULT_MODEL_KEY = "transcription.default_model"
@dataclass(frozen=True)
class TranscriptionModelInfo:
name: str
display_name: str
description: str
params: str
approx_memory: str
relative_speed: str
SUPPORTED_TRANSCRIPTION_MODELS: dict[str, TranscriptionModelInfo] = {
"tiny": TranscriptionModelInfo(
name="tiny",
display_name="Tiny",
description="Fastest and lightest. Good for quick drafts and slow machines.",
params="~39M",
approx_memory="~1 GB RAM",
relative_speed="~10x real-time (CPU)",
),
"base": TranscriptionModelInfo(
name="base",
display_name="Base (default)",
description="Balanced default. Works reasonably well on ordinary computers.",
params="~74M",
approx_memory="~1 GB RAM",
relative_speed="~7x real-time (CPU)",
),
"small": TranscriptionModelInfo(
name="small",
display_name="Small",
description="Better accuracy with higher resource usage.",
params="~244M",
approx_memory="~2 GB RAM",
relative_speed="~4x real-time (CPU)",
),
"medium": TranscriptionModelInfo(
name="medium",
display_name="Medium",
description="Higher accuracy and slower performance.",
params="~769M",
approx_memory="~5 GB RAM",
relative_speed="~2x real-time (CPU)",
),
"large-v3": TranscriptionModelInfo(
name="large-v3",
display_name="Large v3",
description="Highest accuracy and greatest resource requirements.",
params="~1.5B",
approx_memory="~10 GB RAM",
relative_speed="~1x real-time (CPU)",
),
}
def normalize_model_name(raw: str | None) -> str:
"""Case/whitespace-tolerant normalization. Never maps one model to another."""
return (raw or "").strip().lower()
def validate_model_name(raw: str | None) -> str:
"""Return the normalized name, or raise with a helpful message."""
name = normalize_model_name(raw)
if name in SUPPORTED_TRANSCRIPTION_MODELS:
return name
supported = ", ".join(sorted(SUPPORTED_TRANSCRIPTION_MODELS))
raise ProviderConfigError(
f"Unsupported transcription model {raw!r}. Supported models: {supported}. "
"Check the spelling — a different model is never substituted silently."
)
def _hf_hub_cache() -> Path:
try:
from huggingface_hub.constants import HF_HUB_CACHE
return Path(HF_HUB_CACHE)
except ImportError:
return Path(
os.environ.get("HF_HUB_CACHE", str(Path.home() / ".cache" / "huggingface" / "hub"))
)
def is_model_downloaded(name: str) -> bool:
"""True when a non-empty faster-whisper snapshot for `name` sits in the
HuggingFace hub cache (repo Systran/faster-whisper-<name>)."""
repo_dir = _hf_hub_cache() / f"models--Systran--faster-whisper-{name}"
snapshots = repo_dir / "snapshots"
if not snapshots.is_dir():
return False
return any(s.is_dir() and any(s.iterdir()) for s in snapshots.iterdir())
def is_faster_whisper_installed() -> bool:
try:
import faster_whisper # noqa: F401
return True
except ImportError:
return False
def download_instructions(name: str) -> str:
return (
f'Model "{name}" is not downloaded. Download it with: '
f"POST /api/v1/models/{name}/download "
"(needs internet once), or run any transcription with that model selected — "
"faster-whisper fetches it from HuggingFace automatically."
)
async def get_global_default_model(session: AsyncSession) -> str:
row = await session.scalar(select(AppSetting).where(AppSetting.key == DEFAULT_MODEL_KEY))
if row is None:
return DEFAULT_MODEL
model = row.value.get("model") if isinstance(row.value, dict) else None
return model if model in SUPPORTED_TRANSCRIPTION_MODELS else DEFAULT_MODEL
async def set_global_default_model(session: AsyncSession, raw: str) -> str:
"""Validate + persist the global default. Affects future recordings only —
existing rows keep their saved model."""
name = validate_model_name(raw)
row = await session.scalar(select(AppSetting).where(AppSetting.key == DEFAULT_MODEL_KEY))
if row is None:
row = AppSetting(key=DEFAULT_MODEL_KEY, value={"model": name})
session.add(row)
else:
row.value = {"model": name}
await session.flush()
return name
def effective_model(recording_model: str | None, global_default: str) -> str:
"""Per-recording override wins; otherwise the global default."""
if recording_model and recording_model in SUPPORTED_TRANSCRIPTION_MODELS:
return recording_model
return global_default

View file

@ -112,6 +112,8 @@ async def reset_or_create(
existing.status = JobStatus.queued existing.status = JobStatus.queued
existing.attempt = 0 existing.attempt = 0
existing.error = None existing.error = None
existing.stage = None
existing.progress = None
existing.started_at = None existing.started_at = None
existing.finished_at = None existing.finished_at = None
return True return True
@ -227,6 +229,7 @@ async def _fail(
raise ProviderTransientError(message) raise ProviderTransientError(message)
job.status = JobStatus.failed job.status = JobStatus.failed
job.error = message job.error = message
job.stage = None
job.finished_at = utcnow() job.finished_at = utcnow()
rec.processing_status = ProcessingStatus.failed rec.processing_status = ProcessingStatus.failed
rec.processing_error = message rec.processing_error = message
@ -252,6 +255,24 @@ async def run_transcribe(ctx: dict, recording_id: str) -> None:
await session.commit() await session.commit()
await _maybe_chain_summarize(session, rec) await _maybe_chain_summarize(session, rec)
return return
# The recording's saved model is authoritative: a per-recording
# override wins, otherwise the global default in force at finalize
# time (changing the default never rewrites history).
from shonar.services.ai.model_registry import (
effective_model,
get_global_default_model,
)
model = effective_model(rec.transcription_model, await get_global_default_model(session))
if provider.name == "faster_whisper":
from shonar.services.ai.faster_whisper import FasterWhisperProvider
try:
provider = FasterWhisperProvider(model=model)
except AIError as e:
await _fail(session, rec, job, str(e), ctx, e)
await session.commit()
return
original = await session.scalar( original = await session.scalar(
select(Asset).where( select(Asset).where(
Asset.recording_id == rec.id, Asset.kind == AssetKind.original Asset.recording_id == rec.id, Asset.kind == AssetKind.original
@ -262,11 +283,15 @@ async def run_transcribe(ctx: dict, recording_id: str) -> None:
job.status = JobStatus.running job.status = JobStatus.running
job.attempt = _job_try(ctx) job.attempt = _job_try(ctx)
job.started_at = utcnow() job.started_at = utcnow()
job.stage = "loading-model"
job.progress = None
rec.processing_status = ProcessingStatus.processing rec.processing_status = ProcessingStatus.processing
rec.processing_error = None rec.processing_error = None
await session.flush() await session.flush()
try: try:
audio = await get_storage().get(original.storage_key) audio = await get_storage().get(original.storage_key)
job.stage = "transcribing"
await session.flush()
result = await provider.transcribe(audio, original.mime_type) result = await provider.transcribe(audio, original.mime_type)
except AIError as e: except AIError as e:
await _fail(session, rec, job, str(e), ctx, e) await _fail(session, rec, job, str(e), ctx, e)
@ -277,6 +302,8 @@ async def run_transcribe(ctx: dict, recording_id: str) -> None:
provider.name, getattr(result, "model", ""), provider.name, getattr(result, "model", ""),
) )
job.status = JobStatus.succeeded job.status = JobStatus.succeeded
job.stage = None
job.progress = 100
job.finished_at = utcnow() job.finished_at = utcnow()
await session.flush() await session.flush()
await _maybe_chain_summarize(session, rec) await _maybe_chain_summarize(session, rec)

View file

@ -56,6 +56,7 @@ async def create_session(
declared_size_bytes: int, declared_size_bytes: int,
title: str | None, title: str | None,
client_recording_id: str | None, client_recording_id: str | None,
transcription_model: str | None = None,
) -> UploadSession: ) -> UploadSession:
settings = get_settings() settings = get_settings()
if declared_size_bytes <= 0 or declared_size_bytes > settings.max_upload_bytes: if declared_size_bytes <= 0 or declared_size_bytes > settings.max_upload_bytes:
@ -74,6 +75,7 @@ async def create_session(
declared_size_bytes=declared_size_bytes, declared_size_bytes=declared_size_bytes,
chunk_size_bytes=settings.max_chunk_bytes, chunk_size_bytes=settings.max_chunk_bytes,
expires_at=utcnow() + SESSION_TTL, expires_at=utcnow() + SESSION_TTL,
transcription_model=(transcription_model.strip() if transcription_model else None),
) )
session.add(us) session.add(us)
await session.flush() await session.flush()
@ -165,10 +167,29 @@ async def finalize(
longitude: float | None = None, longitude: float | None = None,
location_accuracy_m: float | None = None, location_accuracy_m: float | None = None,
notes: str | None = None, notes: str | None = None,
transcription_model: str | None = None,
) -> tuple[UploadSession, Recording, Asset]: ) -> tuple[UploadSession, Recording, Asset]:
"""Assemble chunks, validate, store the immutable original, and create or """Assemble chunks, validate, store the immutable original, and create or
update the recording. Idempotent per client_recording_id.""" update the recording. Idempotent per client_recording_id."""
from shonar.services.ai import ProviderConfigError
from shonar.services.ai.model_registry import (
effective_model,
get_global_default_model,
validate_model_name,
)
us = await get_owned_session(session, user_id, session_id) us = await get_owned_session(session, user_id, session_id)
# Resolve + validate the transcription model before touching audio.
# Finalize body wins over the session's upload-screen choice; an
# explicit override updates history, the default never rewrites it.
override_raw = transcription_model or us.transcription_model
override: str | None = None
if override_raw:
try:
override = validate_model_name(override_raw)
except ProviderConfigError as e:
raise UploadError(str(e), 422) from None
model = effective_model(override, await get_global_default_model(session))
if us.status == UploadSessionStatus.completed and us.completed_asset_id: if us.status == UploadSessionStatus.completed and us.completed_asset_id:
# Already finalized: return existing recording (retry-safe client). # Already finalized: return existing recording (retry-safe client).
asset = await session.get(Asset, us.completed_asset_id) asset = await session.get(Asset, us.completed_asset_id)
@ -230,6 +251,10 @@ async def finalize(
us.status = UploadSessionStatus.completed us.status = UploadSessionStatus.completed
us.completed_asset_id = existing_original.id us.completed_asset_id = existing_original.id
recording.title = us.title or recording.title recording.title = us.title or recording.title
if override is not None:
# Explicit re-choice replaces the saved model; the default
# never rewrites history.
recording.transcription_model = override
await session.flush() await session.flush()
from shonar.services import processing as _processing from shonar.services import processing as _processing
@ -276,6 +301,7 @@ async def finalize(
longitude=longitude, longitude=longitude,
location_accuracy_m=location_accuracy_m, location_accuracy_m=location_accuracy_m,
processing_status=ProcessingStatus.uploaded, processing_status=ProcessingStatus.uploaded,
transcription_model=model,
) )
session.add(recording) session.add(recording)
await session.flush() await session.flush()

View file

@ -58,7 +58,8 @@ async def clean_db(_setup_db: None) -> AsyncIterator[None]:
text( text(
"TRUNCATE users, devices, refresh_tokens, recordings, assets, " "TRUNCATE users, devices, refresh_tokens, recordings, assets, "
"upload_sessions, upload_chunks, transcripts, summaries, tags, " "upload_sessions, upload_chunks, transcripts, summaries, tags, "
"recording_tags, processing_jobs, export_jobs RESTART IDENTITY CASCADE" "recording_tags, processing_jobs, export_jobs, app_settings "
"RESTART IDENTITY CASCADE"
) )
) )
await s.commit() await s.commit()

View file

@ -0,0 +1,245 @@
"""Stage 1: per-recording transcription models + models API.
Global default (base) with optional per-recording overrides; the worker
uses the exact saved model; history is never rewritten; unknown models
are rejected; missing downloads fail fast with instructions.
"""
from __future__ import annotations
import struct
import pytest
from sqlalchemy import select
from shonar.db import session as db_session
from shonar.db.models import Recording
from shonar.services import processing
def wav_bytes(payload_len: int = 64) -> bytes:
data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1)
data = data[:payload_len]
return (
b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, 8000, 8000, 1, 8)
+ b"data" + struct.pack("<I", len(data))
) + data
AUTH = {"email": "models@example.com", "password": "models-test-passw0rd-123"}
async def user_tokens(client):
r = await client.post(
"/api/v1/auth/register",
json={"email": AUTH["email"], "password": AUTH["password"]},
)
assert r.status_code == 201, r.text
return r.json()["access_token"]
async def upload_recording(client, token, client_id, session_model=None, finalize_model=None):
h = {"Authorization": f"Bearer {token}"}
data = wav_bytes()
body = {"declared_mime_type": "audio/wav", "declared_size_bytes": len(data),
"client_recording_id": client_id}
if session_model is not None:
body["transcription_model"] = session_model
r = await client.post("/api/v1/uploads", json=body, headers=h)
assert r.status_code == 201, r.text
sid = r.json()["id"]
r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
headers={**h, "content-type": "application/octet-stream"})
assert r.status_code == 201, r.text
fbody = {"duration_seconds": 5.0}
if finalize_model is not None:
fbody["transcription_model"] = finalize_model
r = await client.post(f"/api/v1/uploads/{sid}/finalize", json=fbody, headers=h)
return r
async def test_models_endpoint_lists_five(client):
token = await user_tokens(client)
r = await client.get("/api/v1/models", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200, r.text
body = r.json()
assert body["default_model"] == "base"
assert [m["name"] for m in body["models"]] == ["tiny", "base", "small", "medium", "large-v3"]
base = next(m for m in body["models"] if m["name"] == "base")
assert base["is_default"] is True
assert "ordinary computers" in base["description"]
for m in body["models"]:
assert isinstance(m["downloaded"], bool)
assert isinstance(m["available"], bool)
async def test_default_get_put_validation(client):
token = await user_tokens(client)
h = {"Authorization": f"Bearer {token}"}
r = await client.get("/api/v1/models/default", headers=h)
assert r.json() == {"default_model": "base"}
r = await client.put("/api/v1/models/default", json={"model": "small"}, headers=h)
assert r.status_code == 200, r.text
assert r.json() == {"default_model": "small"}
r = await client.put("/api/v1/models/default", json={"model": "xxl-turbo"}, headers=h)
assert r.status_code == 422
assert "Supported models" in r.text
# rejected change did not stick
r = await client.get("/api/v1/models/default", headers=h)
assert r.json() == {"default_model": "small"}
async def test_finalize_override_and_default_history(client):
token = await user_tokens(client)
h = {"Authorization": f"Bearer {token}"}
r = await upload_recording(client, token, "m-override-1", finalize_model="small")
assert r.status_code == 201, r.text
assert r.json()["transcription_model"] == "small"
r = await upload_recording(client, token, "m-default-1")
assert r.json()["transcription_model"] == "base"
# Changing the default affects future rows only.
r = await client.put("/api/v1/models/default", json={"model": "tiny"}, headers=h)
assert r.status_code == 200
r = await upload_recording(client, token, "m-default-2")
assert r.json()["transcription_model"] == "tiny"
async with db_session._session_factory() as s:
got = dict((await s.execute(
select(Recording.client_recording_id, Recording.transcription_model)
.where(Recording.client_recording_id.in_(
["m-override-1", "m-default-1", "m-default-2"])))
).all())
assert got == {"m-override-1": "small", "m-default-1": "base", "m-default-2": "tiny"}
async def test_session_override_and_finalize_wins(client):
token = await user_tokens(client)
r = await upload_recording(client, token, "m-sess-1", session_model="small")
assert r.status_code == 201, r.text
assert r.json()["transcription_model"] == "small"
r = await upload_recording(client, token, "m-sess-2", session_model="small",
finalize_model="tiny")
assert r.status_code == 201, r.text
assert r.json()["transcription_model"] == "tiny"
async def test_finalize_invalid_override_rejected(client):
token = await user_tokens(client)
r = await upload_recording(client, token, "m-bad-1", finalize_model="xxl-turbo")
assert r.status_code == 422
assert "Supported models" in r.text
class SpyTranscriber:
"""Stands in for FasterWhisperProvider; records the model it was built with."""
name = "faster_whisper"
seen_models: list = []
def __init__(self, model: str = "base"):
type(self).seen_models.append(model)
self.model = model
async def transcribe(self, audio, mime, *, language_hint=None):
from shonar.services.ai import Segment, TranscriptResult
return TranscriptResult(
text="spy text", language="en",
segments=[Segment(0.0, 1.0, "spy text")], model=self.model,
)
@pytest.fixture(autouse=True)
def _clear_spy():
SpyTranscriber.seen_models = []
yield
SpyTranscriber.seen_models = []
async def _run_with_spy(monkeypatch, recording_id):
import shonar.services.ai.faster_whisper as fw
# The worker rebuilds a faster_whisper provider from the saved model;
# the late import inside run_transcribe picks up this spy.
monkeypatch.setattr(fw, "FasterWhisperProvider", SpyTranscriber)
monkeypatch.setattr(
processing, "get_transcription_provider",
lambda settings: SpyTranscriber(model="ignored"),
)
await processing.run_transcribe({}, recording_id)
async def test_worker_uses_saved_model(client, monkeypatch):
token = await user_tokens(client)
rec = (await upload_recording(client, token, "m-work-1", finalize_model="small")).json()
await _run_with_spy(monkeypatch, rec["id"])
# Last construction is the worker rebuild from the saved model.
assert SpyTranscriber.seen_models[-1] == "small"
h = {"Authorization": f"Bearer {token}"}
r = await client.get(f"/api/v1/recordings/{rec['id']}/transcript", headers=h)
assert r.status_code == 200
assert r.json()["model"] == "small"
r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
job = next(j for j in r.json() if j["job_type"] == "transcribe")
assert job["status"] == "succeeded"
assert job["stage"] is None
assert job["progress"] == 100
async def test_worker_keeps_history_after_default_change(client, monkeypatch):
token = await user_tokens(client)
h = {"Authorization": f"Bearer {token}"}
rec = (await upload_recording(client, token, "m-work-2")).json()
assert rec["transcription_model"] == "base"
r = await client.put("/api/v1/models/default", json={"model": "tiny"}, headers=h)
assert r.status_code == 200
await _run_with_spy(monkeypatch, rec["id"])
assert SpyTranscriber.seen_models[-1] == "base"
async def test_unavailable_model_fails_with_instructions(client, monkeypatch):
import shonar.services.ai.faster_whisper as fw
from shonar.services.ai.faster_whisper import FasterWhisperProvider
# Real provider class, but the model is (simulated) not downloaded.
monkeypatch.setattr(fw, "is_model_downloaded", lambda name: False)
monkeypatch.setattr(
processing, "get_transcription_provider",
lambda settings: FasterWhisperProvider(model="base"),
)
token = await user_tokens(client)
h = {"Authorization": f"Bearer {token}"}
rec = (await upload_recording(client, token, "m-work-3")).json()
await processing.run_transcribe({}, rec["id"])
r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h)
assert r.json()["processing_status"] == "failed"
assert "not downloaded" in (r.json()["processing_error"] or "")
r = await client.get(f"/api/v1/recordings/{rec['id']}/jobs", headers=h)
job = next(j for j in r.json() if j["job_type"] == "transcribe")
assert job["status"] == "failed"
assert "download" in (job["error"] or "").lower()
async def test_reupload_explicit_override_updates_model(client):
token = await user_tokens(client)
r = await upload_recording(client, token, "m-reup-1", finalize_model="small")
assert r.json()["transcription_model"] == "small"
# Same client id, new explicit choice: model history moves with it.
r = await upload_recording(client, token, "m-reup-1", finalize_model="tiny")
assert r.status_code == 201, r.text
assert r.json()["transcription_model"] == "tiny"
# Same client id, no choice: history untouched.
r = await upload_recording(client, token, "m-reup-1")
assert r.json()["transcription_model"] == "tiny"
async def test_models_need_auth(client):
r = await client.get("/api/v1/models")
assert r.status_code in (401, 403)

View file

@ -0,0 +1,189 @@
"""M8: user edits to transcript/summary via PUT endpoints.
Edits create a new version with edited_by_user=True; the AI pipeline
must not overwrite them afterwards.
"""
from __future__ import annotations
import struct
import uuid
from sqlalchemy import select
from shonar.db import session as db_session
from shonar.db.models import Transcript
from shonar.services import processing
AUTH = {"email": "m8@example.com", "password": "m8-test-passw0rd-123"}
async def user_tokens(client):
r = await client.post(
"/api/v1/auth/register",
json={"email": AUTH["email"], "password": AUTH["password"]},
)
assert r.status_code == 201, r.text
return r.json()["access_token"]
def wav_bytes(payload_len: int = 64) -> bytes:
data = bytes(range(payload_len % 256)) * (payload_len // 256 + 1)
data = data[:payload_len]
header = (
b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, 8000, 8000, 1, 8)
+ b"data" + struct.pack("<I", len(data))
)
return header + data
async def upload_recording(client, token, client_id=None):
h = {"Authorization": f"Bearer {token}"}
data = wav_bytes()
r = await client.post(
"/api/v1/uploads",
json={"declared_mime_type": "audio/wav", "declared_size_bytes": len(data),
"client_recording_id": client_id},
headers=h,
)
assert r.status_code == 201, r.text
sid = r.json()["id"]
r = await client.put(f"/api/v1/uploads/{sid}/chunks/0", content=data,
headers={**h, "content-type": "application/octet-stream"})
assert r.status_code == 201, r.text
r = await client.post(f"/api/v1/uploads/{sid}/finalize",
json={"duration_seconds": 5.0}, headers=h)
assert r.status_code == 201, r.text
return r.json()
async def test_put_transcript_creates_user_version(client):
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m8-t-1")
h = {"Authorization": f"Bearer {token}"}
rid = rec["id"]
r = await client.put(f"/api/v1/recordings/{rid}/transcript",
json={"text": "hello corrected"}, headers=h)
assert r.status_code == 200, r.text
body = r.json()
assert body["version"] == 1
assert body["text"] == "hello corrected"
assert body["edited_by_user"] is True
assert body["provider"] == "user"
r = await client.get(f"/api/v1/recordings/{rid}/transcript", headers=h)
assert r.status_code == 200
assert r.json()["text"] == "hello corrected"
# Second edit supersedes the first.
r = await client.put(f"/api/v1/recordings/{rid}/transcript",
json={"text": "second pass",
"segments": [{"start": 0.0, "end": 1.0, "text": "second pass"}]},
headers=h)
assert r.status_code == 200, r.text
assert r.json()["version"] == 2
assert r.json()["segments"][0]["text"] == "second pass"
async with db_session._session_factory() as s:
rows = (await s.scalars(
select(Transcript).where(Transcript.recording_id == uuid.UUID(rid))
.order_by(Transcript.version))).all()
assert [(r.version, r.superseded_at is not None, r.edited_by_user) for r in rows] == [
(1, True, True), (2, False, True)]
async def test_put_summary_creates_user_version(client):
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m8-s-1")
h = {"Authorization": f"Bearer {token}"}
rid = rec["id"]
content = {"short": "s", "action_items": ["ship it"]}
r = await client.put(f"/api/v1/recordings/{rid}/summary",
json={"content": content}, headers=h)
assert r.status_code == 200, r.text
body = r.json()
assert body["version"] == 1
assert body["content"] == content
assert body["edited_by_user"] is True
r = await client.get(f"/api/v1/recordings/{rid}/summary", headers=h)
assert r.status_code == 200
assert r.json()["content"]["action_items"] == ["ship it"]
async def test_user_edit_survives_auto_pipeline(client, monkeypatch):
"""An auto transcribe run after a user edit inserts nothing."""
from shonar.services.ai import Segment as AiSegment
from shonar.services.ai import SummaryResult, TranscriptResult
class FakeTranscriber:
name = "fake-stt"
async def transcribe(self, audio, mime, *, language_hint=None):
return TranscriptResult(
text="auto text", language="en",
segments=[AiSegment(0.0, 1.0, "auto text")], model="fake-stt-1",
)
class FakeLlm:
name = "fake-llm"
async def summarize(self, transcript, *, title=None):
return SummaryResult(short="s", detailed="d", key_points=(),
decisions=(), action_items=(), questions=(),
model="fake-llm-1")
monkeypatch.setattr(
processing, "get_transcription_provider", lambda settings: FakeTranscriber())
monkeypatch.setattr(processing, "get_llm_provider", lambda settings: FakeLlm())
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m8-t-2")
h = {"Authorization": f"Bearer {token}"}
rid = rec["id"]
r = await client.put(f"/api/v1/recordings/{rid}/transcript",
json={"text": "user verdict"}, headers=h)
assert r.status_code == 200, r.text
monkeypatch.setattr(
processing, "get_transcription_provider", lambda settings: FakeTranscriber())
monkeypatch.setattr(processing, "get_llm_provider", lambda settings: FakeLlm())
await processing.run_transcribe({}, rid)
r = await client.get(f"/api/v1/recordings/{rid}/transcript", headers=h)
assert r.status_code == 200
assert r.json()["text"] == "user verdict"
async def test_edit_endpoints_enforce_ownership(client):
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m8-t-3")
rid = rec["id"]
r = await client.post("/api/v1/auth/register",
json={"email": "m8-other@example.com", "password": "m8-other-passw0rd-1"})
assert r.status_code == 201, r.text
other = r.json()["access_token"]
h2 = {"Authorization": f"Bearer {other}"}
r = await client.put(f"/api/v1/recordings/{rid}/transcript",
json={"text": "hijack"}, headers=h2)
assert r.status_code == 404
r = await client.put(f"/api/v1/recordings/{rid}/summary",
json={"content": {}}, headers=h2)
assert r.status_code == 404
async def test_edit_validation(client):
token = await user_tokens(client)
rec = await upload_recording(client, token, client_id="m8-t-4")
h = {"Authorization": f"Bearer {token}"}
rid = rec["id"]
r = await client.put(f"/api/v1/recordings/{rid}/transcript",
json={"text": ""}, headers=h)
assert r.status_code == 422

View file

@ -16,7 +16,8 @@ updated in the same commit as the work it describes.
| M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | done — `SyncWorker` + `SyncScheduler` (15-min periodic, one-shot on startup/constraint change, backoff on retry), Context-free `SyncDrain` engine (drains QUEUED + due ERROR incl. P7 leftovers, exponential backoff 1m–1h, cancel reverts to QUEUED), pause as resting state; full suite 167 green. On-device behaviour pending | | M5 | Android: WorkManager upload sync (retry, Wi-Fi-only, charging-only, pause) | done — `SyncWorker` + `SyncScheduler` (15-min periodic, one-shot on startup/constraint change, backoff on retry), Context-free `SyncDrain` engine (drains QUEUED + due ERROR incl. P7 leftovers, exponential backoff 1m–1h, cancel reverts to QUEUED), pause as resting state; full suite 167 green. On-device behaviour pending |
| M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | partial; local list, playback, and delete implemented | | M6 | Android: library (search/filter/sort), playback (seek/speed), waveform, download/delete | partial; local list, playback, and delete implemented |
| M7 | Backend: AI pipeline + adapters (whisper_http, faster-whisper, OpenAI-compat, Ollama, none), status endpoints | done — provider protocols + 4 adapters (faster-whisper lazy optional), versioned transcripts/summaries (user edits win), arq worker (`run_transcribe`/`run_summarize` + startup/5-min sweep, max 3 tries), transcript/summary/jobs endpoints, `none` means skipped; 50 backend tests green, ruff clean | | M7 | Backend: AI pipeline + adapters (whisper_http, faster-whisper, OpenAI-compat, Ollama, none), status endpoints | done — provider protocols + 4 adapters (faster-whisper lazy optional), versioned transcripts/summaries (user edits win), arq worker (`run_transcribe`/`run_summarize` + startup/5-min sweep, max 3 tries), transcript/summary/jobs endpoints, `none` means skipped; 50 backend tests green, ruff clean |
| M8 | Android: details screen — transcript synced to playback, summary, action items, editing | TODO | | M8 | Android: details screen — transcript synced to playback, summary, action items, editing | done — `detail/{id}` route (transcript/summary/status tabs, tap-to-seek, speed control, edit dialogs, title rename) on `CustomShonarProvider` AI methods (fetch/update transcript+summary, jobs, PATCH title/notes) against new backend `PUT transcript/summary` user-edit endpoints (versioned, pipeline won't clobber); pure `AiContent` parsing/sync mapping; 19 new Android tests + 5 backend edit tests, full suites green (186 Android, 55 backend), `assembleDebug` clean. Local-only/unsynced rows get honest empty states; on-device verification pending |
| T1 | Backend transcription model support — registry (tiny/base/small/medium/large-v3), global default (`base`, runtime-editable), per-recording overrides, `GET /api/v1/models`, worker uses saved model, job stage/progress | done — migration (`recordings`+`upload_sessions.transcription_model`, `processing_jobs.stage/progress`, `app_settings`); `PUT /models/default` (future rows only); finalize/session override (finalize wins); faster-whisper model cache + fail-fast unavailable errors; 10 new tests, backend suite 64 green + ruff clean |
| M9 | Backend: full-text search endpoints + filters, exports (audio/txt/md/zip), deletion sweep | TODO (schema/FTS columns exist) | | M9 | Backend: full-text search endpoints + filters, exports (audio/txt/md/zip), deletion sweep | TODO (schema/FTS columns exist) |
| M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO | | M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO |

View file

@ -1,6 +1,34 @@
{ {
"components": { "components": {
"schemas": { "schemas": {
"DefaultModelOut": {
"properties": {
"default_model": {
"title": "Default Model",
"type": "string"
}
},
"required": [
"default_model"
],
"title": "DefaultModelOut",
"type": "object"
},
"DefaultModelUpdate": {
"properties": {
"model": {
"maxLength": 32,
"minLength": 1,
"title": "Model",
"type": "string"
}
},
"required": [
"model"
],
"title": "DefaultModelUpdate",
"type": "object"
},
"DeleteAccountRequest": { "DeleteAccountRequest": {
"properties": { "properties": {
"password": { "password": {
@ -120,6 +148,32 @@
"title": "LogoutRequest", "title": "LogoutRequest",
"type": "object" "type": "object"
}, },
"ModelsOut": {
"properties": {
"default_model": {
"title": "Default Model",
"type": "string"
},
"faster_whisper_installed": {
"title": "Faster Whisper Installed",
"type": "boolean"
},
"models": {
"items": {
"$ref": "#/components/schemas/TranscriptionModelOut"
},
"title": "Models",
"type": "array"
}
},
"required": [
"default_model",
"faster_whisper_installed",
"models"
],
"title": "ModelsOut",
"type": "object"
},
"ProcessingJobOut": { "ProcessingJobOut": {
"properties": { "properties": {
"attempt": { "attempt": {
@ -157,6 +211,28 @@
"title": "Max Attempts", "title": "Max Attempts",
"type": "integer" "type": "integer"
}, },
"progress": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Progress"
},
"stage": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Stage"
},
"started_at": { "started_at": {
"anyOf": [ "anyOf": [
{ {
@ -180,6 +256,8 @@
"attempt", "attempt",
"max_attempts", "max_attempts",
"error", "error",
"stage",
"progress",
"started_at", "started_at",
"finished_at" "finished_at"
], ],
@ -255,6 +333,18 @@
} }
], ],
"title": "Recorded At" "title": "Recorded At"
},
"transcription_model": {
"anyOf": [
{
"maxLength": 32,
"type": "string"
},
{
"type": "null"
}
],
"title": "Transcription Model"
} }
}, },
"title": "RecordingFinalize", "title": "RecordingFinalize",
@ -397,6 +487,17 @@
"title": "Title", "title": "Title",
"type": "string" "type": "string"
}, },
"transcription_model": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Transcription Model"
},
"updated_at": { "updated_at": {
"format": "date-time", "format": "date-time",
"title": "Updated At", "title": "Updated At",
@ -413,6 +514,7 @@
"longitude", "longitude",
"processing_status", "processing_status",
"processing_error", "processing_error",
"transcription_model",
"has_audio", "has_audio",
"mime_type", "mime_type",
"size_bytes", "size_bytes",
@ -628,6 +730,22 @@
"title": "SummaryOut", "title": "SummaryOut",
"type": "object" "type": "object"
}, },
"SummaryUpdate": {
"description": "User edit for the structured summary (same versioning rule).",
"properties": {
"content": {
"additionalProperties": true,
"maxProperties": 100,
"title": "Content",
"type": "object"
}
},
"required": [
"content"
],
"title": "SummaryUpdate",
"type": "object"
},
"TokenPair": { "TokenPair": {
"properties": { "properties": {
"access_token": { "access_token": {
@ -740,6 +858,102 @@
"title": "TranscriptOut", "title": "TranscriptOut",
"type": "object" "type": "object"
}, },
"TranscriptUpdate": {
"description": "User edit: stored as a new version with edited_by_user=True.\n\nThe AI pipeline never overwrites a newest user-edited row, so edits\nare verdicts. Segments are optional \u2014 when omitted the previous\nsegments are dropped (plain-text correction).",
"properties": {
"language": {
"anyOf": [
{
"maxLength": 16,
"type": "string"
},
{
"type": "null"
}
],
"title": "Language"
},
"segments": {
"anyOf": [
{
"items": {
"$ref": "#/components/schemas/SegmentOut"
},
"maxItems": 2000,
"type": "array"
},
{
"type": "null"
}
],
"title": "Segments"
},
"text": {
"maxLength": 200000,
"minLength": 1,
"title": "Text",
"type": "string"
}
},
"required": [
"text"
],
"title": "TranscriptUpdate",
"type": "object"
},
"TranscriptionModelOut": {
"properties": {
"approx_memory": {
"title": "Approx Memory",
"type": "string"
},
"available": {
"title": "Available",
"type": "boolean"
},
"description": {
"title": "Description",
"type": "string"
},
"display_name": {
"title": "Display Name",
"type": "string"
},
"downloaded": {
"title": "Downloaded",
"type": "boolean"
},
"is_default": {
"title": "Is Default",
"type": "boolean"
},
"name": {
"title": "Name",
"type": "string"
},
"params": {
"title": "Params",
"type": "string"
},
"relative_speed": {
"title": "Relative Speed",
"type": "string"
}
},
"required": [
"name",
"display_name",
"description",
"params",
"approx_memory",
"relative_speed",
"is_default",
"downloaded",
"available"
],
"title": "TranscriptionModelOut",
"type": "object"
},
"UploadSessionCreate": { "UploadSessionCreate": {
"properties": { "properties": {
"client_recording_id": { "client_recording_id": {
@ -775,6 +989,18 @@
} }
], ],
"title": "Title" "title": "Title"
},
"transcription_model": {
"anyOf": [
{
"maxLength": 32,
"type": "string"
},
{
"type": "null"
}
],
"title": "Transcription Model"
} }
}, },
"required": [ "required": [
@ -1318,6 +1544,149 @@
] ]
} }
}, },
"/api/v1/models": {
"get": {
"operationId": "list_models_api_v1_models_get",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelsOut"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "List Models",
"tags": [
"models"
]
}
},
"/api/v1/models/default": {
"get": {
"operationId": "get_default_model_api_v1_models_default_get",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DefaultModelOut"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "Get Default Model",
"tags": [
"models"
]
},
"put": {
"operationId": "put_default_model_api_v1_models_default_put",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DefaultModelUpdate"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DefaultModelOut"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "Put Default Model",
"tags": [
"models"
]
}
},
"/api/v1/models/{name}/download": {
"post": {
"operationId": "download_model_api_v1_models__name__download_post",
"parameters": [
{
"in": "path",
"name": "name",
"required": true,
"schema": {
"title": "Name",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TranscriptionModelOut"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "Download Model",
"tags": [
"models"
]
}
},
"/api/v1/provider-info": { "/api/v1/provider-info": {
"get": { "get": {
"description": "Static, unauthenticated deployment identity for client probing.", "description": "Static, unauthenticated deployment identity for client probing.",
@ -1756,6 +2125,63 @@
"uploads", "uploads",
"recordings" "recordings"
] ]
},
"put": {
"operationId": "update_summary_api_v1_recordings__recording_id__summary_put",
"parameters": [
{
"in": "path",
"name": "recording_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Recording Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SummaryUpdate"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SummaryOut"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "Update Summary",
"tags": [
"uploads",
"recordings"
]
} }
}, },
"/api/v1/recordings/{recording_id}/transcript": { "/api/v1/recordings/{recording_id}/transcript": {
@ -1805,6 +2231,63 @@
"uploads", "uploads",
"recordings" "recordings"
] ]
},
"put": {
"operationId": "update_transcript_api_v1_recordings__recording_id__transcript_put",
"parameters": [
{
"in": "path",
"name": "recording_id",
"required": true,
"schema": {
"format": "uuid",
"title": "Recording Id",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TranscriptUpdate"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TranscriptOut"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "Update Transcript",
"tags": [
"uploads",
"recordings"
]
} }
}, },
"/api/v1/system/status": { "/api/v1/system/status": {