From ac77f89ffa5472e887417b5c121fd2af8a14c64b Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 13 Sep 2026 20:38:12 -0500 Subject: [PATCH] desktop: in-place reprocess via .shonar.json remote-id sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bug: 'Re-transcribe' uploaded a brand-new recording every click — the server id lived only in DetailUi and died with the process, so duplicates piled up server-side and no restart could reuse them. - RemoteMapping sidecar (.shonar.json) persists recording id + model + title next to the audio; survives restart, renames (carried by renameFile), and Syncthing - openDetail preloads remoteId + override model from the sidecar - transcribe() re-runs POST /reprocess?job=transcribe&model= in place when a mapping exists; upload only for never-uploaded files - new Re-summarize button (mapping-gated) hits /reprocess?job=summarize - 404 on a stale mapping deletes the sidecar and tells the user to re-upload (self-heals instead of failing forever) - 6 unit tests (roundtrip, corrupt json, blank id, rename carry-over) --- .../kotlin/com/shonar/desktop/DesktopState.kt | 96 ++++++++++++++++++- .../com/shonar/desktop/RemoteMapping.kt | 51 ++++++++++ .../main/kotlin/com/shonar/desktop/Screens.kt | 9 +- .../com/shonar/desktop/RemoteMappingTest.kt | 59 ++++++++++++ docs/ROADMAP.md | 1 + 5 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 desktop/app/src/main/kotlin/com/shonar/desktop/RemoteMapping.kt create mode 100644 desktop/app/src/test/kotlin/com/shonar/desktop/RemoteMappingTest.kt diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index 5b7adca..98e2ea5 100644 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -563,6 +563,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { sizeBytes = f.length(), ) val ref = provider.upload(draft) {} + saveMapping(f, RemoteMapping(ref.key)) var jobs: List = emptyList() var polls = 0 while (polls < 600) { // up to ~30 min per file at 2s polls @@ -613,6 +614,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (oldReport.exists()) { oldReport.renameTo(reportFile(target)) } + moveMapping(old, target) } } }.getOrElse { return "Rename failed: ${it.message}" } @@ -708,7 +710,12 @@ class DesktopState(private val appDir: File = defaultAppDir()) { // Show the saved report immediately — otherwise opening a finished // file looks empty and the summary appears unreachable. val saved = runCatching { reportFile(file).readText() }.getOrNull() - _detail.value = DetailUi(file = file, reportText = saved) + val mapping = loadMapping(file) + _detail.value = DetailUi( + file = file, reportText = saved, + remoteId = mapping?.recordingId, + overrideModel = mapping?.model, + ) go(Screen.DETAIL) } @@ -719,6 +726,16 @@ class DesktopState(private val appDir: File = defaultAppDir()) { fun transcribe() { val d = _detail.value ?: return pollJob?.cancel() + // A file with a known server recording re-runs the pipeline IN + // PLACE (the endpoint takes the model override too) — re-uploading + // would duplicate the recording server-side. Only never-uploaded + // files take the upload path below. + val mapping = loadMapping(d.file) + if (mapping != null) { + d.remoteId = mapping.recordingId + reprocessInPlace(d, job = "transcribe", model = d.overrideModel) + return + } pollJob = scope.launch { d.busy = "Uploading…" d.uploadProgress = 0f @@ -747,6 +764,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) { return@launch } d.remoteId = ref.key + saveMapping(d.file, RemoteMapping(ref.key, model = d.overrideModel, + title = d.file.nameWithoutExtension)) d.busy = "Transcribing…" d.uploadProgress = null _detail.value = d.copy() @@ -785,6 +804,81 @@ class DesktopState(private val appDir: File = defaultAppDir()) { fun retry() = transcribe() + /** + * Re-run one pipeline stage on the EXISTING server recording via + * POST /reprocess — no upload, no duplicate. Polls the same jobs + * endpoint as the upload path. [model] is sent for "transcribe" so a + * model override switch persists on the server row. + */ + fun reprocessInPlace(d0: DetailUi, job: String, model: String? = null) { + val remoteId = d0.remoteId ?: return + pollJob?.cancel() + pollJob = scope.launch { + d0.busy = if (job == "transcribe") "Re-transcribing…" else "Summarizing…" + d0.uploadProgress = null + d0.error = null + _detail.value = d0.copy() + runCatching { provider.reprocess(remoteId, job, model) }.onFailure { + if (job == "transcribe" && it is ProviderError.NotFound) { + // Mapping points at a deleted recording: drop it so the + // next attempt re-uploads instead of failing forever. + mappingFile(d0.file).delete() + d0.remoteId = null + d0.busy = null + d0.error = "That server recording no longer exists — press Transcribe to upload again." + } else { + d0.busy = null + d0.error = it.message ?: "Reprocess failed." + } + _detail.value = d0.copy() + return@launch + } + // Poll the target job to a terminal state. + while (true) { + delay(2000) + val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrNull() + .orEmpty() + d0.jobs = jobs + jobLabel(jobs)?.let { d0.busy = it } + _detail.value = d0.copy() + val st = jobs.firstOrNull { it.jobType == job }?.status + if (st == null || st in TERMINAL) break + } + val failed = d0.jobs.firstOrNull { it.jobType == job && it.status == "failed" } + if (failed != null) { + d0.busy = null + d0.error = failed.error ?: "That stage failed." + _detail.value = d0.copy() + return@launch + } + d0.transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) } + .getOrNull() + d0.summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) } + .getOrNull() + d0.busy = null + _detail.value = d0.copy() + if (d0.transcript != null || d0.summary != null) { + saveReport(d0) + saveMapping(d0.file, RemoteMapping(remoteId, model = d0.overrideModel, + title = d0.file.nameWithoutExtension)) + rescan() + } + } + } + + /** "Summarize" button: regenerate the summary in place from the + * current transcript. Requires a known server recording. */ + fun summarize() { + val d = _detail.value ?: return + val remoteId = d.remoteId ?: loadMapping(d.file)?.recordingId ?: return + d.remoteId = remoteId + reprocessInPlace(d, job = "summarize") + } + + /** Server recording id for a library file, if it was ever uploaded. */ + fun remoteIdFor(file: File): String? = + _detail.value?.takeIf { it.file == file }?.remoteId ?: loadMapping(file)?.recordingId + fun saveReport(d: DetailUi) { reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary)) } diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/RemoteMapping.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/RemoteMapping.kt new file mode 100644 index 0000000..250391d --- /dev/null +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/RemoteMapping.kt @@ -0,0 +1,51 @@ +package com.shonar.desktop + +import java.io.File +import org.json.JSONObject + +/** + * Sidecar mapping a library file to its server recording id. Written next + * to the audio as `.shonar.json` (excluded from the library listing + * by the audio-extension filter; travels with the file through renames + * and Syncthing). + * + * Why persisted: without it, "Re-transcribe" after a restart uploads a + * brand-new recording (duplicate on the server). With it, the desktop + * re-runs the pipeline in place via POST /reprocess. + * + * Pure logic — unit-tested. + */ +data class RemoteMapping( + val recordingId: String, + /** Model override the last transcribe used, if any (informational). */ + val model: String? = null, + val title: String? = null, +) + +fun mappingFile(audio: File): File = + File(audio.parentFile, "${audio.nameWithoutExtension}.shonar.json") + +fun loadMapping(audio: File): RemoteMapping? = runCatching { + val o = JSONObject(mappingFile(audio).readText()) + val id = o.optString("recording_id") + if (id.isBlank()) null + else RemoteMapping( + recordingId = id, + model = o.optString("model").takeIf { it.isNotBlank() }, + title = o.optString("title").takeIf { it.isNotBlank() }, + ) +}.getOrNull() + +fun saveMapping(audio: File, mapping: RemoteMapping) { + val o = JSONObject() + o.put("recording_id", mapping.recordingId) + mapping.model?.let { o.put("model", it) } + mapping.title?.let { o.put("title", it) } + mappingFile(audio).writeText(o.toString(2) + "\n") +} + +/** Carry the mapping across a file rename (returns true if one moved). */ +fun moveMapping(old: File, new: File): Boolean { + val src = mappingFile(old) + return src.exists() && src.renameTo(mappingFile(new)) +} diff --git a/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 151bdd9..d061cf8 100644 --- a/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/desktop/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -370,7 +370,14 @@ fun DetailScreen(state: DesktopState) { } Spacer(Modifier.height(4.dp)) if (detail.transcript != null || detail.reportText != null) { - TextButton({ state.transcribe() }) { Text("Re-transcribe") } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton({ state.transcribe() }) { Text("Re-transcribe") } + // Summarize needs a known server recording; files that predate + // remote-id mapping (or never uploaded) don't show it. + if (state.remoteIdFor(detail.file) != null) { + TextButton({ state.summarize() }) { Text("Re-summarize") } + } + } } } } diff --git a/desktop/app/src/test/kotlin/com/shonar/desktop/RemoteMappingTest.kt b/desktop/app/src/test/kotlin/com/shonar/desktop/RemoteMappingTest.kt new file mode 100644 index 0000000..5c68501 --- /dev/null +++ b/desktop/app/src/test/kotlin/com/shonar/desktop/RemoteMappingTest.kt @@ -0,0 +1,59 @@ +package com.shonar.desktop + +import java.io.File +import kotlin.io.path.createTempDirectory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RemoteMappingTest { + private fun tmpAudio(name: String = "rec.m4a"): File { + val dir = createTempDirectory().toFile() + val f = File(dir, name) + f.writeText("audio") + return f + } + + @Test fun `round trip save and load`() { + val f = tmpAudio() + saveMapping(f, RemoteMapping("uuid-1", model = "small", title = "rec")) + val m = loadMapping(f) + assertEquals("uuid-1", m?.recordingId) + assertEquals("small", m?.model) + assertEquals("rec", m?.title) + } + + @Test fun `load returns null when absent`() { + assertNull(loadMapping(tmpAudio())) + } + + @Test fun `load returns null on corrupt json`() { + val f = tmpAudio() + mappingFile(f).writeText("{not json") + assertNull(loadMapping(f)) + } + + @Test fun `load returns null on blank id`() { + val f = tmpAudio() + mappingFile(f).writeText("""{"recording_id": ""}""") + assertNull(loadMapping(f)) + } + + @Test fun `move follows the audio rename`() { + val f = tmpAudio("old.m4a") + saveMapping(f, RemoteMapping("uuid-2")) + val renamed = File(f.parentFile, "new.m4a") + f.renameTo(renamed) + assertTrue(moveMapping(f, renamed)) + assertEquals("uuid-2", loadMapping(renamed)?.recordingId) + assertNull(loadMapping(f)) + } + + @Test fun `move is a no-op without an existing mapping`() { + val f = tmpAudio("a.m4a") + val g = File(f.parentFile, "b.m4a") + assertTrue(!moveMapping(f, g)) + assertNull(loadMapping(g)) + } +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8e98502..ab01a04 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -19,6 +19,7 @@ updated in the same commit as the work it describes. | 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) | +| D-1 | Desktop: in-place reprocess (no re-upload) — `.shonar.json` sidecar maps library file → server recording id; Re-transcribe/Re-summarize hit `POST /reprocess` (model override switches persist server-side); rename carries mapping; deleted-remote mapping self-heals to re-upload | done — provider `reprocess()`, endpoint `?model=` param, 2 backend + 6 desktop tests, suites green (69 backend, 23 desktop) | | M10 | Android dark mode, accessibility pass, consent UX polish, deploy/backup docs, OpenAPI sync | TODO | ## Explicit TODOs (not yet implemented)