Desktop: queued files can be deleted/renamed; only running jobs lock
inFlight conflated queued with actually-running, so a file waiting in
the pump refused deletes ('is transcribing' — false) and hid its
Rename/Delete buttons during a batch. Track the truly-running files in
a dedicated set: the pump registers a file at poll-time atomically with
the queue pop, and deleteFile/renameFile either find it running (refuse)
or cancel its queue entry under the same lock (proceed). Batch progress
shrinks when a queued file is cancelled (batchRemove) so the bar still
ends; row status now distinguishes QUEUED from RUNNING on rescan.
This commit is contained in:
parent
757595aaed
commit
36a1d2374b
4 changed files with 107 additions and 24 deletions
|
|
@ -133,6 +133,16 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
/** Files the pump is running or has queued this session (by name). */
|
/** Files the pump is running or has queued this session (by name). */
|
||||||
private val inFlight = mutableSetOf<String>()
|
private val inFlight = mutableSetOf<String>()
|
||||||
|
|
||||||
|
/** 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<String>()
|
||||||
|
private fun isRunning(name: String): Boolean =
|
||||||
|
synchronized(runningGate) { name in runningSet }
|
||||||
|
private val runningNames: Set<String>
|
||||||
|
get() = synchronized(runningGate) { runningSet.toSet() }
|
||||||
|
|
||||||
/** Files whose last auto attempt failed (no hot-loop retries). */
|
/** Files whose last auto attempt failed (no hot-loop retries). */
|
||||||
private val failedFiles = mutableSetOf<String>()
|
private val failedFiles = mutableSetOf<String>()
|
||||||
|
|
||||||
|
|
@ -455,7 +465,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
status = when {
|
status = when {
|
||||||
done -> FileStatus.DONE
|
done -> FileStatus.DONE
|
||||||
f.name in failedFiles -> FileStatus.FAILED
|
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
|
else -> FileStatus.NEW
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -545,10 +556,19 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
if (pumpJob?.isActive == true) return
|
if (pumpJob?.isActive == true) return
|
||||||
pumpJob = scope.launch {
|
pumpJob = scope.launch {
|
||||||
while (queue.isNotEmpty() && _connected.value) {
|
while (queue.isNotEmpty() && _connected.value) {
|
||||||
val f = queue.poll() ?: break
|
// 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)
|
val ok = transcribeFile(f)
|
||||||
if (!ok) failedFiles += f.name
|
if (!ok) failedFiles += f.name
|
||||||
else failedFiles -= f.name
|
else failedFiles -= f.name
|
||||||
|
} finally {
|
||||||
|
synchronized(runningGate) { runningSet -= f.name }
|
||||||
|
}
|
||||||
inFlight -= f.name
|
inFlight -= f.name
|
||||||
_batch.value = LibraryQueue.batchAdvance(_batch.value, f.name)
|
_batch.value = LibraryQueue.batchAdvance(_batch.value, f.name)
|
||||||
rescanStatuses()
|
rescanStatuses()
|
||||||
|
|
@ -654,7 +674,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
* race the rename. Returns null on success or a human-readable reason.
|
* race the rename. Returns null on success or a human-readable reason.
|
||||||
*/
|
*/
|
||||||
fun renameFile(old: File, newBase: String): String? {
|
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)
|
val target = LibraryQueue.renameTarget(old, newBase)
|
||||||
.getOrElse { return it.message ?: "Invalid name." }
|
.getOrElse { return it.message ?: "Invalid name." }
|
||||||
val ok = runCatching {
|
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
|
* Delete a library recording for good: the audio, its report, and its
|
||||||
* mapping sidecar go now; the server record (when this file was ever
|
* mapping sidecar go now; the server record (when this file was ever
|
||||||
* transcribed) is purged in the background — a failure there surfaces
|
* transcribed) is purged in the background — a failure there surfaces
|
||||||
* on [deleteWarning] rather than blocking the delete. Refuses while
|
* on [deleteWarning] rather than blocking the delete. Refuses only
|
||||||
* the file is queued/transcribing — the pump would resurrect it
|
* while the file is ACTUALLY transcribing (the engine has it mid-job);
|
||||||
* mid-flight. Returns null on success or a human-readable refusal.
|
* 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? {
|
fun deleteFile(f: File): String? {
|
||||||
_deleteWarning.value = null
|
_deleteWarning.value = null
|
||||||
if (playingFile.value?.absolutePath == f.absolutePath) stopPlayback()
|
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." }
|
if (!f.exists()) { rescan(); return "That file is already gone." }
|
||||||
|
|
||||||
val remoteId = _detail.value?.takeIf { it.file == f }?.remoteId
|
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
|
// Bookkeeping keyed by name — drop every trace so a re-added file
|
||||||
// with the same name starts clean.
|
// with the same name starts clean.
|
||||||
failedFiles -= f.name
|
failedFiles -= f.name
|
||||||
inFlight -= f.name
|
_batch.value = LibraryQueue.batchRemove(_batch.value, f.name)
|
||||||
queue.remove(f)
|
|
||||||
_liveProgress.value = _liveProgress.value.toMutableMap().apply { remove(f.name) }
|
_liveProgress.value = _liveProgress.value.toMutableMap().apply { remove(f.name) }
|
||||||
if (_detail.value?.file == f) {
|
if (_detail.value?.file == f) {
|
||||||
pollJob?.cancel()
|
pollJob?.cancel()
|
||||||
|
|
@ -748,7 +780,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
* summary of what was skipped/failed.
|
* summary of what was skipped/failed.
|
||||||
*/
|
*/
|
||||||
fun deleteFiles(files: List<File>): String? {
|
fun deleteFiles(files: List<File>): String? {
|
||||||
val (now, waiting) = LibraryQueue.planDelete(files, inFlight.toSet())
|
val (now, waiting) = LibraryQueue.planDelete(files, runningNames)
|
||||||
val failed = now.mapNotNull { deleteFile(it) }
|
val failed = now.mapNotNull { deleteFile(it) }
|
||||||
val notes = buildList {
|
val notes = buildList {
|
||||||
waiting.forEach { add("\"${it.nameWithoutExtension}\" is transcribing — not deleted") }
|
waiting.forEach { add("\"${it.nameWithoutExtension}\" is transcribing — not deleted") }
|
||||||
|
|
@ -1029,6 +1061,15 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pollJob = scope.launch {
|
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.busy = "Uploading…"
|
||||||
d.uploadProgress = 0f
|
d.uploadProgress = 0f
|
||||||
d.error = null
|
d.error = null
|
||||||
|
|
@ -1057,7 +1098,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
d.busy = null
|
d.busy = null
|
||||||
d.error = it.message ?: "Upload failed."
|
d.error = it.message ?: "Upload failed."
|
||||||
_detail.value = d.copy()
|
_detail.value = d.copy()
|
||||||
return@launch
|
return@run
|
||||||
}
|
}
|
||||||
d.remoteId = ref.key
|
d.remoteId = ref.key
|
||||||
saveMapping(d.file, RemoteMapping(ref.key, model = d.overrideModel,
|
saveMapping(d.file, RemoteMapping(ref.key, model = d.overrideModel,
|
||||||
|
|
@ -1088,7 +1129,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
d.busy = null
|
d.busy = null
|
||||||
d.error = failed.error ?: "Transcription failed."
|
d.error = failed.error ?: "Transcription failed."
|
||||||
_detail.value = d.copy()
|
_detail.value = d.copy()
|
||||||
return@launch
|
return@run
|
||||||
}
|
}
|
||||||
d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) }
|
d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
|
|
@ -1117,6 +1158,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
val remoteId = d0.remoteId ?: return
|
val remoteId = d0.remoteId ?: return
|
||||||
pollJob?.cancel()
|
pollJob?.cancel()
|
||||||
pollJob = scope.launch {
|
pollJob = scope.launch {
|
||||||
|
synchronized(runningGate) { runningSet += d0.file.name }
|
||||||
|
try {
|
||||||
d0.busy = if (job == "transcribe") "Re-transcribing…" else "Summarizing…"
|
d0.busy = if (job == "transcribe") "Re-transcribing…" else "Summarizing…"
|
||||||
d0.uploadProgress = null
|
d0.uploadProgress = null
|
||||||
d0.error = null
|
d0.error = null
|
||||||
|
|
@ -1173,6 +1216,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
title = d0.file.nameWithoutExtension))
|
title = d0.file.nameWithoutExtension))
|
||||||
rescan()
|
rescan()
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
synchronized(runningGate) { runningSet -= d0.file.name }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,7 @@ object LibraryQueue {
|
||||||
/**
|
/**
|
||||||
* One completed file in an active batch: returns the updated batch, or
|
* One completed file in an active batch: returns the updated batch, or
|
||||||
* null when the batch just finished (done == total). Null in → null out.
|
* 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? {
|
fun batchAdvance(batch: TranscribeBatch?, fileName: String): TranscribeBatch? {
|
||||||
if (batch == null || fileName !in batch.names) return batch
|
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)
|
return if (done >= batch.total) null else batch.copy(done = done)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split a selection into what can go now vs. files that must wait
|
/** A queued batch file was cancelled (deleted before its turn): shrink
|
||||||
* because the pump has them in flight. */
|
* 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(
|
fun planDelete(
|
||||||
selected: List<File>,
|
selected: List<File>,
|
||||||
inFlightNames: Set<String>,
|
runningNames: Set<String>,
|
||||||
): Pair<List<File>, List<File>> =
|
): Pair<List<File>, List<File>> =
|
||||||
selected.partition { it.name !in inFlightNames }
|
selected.partition { it.name !in runningNames }
|
||||||
|
|
||||||
/** Audio candidates: regular files, known extension, not hidden. */
|
/** Audio candidates: regular files, known extension, not hidden. */
|
||||||
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =
|
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =
|
||||||
|
|
|
||||||
|
|
@ -399,8 +399,10 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
Text("Transcribe")
|
Text("Transcribe")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (e.status != FileStatus.RUNNING &&
|
// Queued files stay editable/deletable: deleting
|
||||||
e.status != FileStatus.QUEUED) {
|
// 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 }) {
|
TextButton({ renaming = e.file }) {
|
||||||
Icon(Icons.Filled.Edit, contentDescription = null,
|
Icon(Icons.Filled.Edit, contentDescription = null,
|
||||||
modifier = Modifier.size(18.dp))
|
modifier = Modifier.size(18.dp))
|
||||||
|
|
@ -410,8 +412,7 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
}
|
}
|
||||||
// No "Open" button: clicking the card opens the
|
// No "Open" button: clicking the card opens the
|
||||||
// recording (the button was redundant).
|
// recording (the button was redundant).
|
||||||
if (e.status != FileStatus.RUNNING &&
|
if (e.status != FileStatus.RUNNING) {
|
||||||
e.status != FileStatus.QUEUED) {
|
|
||||||
TextButton(
|
TextButton(
|
||||||
{ deleting = e.file },
|
{ deleting = e.file },
|
||||||
content = {
|
content = {
|
||||||
|
|
|
||||||
|
|
@ -66,15 +66,25 @@ class LibraryQueueTest {
|
||||||
LibraryQueue.selectOrClearAll(emptySet(), emptyList()))
|
LibraryQueue.selectOrClearAll(emptySet(), emptyList()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun planDeleteHoldsBackInFlightFiles() {
|
@Test fun planDeleteHoldsBackOnlyActuallyRunningFiles() {
|
||||||
val dir = createTempDirectory("lqp").toFile()
|
val dir = createTempDirectory("lqp").toFile()
|
||||||
val a = File(dir, "a.m4a").apply { writeText("x") }
|
val a = File(dir, "a.m4a").apply { writeText("x") }
|
||||||
val b = File(dir, "b.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"))
|
val (now, waiting) = LibraryQueue.planDelete(listOf(a, b), setOf("b.m4a"))
|
||||||
assertEquals(listOf(a), now)
|
assertEquals(listOf(a), now)
|
||||||
assertEquals(listOf(b), waiting)
|
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() {
|
@Test fun autoQueueSkipsReportsInFlightAndFailed() {
|
||||||
val dir = createTempDirectory("lq").toFile()
|
val dir = createTempDirectory("lq").toFile()
|
||||||
val fresh = File(dir, "fresh.m4a").apply { writeText("x") }
|
val fresh = File(dir, "fresh.m4a").apply { writeText("x") }
|
||||||
|
|
@ -160,4 +170,17 @@ class TranscribeBatchTest {
|
||||||
assertEquals(0, LibraryQueue.batchAdvance(b, "z.m4a")?.done)
|
assertEquals(0, LibraryQueue.batchAdvance(b, "z.m4a")?.done)
|
||||||
assertEquals(null, LibraryQueue.batchAdvance(null, "a.m4a"))
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue