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:
parent
b121d1a2e3
commit
ac77f89ffa
5 changed files with 214 additions and 2 deletions
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue