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:
parent
23714cfe7c
commit
8873c759e1
41 changed files with 4396 additions and 134 deletions
|
|
@ -9,6 +9,16 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<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
|
||||
android:name=".ShonarApplication"
|
||||
android:allowBackup="false"
|
||||
|
|
|
|||
|
|
@ -8,9 +8,14 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
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.provider.ProviderSelectionScreen
|
||||
import com.shonar.ui.provider.StorageScreen
|
||||
|
|
@ -32,6 +37,16 @@ class MainActivity : ComponentActivity() {
|
|||
HomeScreen(
|
||||
onOpenSettings = { nav.navigate("settings") },
|
||||
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") {
|
||||
|
|
@ -40,10 +55,29 @@ class MainActivity : ComponentActivity() {
|
|||
onSwitchProvider = { nav.navigate("provider") },
|
||||
)
|
||||
}
|
||||
composable("provider") {
|
||||
composable("provider") { entry ->
|
||||
val pick by entry.savedStateHandle
|
||||
.getStateFlow<String?>("picked_path", null)
|
||||
.collectAsState()
|
||||
ProviderSelectionScreen(
|
||||
onDone = { 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() }) }
|
||||
|
|
|
|||
|
|
@ -28,7 +28,19 @@ class ShonarApplication : Application() {
|
|||
}
|
||||
|
||||
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) }
|
||||
|
|
@ -63,6 +75,7 @@ class ShonarApplication : Application() {
|
|||
tlsPolicy = com.shonar.provider.TlsPolicy(
|
||||
tofu = tofu,
|
||||
bodiesEnabled = bodyLogging::get,
|
||||
sink = { msg -> android.util.Log.d("ShonarNet", msg) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -73,6 +86,8 @@ class ShonarApplication : Application() {
|
|||
settingsManager.ensureLoaded()
|
||||
// TOFU pins must be in cache before any TLS handshake needs them.
|
||||
tofu.refresh()
|
||||
// Point new recordings at the picked folder (if any).
|
||||
runCatching { recordingRepository.refreshRoot() }
|
||||
syncBodyLoggingFlag()
|
||||
scheduleSync()
|
||||
settingsManager.valuesChanged.collect {
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@ import okhttp3.Response
|
|||
* refresh, and concurrent 401s serialize on [refreshMutex] — two parallel
|
||||
* refreshes would look like token reuse and burn the whole family.
|
||||
*
|
||||
* Sidecars: the backend has no sidecar endpoints until M7 (transcripts),
|
||||
* so transcript/summary JSON is cached in app-private storage keyed by the
|
||||
* remote recording id. Same layout as LocalOnlyProvider, so the M7
|
||||
* migration is a file walk, not a format change.
|
||||
* Sidecars: file cache under [sidecarRoot] keyed by remote recording id
|
||||
* (same layout as LocalOnlyProvider). AI content (transcript/summary/jobs)
|
||||
* goes through the real M7 endpoints instead — see [fetchTranscript] and
|
||||
* friends, consumed by the M8 details screen.
|
||||
*
|
||||
* Cancellation safety: a cancelled upload leaves an open server session
|
||||
* with some chunks stored — invisible until finalize, resumable via the
|
||||
|
|
@ -55,6 +55,9 @@ class CustomShonarProvider(
|
|||
private val sidecarRoot: File,
|
||||
private val client: OkHttpClient = defaultClient(),
|
||||
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 {
|
||||
|
||||
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) ----
|
||||
|
||||
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) {
|
||||
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 ->
|
||||
when (resp.code) {
|
||||
200 -> {
|
||||
|
|
@ -445,6 +569,16 @@ class CustomShonarProvider(
|
|||
.post(json.toRequestBody("application/json; charset=utf-8".toMediaType()))
|
||||
.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 {
|
||||
val req = Request.Builder().url(base + path)
|
||||
.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).
|
||||
private suspend fun createSession(base: String, draft: RecordingDraft): Pair<String, Int> {
|
||||
val body = """{"declared_mime_type":${jsonStr(draft.mime)},""" +
|
||||
""""declared_size_bytes":${draft.sizeBytes},""" +
|
||||
""""title":${jsonStr(draft.title)},""" +
|
||||
""""client_recording_id":${jsonStr(draft.id)}}"""
|
||||
val body = buildString {
|
||||
append("""{"declared_mime_type":${jsonStr(draft.mime)},""")
|
||||
append(""""declared_size_bytes":${draft.sizeBytes},""")
|
||||
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 ->
|
||||
when (resp.code) {
|
||||
201 -> {
|
||||
|
|
@ -494,8 +634,14 @@ class CustomShonarProvider(
|
|||
|
||||
private suspend fun finalize(base: String, sessionId: String, draft: RecordingDraft): String {
|
||||
val recordedAt = Instant.ofEpochMilli(draft.createdAtEpochMs).toString()
|
||||
val body = """{"recorded_at":${jsonStr(recordedAt)},""" +
|
||||
""""duration_seconds":${draft.durationMs / 1000.0}}"""
|
||||
val body = buildString {
|
||||
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) }
|
||||
.use { resp ->
|
||||
if (resp.code != 201) {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,39 @@ class FolderSyncProvider(
|
|||
_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) {
|
||||
val root = storedRoot()
|
||||
if (root == null) {
|
||||
|
|
@ -146,10 +179,11 @@ class FolderSyncProvider(
|
|||
val audio = File(r, "audio").listFiles()?.filter { it.isFile } ?: emptyList()
|
||||
StorageLocation(
|
||||
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,
|
||||
localOnlyCount = audio.size,
|
||||
bytesUsed = audio.sumOf { it.length() },
|
||||
path = r.path,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,17 @@ import java.io.File
|
|||
* 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
|
||||
* 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(
|
||||
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 {
|
||||
|
||||
override val descriptor = ProviderDescriptor(
|
||||
|
|
@ -22,17 +30,93 @@ class LocalOnlyProvider(
|
|||
private val _authState = MutableStateFlow(AuthState.CONNECTED)
|
||||
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 =
|
||||
ProbeResult.Incompatible // local-only never talks to servers
|
||||
|
||||
override suspend fun connect(credential: ProviderCredential) {
|
||||
if (credential != ProviderCredential.None) throw ProviderError.InvalidUrl(
|
||||
when (credential) {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
// Nothing to revoke; DISCONNECTED means "user left local mode" — the
|
||||
|
|
@ -41,19 +125,25 @@ class LocalOnlyProvider(
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ---- storage -----------------------------------------------------------
|
||||
|
||||
private fun audioFile(ref: RemoteRef) = File(root, ref.key)
|
||||
private fun sidecarFile(ref: RemoteRef, kind: SidecarKind) =
|
||||
File(root, "sidecars/${ref.key}/${kind.fileName}")
|
||||
private suspend fun audioFile(ref: RemoteRef) = File(effectiveRoot(), ref.key)
|
||||
private suspend fun sidecarFile(ref: RemoteRef, kind: SidecarKind) =
|
||||
File(effectiveRoot(), "sidecars/${ref.key}/${kind.fileName}")
|
||||
|
||||
override suspend fun upload(draft: RecordingDraft, onProgress: (Float) -> Unit): RemoteRef {
|
||||
val key = "audio/${draft.id}"
|
||||
val dest = File(root, key)
|
||||
val dest = File(effectiveRoot(), key)
|
||||
dest.parentFile?.mkdirs()
|
||||
// "Upload" locally = copy; report progress in slices so UI behaves uniformly
|
||||
draft.sourceFile.inputStream().use { input ->
|
||||
|
|
@ -82,11 +172,11 @@ class LocalOnlyProvider(
|
|||
|
||||
override suspend fun delete(ref: RemoteRef) {
|
||||
audioFile(ref).delete()
|
||||
File(root, "sidecars/${ref.key}").deleteRecursively()
|
||||
File(effectiveRoot(), "sidecars/${ref.key}").deleteRecursively()
|
||||
}
|
||||
|
||||
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()
|
||||
// single page; no paging for local storage
|
||||
val items = files.map { f ->
|
||||
|
|
@ -113,18 +203,35 @@ class LocalOnlyProvider(
|
|||
}
|
||||
|
||||
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 = "On this device only",
|
||||
detail = "Recordings and transcripts never leave your phone.",
|
||||
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(
|
||||
headline = if (custom != null) "Your folder" else "On this device only",
|
||||
detail = if (custom != null) "Recordings stay in your folder and never leave your phone."
|
||||
else "Recordings and transcripts never leave your phone.",
|
||||
syncedCount = 0,
|
||||
localOnlyCount = audio.size,
|
||||
bytesUsed = audio.sumOf { it.length() },
|
||||
path = r.absolutePath,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ID = "local-only"
|
||||
const val KEY_ROOT = "provider.local-only.root"
|
||||
|
||||
private fun File.sha256Hex(): String {
|
||||
val md = java.security.MessageDigest.getInstance("SHA-256")
|
||||
|
|
|
|||
|
|
@ -44,8 +44,9 @@ class ProviderRegistry(
|
|||
const val SYNC_FOLDER_ID = FolderSyncProvider.ID
|
||||
|
||||
/** 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())),
|
||||
sink = sink,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -77,7 +78,12 @@ class ProviderRegistry(
|
|||
val folder = FolderSyncProvider(pathStore = plainStore)
|
||||
return ProviderRegistry(
|
||||
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 },
|
||||
NEXTCLOUD_ID to { nextcloud },
|
||||
SYNC_FOLDER_ID to { folder },
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ data class RecordingDraft(
|
|||
val mime: String, // audio/mp4 | audio/wav | audio/ogg
|
||||
val sourceFile: File,
|
||||
val sizeBytes: Long,
|
||||
/** Optional per-recording transcription model override (null = server default). */
|
||||
val transcriptionModel: String? = null,
|
||||
)
|
||||
|
||||
enum class SidecarKind(val fileName: String) {
|
||||
|
|
@ -200,6 +202,8 @@ data class StorageLocation(
|
|||
val syncedCount: Int,
|
||||
val localOnlyCount: Int,
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -9,11 +9,15 @@ import okhttp3.Response
|
|||
* 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
|
||||
* 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(
|
||||
val tofu: TofuManager,
|
||||
private val bodiesEnabled: () -> Boolean = { false },
|
||||
private val sink: (String) -> Unit = { msg -> android.util.Log.d("ShonarNet", msg) },
|
||||
private val sink: (String) -> Unit,
|
||||
) {
|
||||
fun newClient(
|
||||
connectTimeoutS: Long,
|
||||
|
|
|
|||
225
android/app/src/main/java/com/shonar/recording/AiContent.kt
Normal file
225
android/app/src/main/java/com/shonar/recording/AiContent.kt
Normal 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()
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
package com.shonar.recording
|
||||
|
||||
import android.content.Context
|
||||
import com.shonar.provider.RecordingDraft
|
||||
import com.shonar.provider.RemoteRef
|
||||
import com.shonar.provider.SyncState
|
||||
|
|
@ -11,18 +10,82 @@ import java.util.Date
|
|||
import java.util.UUID
|
||||
|
||||
class RecordingRepository(
|
||||
private val context: Context,
|
||||
appFilesDir: File,
|
||||
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 recordingsDir = File(context.filesDir, "recordings").apply { mkdirs() }
|
||||
private val tempDir = File(recordingsDir, ".in-progress").apply { mkdirs() }
|
||||
private val sandboxDir: File = appFilesDir
|
||||
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()
|
||||
|
||||
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(
|
||||
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) {
|
||||
val canonicalRoot = recordingsDir.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()
|
||||
dao.delete(recording)
|
||||
}
|
||||
|
|
@ -73,4 +171,30 @@ class RecordingRepository(
|
|||
* nothing remote is touched — forgetting is metadata-only.
|
||||
*/
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
507
android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt
Normal file
507
android/app/src/main/java/com/shonar/ui/detail/DetailsScreen.kt
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
48
android/app/src/main/java/com/shonar/ui/folder/FolderList.kt
Normal file
48
android/app/src/main/java/com/shonar/ui/folder/FolderList.kt
Normal 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."))
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.verticalScroll
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
|
|
@ -21,6 +22,7 @@ import androidx.compose.material3.CardDefaults
|
|||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -55,6 +57,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||
fun HomeScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenStorage: () -> Unit,
|
||||
onOpenDetail: (String) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as ShonarApplication
|
||||
|
|
@ -62,9 +65,22 @@ fun HomeScreen(
|
|||
val recorderState by vm.recorderState.collectAsState()
|
||||
val recordings by vm.recordings.collectAsState()
|
||||
val playingId by vm.playingId.collectAsState()
|
||||
val notice by vm.notice.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var consentSeen by remember { mutableStateOf<Boolean?>(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(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> if (granted) vm.start() }
|
||||
|
|
@ -79,13 +95,20 @@ fun HomeScreen(
|
|||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(headlineTick) {
|
||||
app.settingsManager.ensureLoaded()
|
||||
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)
|
||||
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)
|
||||
storagePath = summary?.path
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
|
@ -126,7 +149,7 @@ fun HomeScreen(
|
|||
StorageChip(headline = headline, onClick = onOpenStorage)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
QuickCard("Choose storage", storageHeadline ?: "Where recordings live",
|
||||
QuickCard("Choose storage", storagePath ?: storageHeadline ?: "Where recordings live",
|
||||
Icons.Filled.Cloud, onOpenStorage)
|
||||
QuickCard("Settings", "Appearance, custom settings",
|
||||
Icons.Filled.Settings, onOpenSettings)
|
||||
|
|
@ -141,12 +164,19 @@ fun HomeScreen(
|
|||
if (recordings.isNotEmpty()) {
|
||||
Text("Your recordings", style = MaterialTheme.typography.titleLarge,
|
||||
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 ->
|
||||
RecordingRow(
|
||||
recording = recording,
|
||||
playing = playingId == recording.id,
|
||||
onPlay = { vm.togglePlayback(recording) },
|
||||
onDelete = { vm.delete(recording) },
|
||||
onOpen = { onOpenDetail(recording.id) },
|
||||
onRename = { vm.rename(recording, it) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -199,8 +229,12 @@ private fun RecordingRow(
|
|||
playing: Boolean,
|
||||
onPlay: () -> 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(
|
||||
Modifier.fillMaxWidth().padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
|
@ -214,8 +248,55 @@ private fun RecordingRow(
|
|||
)
|
||||
}
|
||||
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") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
|
|||
val recorderState = RecordingService.snapshot
|
||||
private val _playingId = kotlinx.coroutines.flow.MutableStateFlow<String?>(null)
|
||||
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
|
||||
|
||||
fun start() = RecordingService.command(app, RecordingService.ACTION_START)
|
||||
|
|
@ -44,8 +47,20 @@ class RecordingViewModel(private val app: ShonarApplication) : ViewModel() {
|
|||
}
|
||||
|
||||
fun delete(recording: RecordingEntity) {
|
||||
_notice.value = null
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import androidx.compose.material3.LinearProgressIndicator
|
|||
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.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
|
@ -29,6 +30,7 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -54,12 +56,35 @@ fun ProviderSelectionScreen(
|
|||
onDone: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
vm: ProviderSelectionViewModel = viewModel(factory = ProviderSelectionViewModel.Factory),
|
||||
browsePick: String? = null,
|
||||
onPickConsumed: () -> Unit = {},
|
||||
onBrowse: () -> Unit = {},
|
||||
) {
|
||||
val state by vm.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var selectedId by remember { mutableStateOf<String?>(null) }
|
||||
var urlInput 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(
|
||||
topBar = {
|
||||
|
|
@ -79,14 +104,14 @@ fun ProviderSelectionScreen(
|
|||
) {
|
||||
item {
|
||||
Text(
|
||||
"You stay in control: recordings, transcripts, and summaries go where " +
|
||||
"you choose. You can switch providers later without losing anything.",
|
||||
"Recordings stay on this phone, in your own folder if you " +
|
||||
"like. You can switch later without losing anything.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
items(vm.options) { opt ->
|
||||
items(vm.simpleOptions) { opt ->
|
||||
ProviderCard(
|
||||
option = opt,
|
||||
selected = selectedId == opt.id,
|
||||
|
|
@ -95,11 +120,40 @@ fun ProviderSelectionScreen(
|
|||
if (!opt.available) {
|
||||
// surface the honest reason immediately
|
||||
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 }
|
||||
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) {
|
||||
item {
|
||||
OutlinedTextField(
|
||||
|
|
@ -123,17 +177,56 @@ fun ProviderSelectionScreen(
|
|||
OutlinedTextField(
|
||||
value = pathInput,
|
||||
onValueChange = { pathInput = it },
|
||||
label = { Text("Folder path (created by your sync tool)") },
|
||||
label = { Text("Folder path (or browse below)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedButton(
|
||||
onClick = onBrowse,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Browse my folders…") }
|
||||
}
|
||||
item {
|
||||
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(),
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,13 @@ data class ProviderOption(
|
|||
val blurb: String,
|
||||
val needsUrl: Boolean,
|
||||
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 {
|
||||
data object Idle : SelectionUiState
|
||||
|
|
@ -69,9 +74,6 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
|
|||
private val nextcloudProvider: com.shonar.provider.ShonarProvider by lazy {
|
||||
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 migrationJob: kotlinx.coroutines.Job? = null
|
||||
private var migratingTo: String? = null
|
||||
|
|
@ -87,12 +89,23 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
|
|||
val state: StateFlow<SelectionUiState> = _state.asStateFlow()
|
||||
|
||||
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(
|
||||
id = ProviderRegistry.NEXTCLOUD_ID,
|
||||
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,
|
||||
available = true, // P4
|
||||
advanced = true,
|
||||
),
|
||||
ProviderOption(
|
||||
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.",
|
||||
needsUrl = true,
|
||||
available = true, // P3
|
||||
),
|
||||
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,
|
||||
advanced = true,
|
||||
),
|
||||
ProviderOption(
|
||||
id = "start9",
|
||||
|
|
@ -117,6 +122,7 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
|
|||
"Nextcloud or SHONAR automatically.",
|
||||
needsUrl = true,
|
||||
available = true, // P6a: generic hosted setup (no platform RPC)
|
||||
advanced = true,
|
||||
),
|
||||
ProviderOption(
|
||||
id = "umbrel",
|
||||
|
|
@ -125,16 +131,16 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
|
|||
"Nextcloud or SHONAR automatically.",
|
||||
needsUrl = true,
|
||||
available = true, // P6a: generic hosted setup (no platform RPC)
|
||||
),
|
||||
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,
|
||||
advanced = 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) {
|
||||
viewModelScope.launch {
|
||||
pollJob?.cancel()
|
||||
|
|
@ -144,9 +150,14 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
|
|||
return@launch
|
||||
}
|
||||
if (!option.needsUrl) {
|
||||
// Local-only saves straight away; the sync folder has its
|
||||
// own path step (connectFolder).
|
||||
if (option.needsPath) return@launch
|
||||
// Local-only saves straight away (its folder is optional —
|
||||
// the path step below only overrides it). The sync folder
|
||||
// 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, "") }
|
||||
.onFailure { _state.value = SelectionUiState.Error(it.message ?: "Could not save provider") }
|
||||
return@launch
|
||||
|
|
@ -394,22 +405,31 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
|
|||
}
|
||||
|
||||
/**
|
||||
* P4 sync folder: validate the directory and remember it. The folder
|
||||
* must already exist (created by you or by Syncthing) — a typo is an
|
||||
* error, never a silently created directory.
|
||||
* Use a folder for a path-capable provider (on-this-phone or sync
|
||||
* folder). Blank path means "just the default": valid for on-this-phone
|
||||
* (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 {
|
||||
val path = rawPath.trim()
|
||||
if (path.isEmpty()) {
|
||||
if (providerId == com.shonar.provider.LocalOnlyProvider.ID) {
|
||||
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
|
||||
}
|
||||
_state.value = SelectionUiState.Probing(path)
|
||||
runCatching {
|
||||
folderProvider.connect(
|
||||
registry.provider(providerId).connect(
|
||||
com.shonar.provider.ProviderCredential.FolderPath(
|
||||
accountLabel = path,
|
||||
path = path,
|
||||
|
|
@ -419,7 +439,42 @@ class ProviderSelectionViewModel(app: ShonarApplication) : ViewModel() {
|
|||
_state.value = SelectionUiState.Error(it.message ?: "Could not use $path")
|
||||
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("\"", "\\\"") + "\"",
|
||||
)
|
||||
}
|
||||
// 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
|
||||
// to it — nothing moves or is forgotten silently.
|
||||
val count = runCatching { dao.getAll().size }.getOrDefault(0)
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.shonar.provider.AuthState
|
||||
import com.shonar.provider.LocalOnlyProvider
|
||||
|
||||
/**
|
||||
* P7 "Where is my data?": live provider summary plus the account
|
||||
|
|
@ -136,6 +137,13 @@ private fun ReadyBody(
|
|||
)
|
||||
ready.summary?.let {
|
||||
Text(it.headline, style = MaterialTheme.typography.titleMedium)
|
||||
it.path?.let { path ->
|
||||
Text(
|
||||
path,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
it.detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
|
@ -159,14 +167,18 @@ private fun ReadyBody(
|
|||
}
|
||||
|
||||
val enabled = ready.busy == null
|
||||
val isLocal = ready.providerId == LocalOnlyProvider.ID
|
||||
Button(onClick = onSwitchProvider, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Location")
|
||||
}
|
||||
// On-device storage has no accounts, servers, or sync jobs — just the
|
||||
// location. Everything below is server/provider lifecycle.
|
||||
if (!isLocal) {
|
||||
if (ready.auth != AuthState.CONNECTED) {
|
||||
Button(onClick = onReconnect, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Reconnect")
|
||||
}
|
||||
}
|
||||
Button(onClick = onSwitchProvider, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Switch provider")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onPauseSync,
|
||||
enabled = enabled,
|
||||
|
|
@ -187,6 +199,7 @@ private fun ReadyBody(
|
|||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Delete account & data") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun authLabel(auth: AuthState): String = when (auth) {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
143
android/app/src/test/java/com/shonar/recording/AiContentTest.kt
Normal file
143
android/app/src/test/java/com/shonar/recording/AiContentTest.kt
Normal 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")))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
63
backend/migrations/versions/m8models000001_model_support.py
Normal file
63
backend/migrations/versions/m8models000001_model_support.py
Normal 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")
|
||||
|
|
@ -17,6 +17,9 @@ class UploadSessionCreate(BaseModel):
|
|||
declared_size_bytes: int = Field(gt=0)
|
||||
title: str | None = Field(default=None, max_length=300)
|
||||
client_recording_id: str | None = Field(default=None, max_length=64)
|
||||
# Optional per-recording transcription model override ("Use default"
|
||||
# when omitted). Validated against the model registry at finalize time.
|
||||
transcription_model: str | None = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
class UploadSessionOut(ORMModel):
|
||||
|
|
@ -43,6 +46,9 @@ class RecordingFinalize(BaseModel):
|
|||
longitude: float | None = Field(default=None, ge=-180, le=180)
|
||||
location_accuracy_m: float | None = Field(default=None, ge=0)
|
||||
notes: str | None = Field(default=None, max_length=20000)
|
||||
# Optional per-recording transcription model override. When both the
|
||||
# session and the finalize body specify one, the finalize body wins.
|
||||
transcription_model: str | None = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
class RecordingOut(ORMModel):
|
||||
|
|
@ -55,6 +61,9 @@ class RecordingOut(ORMModel):
|
|||
longitude: float | None
|
||||
processing_status: str
|
||||
processing_error: str | None
|
||||
# Effective transcription model saved at finalize time (override or the
|
||||
# global default then in force). None for pre-model rows.
|
||||
transcription_model: str | None
|
||||
has_audio: bool
|
||||
mime_type: str | None
|
||||
size_bytes: int | None
|
||||
|
|
@ -113,5 +122,28 @@ class ProcessingJobOut(ORMModel):
|
|||
attempt: int
|
||||
max_attempts: int
|
||||
error: str | None
|
||||
# Display-only phase within a running job ("loading-model",
|
||||
# "transcribing") plus 0-100 progress when known.
|
||||
stage: str | None
|
||||
progress: int | None
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
|
||||
|
||||
class TranscriptUpdate(BaseModel):
|
||||
"""User edit: stored as a new version with edited_by_user=True.
|
||||
|
||||
The AI pipeline never overwrites a newest user-edited row, so edits
|
||||
are verdicts. Segments are optional — when omitted the previous
|
||||
segments are dropped (plain-text correction).
|
||||
"""
|
||||
|
||||
text: str = Field(min_length=1, max_length=200000)
|
||||
segments: list[SegmentOut] | None = Field(default=None, max_length=2000)
|
||||
language: str | None = Field(default=None, max_length=16)
|
||||
|
||||
|
||||
class SummaryUpdate(BaseModel):
|
||||
"""User edit for the structured summary (same versioning rule)."""
|
||||
|
||||
content: dict = Field(max_length=100)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
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.include_router(health.router)
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(recordings.router)
|
||||
api_router.include_router(models.router)
|
||||
api_router.include_router(provider_info.router)
|
||||
|
||||
# Included as later milestones land:
|
||||
|
|
|
|||
132
backend/shonar/api/v1/models.py
Normal file
132
backend/shonar/api/v1/models.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Transcription model registry + global default (Stage 1).
|
||||
|
||||
- GET /models — supported models with display metadata, download and
|
||||
availability status, and which is the default.
|
||||
- GET /models/default — the current global default model name.
|
||||
- PUT /models/default — change the global default (affects future
|
||||
recordings only; saved per-recording models are never rewritten).
|
||||
- POST /models/{name}/download — fetch a model into the local cache
|
||||
(needs internet once; runs synchronously and may take minutes for
|
||||
large models).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from shonar.api.deps import CurrentUser, SessionDep
|
||||
from shonar.services.ai import ProviderConfigError
|
||||
from shonar.services.ai.model_registry import (
|
||||
SUPPORTED_TRANSCRIPTION_MODELS,
|
||||
get_global_default_model,
|
||||
is_faster_whisper_installed,
|
||||
is_model_downloaded,
|
||||
set_global_default_model,
|
||||
validate_model_name,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["models"])
|
||||
|
||||
|
||||
class TranscriptionModelOut(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
description: str
|
||||
params: str
|
||||
approx_memory: str
|
||||
relative_speed: str
|
||||
is_default: bool
|
||||
downloaded: bool
|
||||
available: bool
|
||||
|
||||
|
||||
class ModelsOut(BaseModel):
|
||||
default_model: str
|
||||
faster_whisper_installed: bool
|
||||
models: list[TranscriptionModelOut]
|
||||
|
||||
|
||||
class DefaultModelUpdate(BaseModel):
|
||||
model: str = Field(min_length=1, max_length=32)
|
||||
|
||||
|
||||
class DefaultModelOut(BaseModel):
|
||||
default_model: str
|
||||
|
||||
|
||||
async def _models_out(session: SessionDep) -> ModelsOut:
|
||||
default = await get_global_default_model(session)
|
||||
installed = is_faster_whisper_installed()
|
||||
return ModelsOut(
|
||||
default_model=default,
|
||||
faster_whisper_installed=installed,
|
||||
models=[
|
||||
TranscriptionModelOut(
|
||||
name=info.name,
|
||||
display_name=info.display_name,
|
||||
description=info.description,
|
||||
params=info.params,
|
||||
approx_memory=info.approx_memory,
|
||||
relative_speed=info.relative_speed,
|
||||
is_default=info.name == default,
|
||||
downloaded=is_model_downloaded(info.name),
|
||||
available=installed and is_model_downloaded(info.name),
|
||||
)
|
||||
for info in SUPPORTED_TRANSCRIPTION_MODELS.values()
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/models", response_model=ModelsOut)
|
||||
async def list_models(user: CurrentUser, session: SessionDep):
|
||||
return await _models_out(session)
|
||||
|
||||
|
||||
@router.get("/models/default", response_model=DefaultModelOut)
|
||||
async def get_default_model(user: CurrentUser, session: SessionDep):
|
||||
return DefaultModelOut(default_model=await get_global_default_model(session))
|
||||
|
||||
|
||||
@router.put("/models/default", response_model=DefaultModelOut)
|
||||
async def put_default_model(body: DefaultModelUpdate, user: CurrentUser, session: SessionDep):
|
||||
try:
|
||||
name = await set_global_default_model(session, body.model)
|
||||
except ProviderConfigError as e:
|
||||
raise HTTPException(422, str(e)) from None
|
||||
return DefaultModelOut(default_model=name)
|
||||
|
||||
|
||||
@router.post("/models/{name}/download", response_model=TranscriptionModelOut)
|
||||
async def download_model(name: str, user: CurrentUser, session: SessionDep):
|
||||
try:
|
||||
clean = validate_model_name(name)
|
||||
except ProviderConfigError as e:
|
||||
raise HTTPException(422, str(e)) from None
|
||||
if not is_faster_whisper_installed():
|
||||
raise HTTPException(
|
||||
501,
|
||||
"faster-whisper is not installed on this server "
|
||||
"(pip install shonar-backend[faster-whisper]).",
|
||||
)
|
||||
from shonar.services.ai.faster_whisper import _load_model
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_load_model, clean)
|
||||
except Exception as e: # noqa: BLE001 — surface download failures plainly
|
||||
raise HTTPException(500, f"Model download failed: {e}") from None
|
||||
default = await get_global_default_model(session)
|
||||
info = SUPPORTED_TRANSCRIPTION_MODELS[clean]
|
||||
return TranscriptionModelOut(
|
||||
name=info.name,
|
||||
display_name=info.display_name,
|
||||
description=info.description,
|
||||
params=info.params,
|
||||
approx_memory=info.approx_memory,
|
||||
relative_speed=info.relative_speed,
|
||||
is_default=info.name == default,
|
||||
downloaded=is_model_downloaded(clean),
|
||||
available=is_model_downloaded(clean),
|
||||
)
|
||||
|
|
@ -20,7 +20,9 @@ from shonar.api.schemas_recordings import (
|
|||
RecordingOut,
|
||||
RecordingUpdate,
|
||||
SummaryOut,
|
||||
SummaryUpdate,
|
||||
TranscriptOut,
|
||||
TranscriptUpdate,
|
||||
UploadSessionCreate,
|
||||
UploadSessionOut,
|
||||
UploadStatusOut,
|
||||
|
|
@ -61,6 +63,7 @@ async def _recording_out(session, rec: Recording) -> RecordingOut: # noqa: ANN0
|
|||
longitude=rec.longitude,
|
||||
processing_status=rec.processing_status.value,
|
||||
processing_error=rec.processing_error,
|
||||
transcription_model=rec.transcription_model,
|
||||
has_audio=original is not None,
|
||||
mime_type=original.mime_type if original else None,
|
||||
size_bytes=original.size_bytes if original else None,
|
||||
|
|
@ -78,7 +81,7 @@ async def create_upload(body: UploadSessionCreate, user: CurrentUser, session: S
|
|||
try:
|
||||
us = await up.create_session(
|
||||
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:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
|
|
@ -125,6 +128,7 @@ async def finalize_upload(
|
|||
session, user.id, session_id,
|
||||
recorded_at=body.recorded_at, duration_seconds=body.duration_seconds,
|
||||
latitude=lat, longitude=lon, location_accuracy_m=acc, notes=body.notes,
|
||||
transcription_model=body.transcription_model,
|
||||
)
|
||||
except up.UploadError as e:
|
||||
raise HTTPException(e.status_code, e.message) from None
|
||||
|
|
@ -297,19 +301,7 @@ async def get_transcript(
|
|||
)
|
||||
if row is None:
|
||||
raise HTTPException(404, "No transcript yet")
|
||||
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,
|
||||
)
|
||||
return _transcript_out(row)
|
||||
|
||||
|
||||
@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)
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -143,6 +143,11 @@ class Recording(Base, PublicIdMixin):
|
|||
index=True,
|
||||
)
|
||||
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
|
||||
# recording (at most one, enforced by a partial unique index). Keeping
|
||||
# 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))
|
||||
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_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
chunk_size_bytes: Mapped[int] = mapped_column(
|
||||
|
|
@ -399,6 +408,12 @@ class ProcessingJob(Base, PublicIdMixin):
|
|||
attempt: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||
max_attempts: Mapped[int] = mapped_column(default=3, nullable=False)
|
||||
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))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
# 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
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -9,9 +9,38 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
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:
|
||||
|
|
@ -19,12 +48,12 @@ class FasterWhisperProvider:
|
|||
|
||||
def __init__(self, model: str = "base") -> None:
|
||||
try:
|
||||
from faster_whisper import WhisperModel # noqa: F401
|
||||
import faster_whisper # noqa: F401
|
||||
except ImportError as e:
|
||||
raise ProviderConfigError(
|
||||
"faster-whisper is not installed (pip install shonar-backend[faster-whisper])."
|
||||
) from e
|
||||
self.model = model or "base"
|
||||
self.model = validate_model_name(model or "base")
|
||||
|
||||
async def transcribe(
|
||||
self,
|
||||
|
|
@ -37,15 +66,14 @@ class FasterWhisperProvider:
|
|||
return await asyncio.to_thread(self._run, audio, language_hint)
|
||||
|
||||
def _run(self, audio: bytes, language_hint: str | None) -> TranscriptResult:
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
path: Path | None = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".m4a", delete=False) as f:
|
||||
f.write(audio)
|
||||
path = Path(f.name)
|
||||
model = WhisperModel(self.model, device="auto")
|
||||
segments_iter, info = model.transcribe(
|
||||
ensure_model_available(self.model)
|
||||
model = _load_model(self.model)
|
||||
segments_iter, info = model.transcribe( # type: ignore[union-attr]
|
||||
str(path),
|
||||
beam_size=5,
|
||||
language=language_hint,
|
||||
|
|
|
|||
165
backend/shonar/services/ai/model_registry.py
Normal file
165
backend/shonar/services/ai/model_registry.py
Normal 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
|
||||
|
|
@ -112,6 +112,8 @@ async def reset_or_create(
|
|||
existing.status = JobStatus.queued
|
||||
existing.attempt = 0
|
||||
existing.error = None
|
||||
existing.stage = None
|
||||
existing.progress = None
|
||||
existing.started_at = None
|
||||
existing.finished_at = None
|
||||
return True
|
||||
|
|
@ -227,6 +229,7 @@ async def _fail(
|
|||
raise ProviderTransientError(message)
|
||||
job.status = JobStatus.failed
|
||||
job.error = message
|
||||
job.stage = None
|
||||
job.finished_at = utcnow()
|
||||
rec.processing_status = ProcessingStatus.failed
|
||||
rec.processing_error = message
|
||||
|
|
@ -252,6 +255,24 @@ async def run_transcribe(ctx: dict, recording_id: str) -> None:
|
|||
await session.commit()
|
||||
await _maybe_chain_summarize(session, rec)
|
||||
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(
|
||||
select(Asset).where(
|
||||
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.attempt = _job_try(ctx)
|
||||
job.started_at = utcnow()
|
||||
job.stage = "loading-model"
|
||||
job.progress = None
|
||||
rec.processing_status = ProcessingStatus.processing
|
||||
rec.processing_error = None
|
||||
await session.flush()
|
||||
try:
|
||||
audio = await get_storage().get(original.storage_key)
|
||||
job.stage = "transcribing"
|
||||
await session.flush()
|
||||
result = await provider.transcribe(audio, original.mime_type)
|
||||
except AIError as 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", ""),
|
||||
)
|
||||
job.status = JobStatus.succeeded
|
||||
job.stage = None
|
||||
job.progress = 100
|
||||
job.finished_at = utcnow()
|
||||
await session.flush()
|
||||
await _maybe_chain_summarize(session, rec)
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ async def create_session(
|
|||
declared_size_bytes: int,
|
||||
title: str | None,
|
||||
client_recording_id: str | None,
|
||||
transcription_model: str | None = None,
|
||||
) -> UploadSession:
|
||||
settings = get_settings()
|
||||
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,
|
||||
chunk_size_bytes=settings.max_chunk_bytes,
|
||||
expires_at=utcnow() + SESSION_TTL,
|
||||
transcription_model=(transcription_model.strip() if transcription_model else None),
|
||||
)
|
||||
session.add(us)
|
||||
await session.flush()
|
||||
|
|
@ -165,10 +167,29 @@ async def finalize(
|
|||
longitude: float | None = None,
|
||||
location_accuracy_m: float | None = None,
|
||||
notes: str | None = None,
|
||||
transcription_model: str | None = None,
|
||||
) -> tuple[UploadSession, Recording, Asset]:
|
||||
"""Assemble chunks, validate, store the immutable original, and create or
|
||||
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)
|
||||
# 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:
|
||||
# Already finalized: return existing recording (retry-safe client).
|
||||
asset = await session.get(Asset, us.completed_asset_id)
|
||||
|
|
@ -230,6 +251,10 @@ async def finalize(
|
|||
us.status = UploadSessionStatus.completed
|
||||
us.completed_asset_id = existing_original.id
|
||||
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()
|
||||
from shonar.services import processing as _processing
|
||||
|
||||
|
|
@ -276,6 +301,7 @@ async def finalize(
|
|||
longitude=longitude,
|
||||
location_accuracy_m=location_accuracy_m,
|
||||
processing_status=ProcessingStatus.uploaded,
|
||||
transcription_model=model,
|
||||
)
|
||||
session.add(recording)
|
||||
await session.flush()
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ async def clean_db(_setup_db: None) -> AsyncIterator[None]:
|
|||
text(
|
||||
"TRUNCATE users, devices, refresh_tokens, recordings, assets, "
|
||||
"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()
|
||||
|
|
|
|||
245
backend/tests/test_models.py
Normal file
245
backend/tests/test_models.py
Normal 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)
|
||||
189
backend/tests/test_transcript_edits.py
Normal file
189
backend/tests/test_transcript_edits.py
Normal 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
|
||||
|
|
@ -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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,34 @@
|
|||
{
|
||||
"components": {
|
||||
"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": {
|
||||
"properties": {
|
||||
"password": {
|
||||
|
|
@ -120,6 +148,32 @@
|
|||
"title": "LogoutRequest",
|
||||
"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": {
|
||||
"properties": {
|
||||
"attempt": {
|
||||
|
|
@ -157,6 +211,28 @@
|
|||
"title": "Max Attempts",
|
||||
"type": "integer"
|
||||
},
|
||||
"progress": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Progress"
|
||||
},
|
||||
"stage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Stage"
|
||||
},
|
||||
"started_at": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -180,6 +256,8 @@
|
|||
"attempt",
|
||||
"max_attempts",
|
||||
"error",
|
||||
"stage",
|
||||
"progress",
|
||||
"started_at",
|
||||
"finished_at"
|
||||
],
|
||||
|
|
@ -255,6 +333,18 @@
|
|||
}
|
||||
],
|
||||
"title": "Recorded At"
|
||||
},
|
||||
"transcription_model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 32,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Transcription Model"
|
||||
}
|
||||
},
|
||||
"title": "RecordingFinalize",
|
||||
|
|
@ -397,6 +487,17 @@
|
|||
"title": "Title",
|
||||
"type": "string"
|
||||
},
|
||||
"transcription_model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Transcription Model"
|
||||
},
|
||||
"updated_at": {
|
||||
"format": "date-time",
|
||||
"title": "Updated At",
|
||||
|
|
@ -413,6 +514,7 @@
|
|||
"longitude",
|
||||
"processing_status",
|
||||
"processing_error",
|
||||
"transcription_model",
|
||||
"has_audio",
|
||||
"mime_type",
|
||||
"size_bytes",
|
||||
|
|
@ -628,6 +730,22 @@
|
|||
"title": "SummaryOut",
|
||||
"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": {
|
||||
"properties": {
|
||||
"access_token": {
|
||||
|
|
@ -740,6 +858,102 @@
|
|||
"title": "TranscriptOut",
|
||||
"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": {
|
||||
"properties": {
|
||||
"client_recording_id": {
|
||||
|
|
@ -775,6 +989,18 @@
|
|||
}
|
||||
],
|
||||
"title": "Title"
|
||||
},
|
||||
"transcription_model": {
|
||||
"anyOf": [
|
||||
{
|
||||
"maxLength": 32,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Transcription Model"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"get": {
|
||||
"description": "Static, unauthenticated deployment identity for client probing.",
|
||||
|
|
@ -1756,6 +2125,63 @@
|
|||
"uploads",
|
||||
"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": {
|
||||
|
|
@ -1805,6 +2231,63 @@
|
|||
"uploads",
|
||||
"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": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue