diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index b9a9db6..c945efc 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -673,24 +673,39 @@ class DesktopState(private val appDir: File = defaultAppDir()) { return false } return try { - val draft = RecordingDraft( - id = UUID.randomUUID().toString(), - title = f.nameWithoutExtension, - createdAtEpochMs = f.lastModified().takeIf { it > 0 } - ?: System.currentTimeMillis(), - durationMs = 0, - mime = AUDIO_EXTS[f.extension.lowercase()] ?: "audio/mp4", - sourceFile = f, - sizeBytes = f.length(), - ) - val ref = provider.upload(draft) {} - saveMapping(f, RemoteMapping(ref.key)) + // If an earlier pass ever uploaded this file, reuse that + // recording and re-run the pipeline in place. Uploading again + // would create a duplicate server recording (this is exactly + // how a file whose pipeline died mid-run got re-imported as a + // second row on the next pump). + val existing = loadMapping(f)?.recordingId + val refKey: String + if (existing != null) { + refKey = existing + runCatching { + provider.reprocess(existing, "transcribe") + } // 409 "already running" is fine: we adopt it by polling. + } else { + val draft = RecordingDraft( + id = UUID.randomUUID().toString(), + title = f.nameWithoutExtension, + createdAtEpochMs = f.lastModified().takeIf { it > 0 } + ?: System.currentTimeMillis(), + durationMs = 0, + mime = AUDIO_EXTS[f.extension.lowercase()] ?: "audio/mp4", + sourceFile = f, + sizeBytes = f.length(), + ) + val ref = provider.upload(draft) {} + saveMapping(f, RemoteMapping(ref.key)) + refKey = ref.key + } var jobs: List = emptyList() var polls = 0 while (polls < 600) { // up to ~30 min per file at 2s polls polls++ delay(2000) - jobs = runCatching { parseJobs(provider.fetchJobs(ref.key)) }.getOrNull().orEmpty() + jobs = runCatching { parseJobs(provider.fetchJobs(refKey)) }.getOrNull().orEmpty() setLive(f.name, jobProgress(jobs)) rescanStatuses() val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status @@ -702,9 +717,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) { it.jobType == "transcribe" && it.status == "failed" } if (transcribeFailed) return false - val transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) } + val transcript = runCatching { parseTranscript(provider.fetchTranscript(refKey)) } .getOrNull() ?: return false - val summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull() + val summary = runCatching { parseSummary(provider.fetchSummary(refKey)) }.getOrNull() saveReport(DetailUi(file = f, transcript = transcript, summary = summary)) true } catch (e: Exception) { @@ -1377,6 +1392,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) { fun transcribe() { val d = _detail.value ?: return pollJob?.cancel() + // The pump (or a detail-run from elsewhere) already owns this + // file: never start a second pipeline for it. The upload path + // below creates a NEW server recording, and the mapping sidecar + // only exists once an upload completes — so mid-flight this is + // the only reliable dedup. Show the live row state instead. + if (d.file.name in inFlight || isRunning(d.file.name)) { + d.busy = _liveProgress.value[d.file.name]?.label ?: "Already queued…" + d.error = null + _detail.value = d.copy() + return + } // 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