Desktop: Transcribe-all button with batch progress bar

Toolbar gets 'Transcribe all (N)' when N files still lack a transcript
(includes previously-failed ones — asking again means retry). Queues
them through the existing pump; hidden while a batch runs or when the
library is fully transcribed.

While a batch runs, a card shows 'Transcribing batch: k of N — <current
file>' with a bar that blends the settled-file count with the current
file's own backend-reported fraction. Disconnected mid-batch clears the
bar instead of stranding it.

Pure logic in LibraryQueue (transcribeAllPlan, batchAdvance) + 3 new
tests; app suite 37/37. Verified button renders on live build 07:34.
This commit is contained in:
avi 2026-09-15 07:37:14 -05:00
commit 957b4d7c77
4 changed files with 125 additions and 0 deletions

View file

@ -520,6 +520,27 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
startPump() startPump()
} }
// ---- transcribe-all batches ---------------------------------------------
private val _batch = MutableStateFlow<TranscribeBatch?>(null)
val batch = _batch.asStateFlow()
/** Queue every file still without a transcript. Progress rides the
* [batch] flow; the shared pump runs them in queue order. */
fun transcribeAll() {
val todo = LibraryQueue.transcribeAllPlan(_entries.value)
if (todo.isEmpty()) return
_batch.value = TranscribeBatch(todo.map { it.name }.toSet(), 0, todo.size)
todo.forEach { f ->
if (f.name !in inFlight) {
queue.add(f)
inFlight += f.name
}
}
rescanStatuses()
startPump()
}
private fun startPump() { private fun startPump() {
if (pumpJob?.isActive == true) return if (pumpJob?.isActive == true) return
pumpJob = scope.launch { pumpJob = scope.launch {
@ -529,8 +550,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
if (!ok) failedFiles += f.name if (!ok) failedFiles += f.name
else failedFiles -= f.name else failedFiles -= f.name
inFlight -= f.name inFlight -= f.name
_batch.value = LibraryQueue.batchAdvance(_batch.value, f.name)
rescanStatuses() rescanStatuses()
} }
// Disconnected mid-batch: don't strand the progress bar.
if (_batch.value != null && queue.isNotEmpty() && !_connected.value) {
_batch.value = null
}
} }
} }

View file

@ -28,6 +28,9 @@ enum class FileStatus {
; ;
} }
/** "Transcribe all" batch: [names] being processed, [done] settled so far. */
data class TranscribeBatch(val names: Set<String>, val done: Int, val total: Int)
/** /**
* Diff between two scans of the library folder. [added] preserves sorted * Diff between two scans of the library folder. [added] preserves sorted
* file-name order so the queue is deterministic across rescans. * file-name order so the queue is deterministic across rescans.
@ -87,6 +90,27 @@ object LibraryQueue {
if (candidates.isNotEmpty() && current.containsAll(candidates)) emptySet() if (candidates.isNotEmpty() && current.containsAll(candidates)) emptySet()
else candidates.toSet() else candidates.toSet()
/**
* What "Transcribe all" queues: every entry still without a report —
* including previously FAILED ones (the user asked again, retry them) —
* but not files already queued or running.
*/
fun transcribeAllPlan(entries: List<DesktopState.LibraryEntry>): List<File> =
entries.filter {
!it.hasReport && it.status != FileStatus.QUEUED &&
it.status != FileStatus.RUNNING
}.map { it.file }
/**
* One completed file in an active batch: returns the updated batch, or
* null when the batch just finished (done == total). Null in → null out.
*/
fun batchAdvance(batch: TranscribeBatch?, fileName: String): TranscribeBatch? {
if (batch == null || fileName !in batch.names) return batch
val done = batch.done + 1
return if (done >= batch.total) null else batch.copy(done = done)
}
/** Split a selection into what can go now vs. files that must wait /** Split a selection into what can go now vs. files that must wait
* because the pump has them in flight. */ * because the pump has them in flight. */
fun planDelete( fun planDelete(

View file

@ -237,6 +237,45 @@ fun LibraryScreen(state: DesktopState) {
Text("Auto-transcribe new files", Text("Auto-transcribe new files",
style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall)
} }
val batch = state.batch.collectAsState().value
val untranscribed = entries.count {
!it.hasReport && it.status != FileStatus.QUEUED &&
it.status != FileStatus.RUNNING
}
if (batch == null && untranscribed > 0) {
OutlinedButton({ state.transcribeAll() }) {
Text("Transcribe all ($untranscribed)")
}
}
}
// Batch progress: settled count + current file's own fraction.
val batch = state.batch.collectAsState().value
if (batch != null) {
val running = entries.firstOrNull {
it.status == FileStatus.RUNNING && it.file.name in batch.names
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Transcribing batch: ${batch.done + 1} of ${batch.total}" +
(running?.let { " — ${it.file.name}" } ?: ""),
style = MaterialTheme.typography.bodySmall)
val fileFrac = running?.progress
if (fileFrac != null) {
// Overall = settled files + current file's fraction.
LinearProgressIndicator(
progress = {
((batch.done + fileFrac) / batch.total)
.coerceIn(0f, 1f)
},
modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(
progress = { (batch.done.toFloat() / batch.total) },
modifier = Modifier.fillMaxWidth())
}
}
}
} }
OutlinedTextField(query, { state.setQuery(it) }, OutlinedTextField(query, { state.setQuery(it) },
label = { Text("Search saved transcripts") }, label = { Text("Search saved transcripts") },

View file

@ -125,3 +125,39 @@ class FolderPickerTest {
assertTrue(chain.size >= 3) assertTrue(chain.size >= 3)
} }
} }
class TranscribeBatchTest {
private fun entry(name: String, status: FileStatus, report: Boolean) =
DesktopState.LibraryEntry(File("/r/$name"), report, status)
@Test fun planPicksNewAndFailedNotQueuedOrDone() {
val entries = listOf(
entry("a.m4a", FileStatus.NEW, false),
entry("b.m4a", FileStatus.DONE, true),
entry("c.m4a", FileStatus.QUEUED, false),
entry("d.m4a", FileStatus.RUNNING, false),
entry("e.m4a", FileStatus.FAILED, false),
)
assertEquals(
listOf(File("/r/a.m4a"), File("/r/e.m4a")),
LibraryQueue.transcribeAllPlan(entries),
)
}
@Test fun planEmptyWhenAllDone() {
assertEquals(
emptyList<File>(),
LibraryQueue.transcribeAllPlan(listOf(entry("b.m4a", FileStatus.DONE, true))),
)
}
@Test fun advanceCountsThenCompletes() {
val b = TranscribeBatch(setOf("a.m4a", "b.m4a"), 0, 2)
assertEquals(1, LibraryQueue.batchAdvance(b, "a.m4a")?.done)
// Completing the last file clears the batch.
assertEquals(null, LibraryQueue.batchAdvance(b.copy(done = 1), "b.m4a"))
// Files outside the batch don't advance it.
assertEquals(0, LibraryQueue.batchAdvance(b, "z.m4a")?.done)
assertEquals(null, LibraryQueue.batchAdvance(null, "a.m4a"))
}
}