desktop: in-place reprocess via .shonar.json remote-id sidecar

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 (<name>.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)
This commit is contained in:
avi 2026-09-13 20:38:12 -05:00
commit ac77f89ffa
5 changed files with 214 additions and 2 deletions

View file

@ -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<JobInfo> = 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))
}

View file

@ -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 `<name>.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))
}

View file

@ -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") }
}
}
}
}
}

View file

@ -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))
}
}

View file

@ -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) — `<name>.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)