diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index d8ea574..aeaa9d0 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -133,6 +133,16 @@ class DesktopState(private val appDir: File = defaultAppDir()) { /** Files the pump is running or has queued this session (by name). */ private val inFlight = mutableSetOf() + /** Files the pump/detail job is working RIGHT NOW (upload/transcribe + * running engine-side). Queued-but-untouched files are NOT here: + * they can be cancelled by deleting. Guarded by [runningGate]. */ + private val runningGate = Object() + private val runningSet = mutableSetOf() + private fun isRunning(name: String): Boolean = + synchronized(runningGate) { name in runningSet } + private val runningNames: Set + get() = synchronized(runningGate) { runningSet.toSet() } + /** Files whose last auto attempt failed (no hot-loop retries). */ private val failedFiles = mutableSetOf() @@ -455,7 +465,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) { status = when { done -> FileStatus.DONE f.name in failedFiles -> FileStatus.FAILED - f.name in inFlight -> FileStatus.RUNNING + isRunning(f.name) -> FileStatus.RUNNING + f.name in inFlight -> FileStatus.QUEUED else -> FileStatus.NEW }, ) @@ -545,10 +556,19 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (pumpJob?.isActive == true) return pumpJob = scope.launch { while (queue.isNotEmpty() && _connected.value) { - val f = queue.poll() ?: break - val ok = transcribeFile(f) - if (!ok) failedFiles += f.name - else failedFiles -= f.name + // Poll + register-running are atomic against deleteFile's + // check+remove (same lock), so a delete can never slip + // between the two and yank the file out from under us. + val f = synchronized(runningGate) { + queue.poll()?.also { runningSet += it.name } + } ?: break + try { + val ok = transcribeFile(f) + if (!ok) failedFiles += f.name + else failedFiles -= f.name + } finally { + synchronized(runningGate) { runningSet -= f.name } + } inFlight -= f.name _batch.value = LibraryQueue.batchAdvance(_batch.value, f.name) rescanStatuses() @@ -654,7 +674,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) { * race the rename. Returns null on success or a human-readable reason. */ fun renameFile(old: File, newBase: String): String? { - if (old.name in inFlight) return "Wait — this file is transcribing." + // Queued-but-unstarted renames are fine: cancel the queue entry + // first (same lock as the pump) so it can't start mid-rename. + val running = synchronized(runningGate) { + if (old.name in runningSet) true + else { queue.remove(old); inFlight -= old.name; false } + } + if (running) return "Wait — this file is transcribing." val target = LibraryQueue.renameTarget(old, newBase) .getOrElse { return it.message ?: "Invalid name." } val ok = runCatching { @@ -683,14 +709,21 @@ class DesktopState(private val appDir: File = defaultAppDir()) { * Delete a library recording for good: the audio, its report, and its * mapping sidecar go now; the server record (when this file was ever * transcribed) is purged in the background — a failure there surfaces - * on [deleteWarning] rather than blocking the delete. Refuses while - * the file is queued/transcribing — the pump would resurrect it - * mid-flight. Returns null on success or a human-readable refusal. + * on [deleteWarning] rather than blocking the delete. Refuses only + * while the file is ACTUALLY transcribing (the engine has it mid-job); + * a merely-queued file is cancelled out of the pump and deleted. + * Returns null on success or a human-readable refusal. */ fun deleteFile(f: File): String? { _deleteWarning.value = null if (playingFile.value?.absolutePath == f.absolutePath) stopPlayback() - if (f.name in inFlight) return "Wait — this file is transcribing." + // Atomic against the pump's poll+register: if it isn't running we + // pull it from the queue now, so it can never start after this point. + val running = synchronized(runningGate) { + if (f.name in runningSet) true + else { queue.remove(f); inFlight -= f.name; false } + } + if (running) return "Wait — this file is transcribing." if (!f.exists()) { rescan(); return "That file is already gone." } val remoteId = _detail.value?.takeIf { it.file == f }?.remoteId @@ -704,8 +737,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { // Bookkeeping keyed by name — drop every trace so a re-added file // with the same name starts clean. failedFiles -= f.name - inFlight -= f.name - queue.remove(f) + _batch.value = LibraryQueue.batchRemove(_batch.value, f.name) _liveProgress.value = _liveProgress.value.toMutableMap().apply { remove(f.name) } if (_detail.value?.file == f) { pollJob?.cancel() @@ -748,7 +780,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { * summary of what was skipped/failed. */ fun deleteFiles(files: List): String? { - val (now, waiting) = LibraryQueue.planDelete(files, inFlight.toSet()) + val (now, waiting) = LibraryQueue.planDelete(files, runningNames) val failed = now.mapNotNull { deleteFile(it) } val notes = buildList { waiting.forEach { add("\"${it.nameWithoutExtension}\" is transcribing — not deleted") } @@ -1029,6 +1061,15 @@ class DesktopState(private val appDir: File = defaultAppDir()) { return } pollJob = scope.launch { + synchronized(runningGate) { runningSet += d.file.name } + try { transcribeNow(d) } finally { + synchronized(runningGate) { runningSet -= d.file.name } + } + } + } + + private suspend fun transcribeNow(d: DetailUi) { + run { d.busy = "Uploading…" d.uploadProgress = 0f d.error = null @@ -1057,7 +1098,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d.busy = null d.error = it.message ?: "Upload failed." _detail.value = d.copy() - return@launch + return@run } d.remoteId = ref.key saveMapping(d.file, RemoteMapping(ref.key, model = d.overrideModel, @@ -1088,7 +1129,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d.busy = null d.error = failed.error ?: "Transcription failed." _detail.value = d.copy() - return@launch + return@run } d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) } .getOrNull() @@ -1117,6 +1158,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) { val remoteId = d0.remoteId ?: return pollJob?.cancel() pollJob = scope.launch { + synchronized(runningGate) { runningSet += d0.file.name } + try { d0.busy = if (job == "transcribe") "Re-transcribing…" else "Summarizing…" d0.uploadProgress = null d0.error = null @@ -1173,6 +1216,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) { title = d0.file.nameWithoutExtension)) rescan() } + } finally { + synchronized(runningGate) { runningSet -= d0.file.name } + } } } diff --git a/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt b/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt index b769e52..e0eafde 100644 --- a/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt +++ b/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt @@ -104,6 +104,7 @@ object LibraryQueue { /** * One completed file in an active batch: returns the updated batch, or * null when the batch just finished (done == total). Null in → null out. + * A cancelled file leaves the batch entirely via [batchRemove]. */ fun batchAdvance(batch: TranscribeBatch?, fileName: String): TranscribeBatch? { if (batch == null || fileName !in batch.names) return batch @@ -111,13 +112,25 @@ object LibraryQueue { return if (done >= batch.total) null else batch.copy(done = done) } - /** Split a selection into what can go now vs. files that must wait - * because the pump has them in flight. */ + /** A queued batch file was cancelled (deleted before its turn): shrink + * the batch so the progress bar still reaches the end. */ + fun batchRemove(batch: TranscribeBatch?, fileName: String): TranscribeBatch? { + if (batch == null || fileName !in batch.names) return batch + val total = batch.total - 1 + return if (batch.done >= total) null + else batch.copy(names = batch.names - fileName, total = total) + } + + /** + * Split a selection into what can go now vs. files the engine is + * actively transcribing ([runningNames]). Files merely QUEUED in the + * pump are deletable — removing them from the queue is a clean cancel. + */ fun planDelete( selected: List, - inFlightNames: Set, + runningNames: Set, ): Pair, List> = - selected.partition { it.name !in inFlightNames } + selected.partition { it.name !in runningNames } /** Audio candidates: regular files, known extension, not hidden. */ fun isAudioCandidate(f: File, audioExts: Set): Boolean = diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 02e9bf0..b4884d7 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -399,8 +399,10 @@ fun LibraryScreen(state: DesktopState) { Text("Transcribe") } } - if (e.status != FileStatus.RUNNING && - e.status != FileStatus.QUEUED) { + // Queued files stay editable/deletable: deleting + // cancels them out of the pump. Only a file the + // engine is actually working is locked. + if (e.status != FileStatus.RUNNING) { TextButton({ renaming = e.file }) { Icon(Icons.Filled.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) @@ -410,8 +412,7 @@ fun LibraryScreen(state: DesktopState) { } // No "Open" button: clicking the card opens the // recording (the button was redundant). - if (e.status != FileStatus.RUNNING && - e.status != FileStatus.QUEUED) { + if (e.status != FileStatus.RUNNING) { TextButton( { deleting = e.file }, content = { diff --git a/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt b/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt index d2109c3..b2f12cc 100644 --- a/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt @@ -66,15 +66,25 @@ class LibraryQueueTest { LibraryQueue.selectOrClearAll(emptySet(), emptyList())) } - @Test fun planDeleteHoldsBackInFlightFiles() { + @Test fun planDeleteHoldsBackOnlyActuallyRunningFiles() { val dir = createTempDirectory("lqp").toFile() val a = File(dir, "a.m4a").apply { writeText("x") } val b = File(dir, "b.m4a").apply { writeText("x") } + // b is running engine-side; a is NEW or merely QUEUED — both go. val (now, waiting) = LibraryQueue.planDelete(listOf(a, b), setOf("b.m4a")) assertEquals(listOf(a), now) assertEquals(listOf(b), waiting) } + @Test fun planDeleteReleasesQueuedFiles() { + val dir = createTempDirectory("lqp2").toFile() + val a = File(dir, "a.m4a").apply { writeText("x") } + // Empty running set: queued files are cancellable, so all delete. + val (now, waiting) = LibraryQueue.planDelete(listOf(a), emptySet()) + assertEquals(listOf(a), now) + assertTrue(waiting.isEmpty()) + } + @Test fun autoQueueSkipsReportsInFlightAndFailed() { val dir = createTempDirectory("lq").toFile() val fresh = File(dir, "fresh.m4a").apply { writeText("x") } @@ -160,4 +170,17 @@ class TranscribeBatchTest { assertEquals(0, LibraryQueue.batchAdvance(b, "z.m4a")?.done) assertEquals(null, LibraryQueue.batchAdvance(null, "a.m4a")) } + + @Test fun removeShrinksBatchForCancelledFile() { + val b = TranscribeBatch(setOf("a.m4a", "b.m4a", "c.m4a"), 1, 3) + val after = LibraryQueue.batchRemove(b, "c.m4a") + assertEquals(2, after?.total) + assertEquals(1, after?.done) + assertTrue(after?.names?.contains("c.m4a") == false) + // Cancelling the last outstanding file completes the batch. + assertEquals(null, LibraryQueue.batchRemove(b.copy(done = 2), "c.m4a")) + // Unknown file / null batch are no-ops. + assertEquals(b, LibraryQueue.batchRemove(b, "z.m4a")) + assertEquals(null, LibraryQueue.batchRemove(null, "a.m4a")) + } }