Desktop: bundled-lite engine launch, folder watch + auto-transcribe queue
- engine spawns in bundled-lite mode: SQLite at ~/.config/shonar-desktop/ engine.db, SHONAR_QUEUE_BACKEND=inline, auto-migrate; no worker proc - Ollama autodetect: if 127.0.0.1:11434 answers, LLM=ollama with qwen3:4b (fallback: any local qwen3); else summaries honestly off - readiness waits on /readyz database:true (not just /healthz) with early exit on process death; engine log persists to logs/engine.log - app auto-starts the engine instead of parking on the Engine screen - folder watcher (4s poll): drop-ins appear without manual rescan - auto-transcribe queue (persisted toggle): pump runs one file at a time, skips files with reports/in-flight/previously-failed (no hot loop); per-file Transcribe button for NEW/FAILED rows; status badges in list - pure LibraryQueue logic + 3 unit tests (desktop suite 8 green)
This commit is contained in:
parent
eea51c6741
commit
1475f48329
4 changed files with 370 additions and 22 deletions
|
|
@ -36,7 +36,7 @@ import okhttp3.OkHttpClient
|
|||
* framework. Talks to the SHONAR backend through the *shared*
|
||||
* [CustomShonarProvider] (same files the Android app compiles).
|
||||
*/
|
||||
class DesktopState(appDir: File = defaultAppDir()) {
|
||||
class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||
|
||||
enum class Screen { ENGINE, LIBRARY, DETAIL, SETTINGS }
|
||||
|
||||
|
|
@ -46,6 +46,8 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
data class LibraryEntry(
|
||||
val file: File,
|
||||
val hasReport: Boolean,
|
||||
val status: FileStatus = FileStatus.NEW,
|
||||
val statusNote: String? = null,
|
||||
)
|
||||
|
||||
data class DetailUi(
|
||||
|
|
@ -119,9 +121,25 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
|
||||
private var pollJob: Job? = null
|
||||
|
||||
/** Auto-queue new files as they appear (persisted preference). */
|
||||
private val _autoTranscribe = MutableStateFlow(true)
|
||||
val autoTranscribe: StateFlow<Boolean> = _autoTranscribe.asStateFlow()
|
||||
|
||||
/** Files the pump is running or has queued this session (by name). */
|
||||
private val inFlight = mutableSetOf<String>()
|
||||
|
||||
/** Files whose last auto attempt failed (no hot-loop retries). */
|
||||
private val failedFiles = mutableSetOf<String>()
|
||||
|
||||
/** Serial pump: one file at a time (engine runs one job anyway). */
|
||||
private val queue = java.util.concurrent.ConcurrentLinkedQueue<File>()
|
||||
private var pumpJob: Job? = null
|
||||
private var watchJob: Job? = null
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
_serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000"
|
||||
_autoTranscribe.value = prefs.getString(KEY_AUTO) != "0"
|
||||
prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } }
|
||||
?.let { setFolder(it, silent = true) }
|
||||
ensureReady()
|
||||
|
|
@ -150,6 +168,8 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
}
|
||||
_engine.value = EngineState.STOPPED
|
||||
_screen.value = Screen.ENGINE
|
||||
// The app owns the engine: start it instead of waiting.
|
||||
startEngine()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,6 +177,7 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
_connected.value = true
|
||||
refreshModels()
|
||||
if (_screen.value == Screen.ENGINE) _screen.value = Screen.LIBRARY
|
||||
if (_autoTranscribe.value) pumpNewFiles()
|
||||
}
|
||||
|
||||
private fun engineUp(): Boolean = runCatching {
|
||||
|
|
@ -164,6 +185,15 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
.contains("\"ok\"")
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** Readiness (not just liveness): the engine's database is usable. */
|
||||
private fun engineReady(): Boolean = runCatching {
|
||||
java.net.URL("${_serverUrl.value}/api/v1/readyz").readText()
|
||||
.contains("\"database\":true")
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** Persistent engine log so failures are actually debuggable. */
|
||||
fun engineLogFile(): File = File(appDir, "logs/engine.log")
|
||||
|
||||
/** Register-or-login the private local account. True when connected. */
|
||||
private suspend fun provisionLocalAccount(): Boolean {
|
||||
val parsed = ServerUrl.parse(_serverUrl.value).getOrNull()
|
||||
|
|
@ -232,11 +262,12 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
// ---- library -----------------------------------------------------------
|
||||
|
||||
// ---- local engine ------------------------------------------------------
|
||||
// The transcription engine is the SHONAR backend+worker running on this
|
||||
// machine. The app starts it on demand; the user never sees "servers".
|
||||
// The transcription engine is the SHONAR backend running on this
|
||||
// machine in bundled-lite mode: SQLite + inline job queue, no
|
||||
// Postgres/Redis/Docker. The app starts it on demand; the user never
|
||||
// sees "servers". Summaries use Ollama when it's reachable locally.
|
||||
|
||||
private var apiProc: Process? = null
|
||||
private var workerProc: Process? = null
|
||||
|
||||
fun startEngine() {
|
||||
scope.launch {
|
||||
|
|
@ -260,30 +291,44 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
prefs.putString(KEY_SECRET, fresh)
|
||||
fresh
|
||||
}
|
||||
val env = mapOf(
|
||||
val env = mutableMapOf(
|
||||
"SHONAR_SECRET_KEY" to secret,
|
||||
"SHONAR_STORAGE_PATH" to File(repo, "data/storage").absolutePath,
|
||||
// Bundled-lite: one process, SQLite, in-process queue,
|
||||
// schema migrates itself on boot.
|
||||
"SHONAR_DATABASE_URL" to
|
||||
"sqlite+aiosqlite:///${File(appDir, "engine.db").absolutePath}",
|
||||
"SHONAR_QUEUE_BACKEND" to "inline",
|
||||
"SHONAR_AUTO_MIGRATE" to "1",
|
||||
"SHONAR_STORAGE_PATH" to File(appDir, "storage").absolutePath,
|
||||
"SHONAR_TRANSCRIPTION_PROVIDER" to "faster_whisper",
|
||||
"SHONAR_TRANSCRIPTION_MODEL" to "base",
|
||||
)
|
||||
if (ollamaUp()) {
|
||||
env["SHONAR_LLM_PROVIDER"] = "ollama"
|
||||
env["SHONAR_LLM_BASE_URL"] = "http://127.0.0.1:11434"
|
||||
env["SHONAR_LLM_MODEL"] = ollamaModel()
|
||||
} else {
|
||||
env["SHONAR_LLM_PROVIDER"] = "none"
|
||||
}
|
||||
stopEngine()
|
||||
apiProc = spawn(backend, File(venv, "uvicorn").absolutePath,
|
||||
listOf("shonar.main:app", "--port", "8000"), env)
|
||||
workerProc = spawn(backend, File(venv, "arq").absolutePath,
|
||||
listOf("shonar.worker.WorkerSettings"), env)
|
||||
var up = false
|
||||
repeat(40) {
|
||||
delay(3000)
|
||||
if (engineUp()) {
|
||||
var waited = 0.0
|
||||
while (waited < 120.0) {
|
||||
delay(1000)
|
||||
waited += 1.0
|
||||
if (apiProc?.isAlive == false) break
|
||||
if (engineReady()) {
|
||||
up = true
|
||||
return@repeat
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!up) {
|
||||
stopEngine()
|
||||
_engine.value = EngineState.FAILED
|
||||
_engineError.value = "Engine started but never answered " +
|
||||
"at ${_serverUrl.value}."
|
||||
_engineError.value = "Engine started but never became " +
|
||||
"ready at ${_serverUrl.value}. See ${engineLogFile()}."
|
||||
return@launch
|
||||
}
|
||||
_engine.value = EngineState.RUNNING
|
||||
|
|
@ -304,9 +349,24 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
|
||||
fun stopEngine() {
|
||||
apiProc?.destroy()
|
||||
workerProc?.destroy()
|
||||
apiProc = null
|
||||
workerProc = null
|
||||
}
|
||||
|
||||
private fun ollamaUp(): Boolean = runCatching {
|
||||
java.net.URL("http://127.0.0.1:11434/api/tags").readText().contains("\"models\"")
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** Prefer the tuned default; fall back to any qwen3 the user has. */
|
||||
private fun ollamaModel(): String {
|
||||
val names = runCatching {
|
||||
val body = java.net.URL("http://127.0.0.1:11434/api/tags").readText()
|
||||
val arr = org.json.JSONObject(body).getJSONArray("models")
|
||||
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||
}.getOrDefault(emptyList())
|
||||
return when {
|
||||
names.any { it == DEFAULT_LLM_MODEL } -> DEFAULT_LLM_MODEL
|
||||
else -> names.firstOrNull { it.startsWith("qwen3") } ?: DEFAULT_LLM_MODEL
|
||||
}
|
||||
}
|
||||
|
||||
private fun repoRoot(): File? {
|
||||
|
|
@ -320,7 +380,9 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
private fun spawn(dir: File, bin: String, args: List<String>, env: Map<String, String>): Process =
|
||||
ProcessBuilder(listOf(bin) + args).directory(dir).apply {
|
||||
environment().putAll(env)
|
||||
redirectOutput(File.createTempFile("shonar-desktop-", ".log").also { it.deleteOnExit() })
|
||||
val log = engineLogFile()
|
||||
log.parentFile?.mkdirs()
|
||||
redirectOutput(log)
|
||||
redirectErrorStream(true)
|
||||
}.start()
|
||||
|
||||
|
|
@ -336,11 +398,152 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
|
||||
fun rescan() {
|
||||
val dir = _folder.value ?: return
|
||||
_entries.value = dir.listFiles()
|
||||
val files = dir.listFiles()
|
||||
?.filter { it.isFile && !it.name.startsWith(".") && it.extension.lowercase() in AUDIO_EXTS }
|
||||
?.sortedBy { it.name.lowercase() }
|
||||
?.map { f -> LibraryEntry(f, reportFile(f).exists()) }
|
||||
.orEmpty()
|
||||
_entries.value = files.map { f ->
|
||||
val done = reportFile(f).exists()
|
||||
LibraryEntry(
|
||||
file = f,
|
||||
hasReport = done,
|
||||
status = when {
|
||||
done -> FileStatus.DONE
|
||||
f.name in failedFiles -> FileStatus.FAILED
|
||||
f.name in inFlight -> FileStatus.RUNNING
|
||||
else -> FileStatus.NEW
|
||||
},
|
||||
)
|
||||
}
|
||||
startWatcher()
|
||||
if (_autoTranscribe.value && _connected.value) pumpNewFiles()
|
||||
}
|
||||
|
||||
/** Poll the folder for changes (drop-ins appear without a manual rescan). */
|
||||
private fun startWatcher() {
|
||||
if (watchJob?.isActive == true) return
|
||||
watchJob = scope.launch {
|
||||
var lastStamp = _folder.value?.lastModified() ?: 0L
|
||||
var lastCount = _entries.value.size
|
||||
while (true) {
|
||||
delay(4000)
|
||||
val dir = _folder.value ?: break
|
||||
val count = dir.listFiles()?.size ?: 0
|
||||
val stamp = dir.lastModified()
|
||||
if (stamp != lastStamp || count != lastCount) {
|
||||
lastStamp = stamp
|
||||
lastCount = count
|
||||
rescan()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoTranscribe(on: Boolean) {
|
||||
_autoTranscribe.value = on
|
||||
scope.launch { prefs.putString(KEY_AUTO, if (on) "1" else "0") }
|
||||
if (on) pumpNewFiles()
|
||||
}
|
||||
|
||||
/** Queue every candidate file that has no report and isn't in flight. */
|
||||
fun pumpNewFiles() {
|
||||
val candidates = LibraryQueue.autoQueueCandidates(
|
||||
entries = _entries.value.map { it.file },
|
||||
reportExists = { reportFile(it).exists() },
|
||||
inFlightOrDone = inFlight,
|
||||
failed = failedFiles,
|
||||
)
|
||||
if (candidates.isEmpty()) return
|
||||
queue.addAll(candidates)
|
||||
candidates.forEach { inFlight += it.name }
|
||||
rescanStatuses()
|
||||
startPump()
|
||||
}
|
||||
|
||||
/** Queue one specific file (manual Transcribe button). */
|
||||
fun pumpFile(f: File) {
|
||||
if (f.name in inFlight) return
|
||||
queue.add(f)
|
||||
inFlight += f.name
|
||||
rescanStatuses()
|
||||
startPump()
|
||||
}
|
||||
|
||||
private fun startPump() {
|
||||
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
|
||||
inFlight -= f.name
|
||||
rescanStatuses()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rescanStatuses() {
|
||||
_entries.value = _entries.value.map { e ->
|
||||
val done = reportFile(e.file).exists()
|
||||
e.copy(
|
||||
hasReport = done,
|
||||
status = when {
|
||||
done -> FileStatus.DONE
|
||||
e.file.name in failedFiles -> FileStatus.FAILED
|
||||
e.file.name in inFlight -> FileStatus.QUEUED
|
||||
else -> FileStatus.NEW
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Background transcription of one file (no Detail UI coupling).
|
||||
* Returns true when the report was written. Waits for BOTH the
|
||||
* transcribe and summarize jobs to settle so the report is complete.
|
||||
*/
|
||||
suspend fun transcribeFile(f: File): Boolean {
|
||||
if (_connected.value && runCatching { provider.reconnect() }
|
||||
.getOrNull() != AuthState.CONNECTED) {
|
||||
_connected.value = false
|
||||
_engine.value = EngineState.STOPPED
|
||||
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) {}
|
||||
var jobs: List<JobInfo> = emptyList()
|
||||
var polls = 0
|
||||
while (polls < 600) { // up to ~30 min per file at 3s polls
|
||||
polls++
|
||||
delay(3000)
|
||||
jobs = runCatching { parseJobs(provider.fetchJobs(ref.key)) }.getOrNull().orEmpty()
|
||||
val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status
|
||||
val s = jobs.firstOrNull { it.jobType == "summarize" }?.status
|
||||
if ((t == null || t in TERMINAL) && (s == null || s in TERMINAL)) break
|
||||
}
|
||||
val transcribeFailed = jobs.any {
|
||||
it.jobType == "transcribe" && it.status == "failed"
|
||||
}
|
||||
if (transcribeFailed) return false
|
||||
val transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) }
|
||||
.getOrNull() ?: return false
|
||||
val summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull()
|
||||
saveReport(DetailUi(file = f, transcript = transcript, summary = summary))
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun setQuery(q: String) {
|
||||
|
|
@ -493,6 +696,9 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
|||
const val KEY_FOLDER = "library.folder"
|
||||
const val KEY_PASSWORD = "local.password"
|
||||
const val KEY_SECRET = "local.secret"
|
||||
const val KEY_AUTO = "library.auto_transcribe"
|
||||
/** Preferred local summarization model (Ollama). */
|
||||
const val DEFAULT_LLM_MODEL = "qwen3:4b"
|
||||
// Must satisfy the engine's email validation (period in domain).
|
||||
const val LOCAL_EMAIL = "desktop@app.shonar"
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Pure library-queue logic (unit-tested; no coroutines, no Android).
|
||||
*
|
||||
* [plan] decides which files on disk are transcription candidates, and
|
||||
* the queue pump consumes them in a stable order. All policy lives here
|
||||
* so the state class stays plumbing.
|
||||
*/
|
||||
|
||||
enum class FileStatus {
|
||||
/** No report yet — a candidate. */
|
||||
NEW,
|
||||
|
||||
/** Queued for auto-transcription. */
|
||||
QUEUED,
|
||||
|
||||
/** Upload/transcribe/summarize running now. */
|
||||
RUNNING,
|
||||
|
||||
/** Report written next to the audio. */
|
||||
DONE,
|
||||
|
||||
/** Last attempt failed (error message kept beside it). */
|
||||
FAILED,
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff between two scans of the library folder. [added] preserves sorted
|
||||
* file-name order so the queue is deterministic across rescans.
|
||||
*/
|
||||
class LibraryDiff(
|
||||
val added: List<File>,
|
||||
/** Names that disappeared since the previous scan. */
|
||||
val removed: Set<String>,
|
||||
)
|
||||
|
||||
object LibraryQueue {
|
||||
|
||||
/** Audio candidates: regular files, known extension, not hidden. */
|
||||
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =
|
||||
f.isFile && !f.name.startsWith(".") &&
|
||||
f.extension.lowercase() in audioExts
|
||||
|
||||
fun diff(before: Set<String>, after: List<File>): LibraryDiff {
|
||||
val afterNames = after.map { it.name }.toSet()
|
||||
val added = after.filter { it.name !in before }
|
||||
val removed = before - afterNames
|
||||
return LibraryDiff(added, removed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Files to (re)queue for auto-transcription: candidates without a
|
||||
* report, minus files already in-flight or previously failed since
|
||||
* the last scan (a failure must not hot-loop; a rescan after the
|
||||
* user edits/renames clears it naturally when the report appears).
|
||||
*/
|
||||
fun autoQueueCandidates(
|
||||
entries: List<File>,
|
||||
reportExists: (File) -> Boolean,
|
||||
inFlightOrDone: Set<String>,
|
||||
failed: Set<String>,
|
||||
): List<File> = entries.filter {
|
||||
!reportExists(it) && it.name !in inFlightOrDone && it.name !in failed
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -112,6 +113,13 @@ fun LibraryScreen(state: DesktopState) {
|
|||
OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) {
|
||||
Text("Choose folder…")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val auto = state.autoTranscribe.collectAsState().value
|
||||
Checkbox(auto, { state.setAutoTranscribe(it) })
|
||||
Text("Auto-transcribe new files",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(query, { state.setQuery(it) },
|
||||
label = { Text("Search saved transcripts") },
|
||||
|
|
@ -150,14 +158,28 @@ fun LibraryScreen(state: DesktopState) {
|
|||
Text(e.file.nameWithoutExtension,
|
||||
style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
"%.1f MB%s".format(
|
||||
"%.1f MB • %s".format(
|
||||
e.file.length() / 1e6,
|
||||
if (e.hasReport) " • transcript saved" else "",
|
||||
when (e.status) {
|
||||
FileStatus.DONE -> "transcript saved"
|
||||
FileStatus.QUEUED -> "queued for transcription"
|
||||
FileStatus.RUNNING -> "transcribing…"
|
||||
FileStatus.FAILED ->
|
||||
"failed" + (e.statusNote?.let { " ($it)" } ?: "")
|
||||
FileStatus.NEW -> "not transcribed"
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = when (e.status) {
|
||||
FileStatus.FAILED -> MaterialTheme.colorScheme.error
|
||||
FileStatus.DONE -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
if (e.status == FileStatus.NEW || e.status == FileStatus.FAILED) {
|
||||
TextButton({ state.pumpFile(e.file) }) { Text("Transcribe") }
|
||||
}
|
||||
TextButton({ state.openDetail(e.file) }) { Text("Open") }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LibraryQueueTest {
|
||||
|
||||
private val exts = setOf("m4a", "wav", "mp3")
|
||||
|
||||
@Test fun candidateOnlyForKnownAudioExt() {
|
||||
val dir = createTempDirectory("lq").toFile()
|
||||
val audio = File(dir, "a.m4a").apply { writeText("x") }
|
||||
val note = File(dir, "notes.txt").apply { writeText("x") }
|
||||
val hidden = File(dir, ".hidden.m4a").apply { writeText("x") }
|
||||
assertTrue(LibraryQueue.isAudioCandidate(audio, exts))
|
||||
assertTrue(!LibraryQueue.isAudioCandidate(note, exts))
|
||||
assertTrue(!LibraryQueue.isAudioCandidate(hidden, exts))
|
||||
assertTrue(!LibraryQueue.isAudioCandidate(File(dir, "sub"), exts))
|
||||
}
|
||||
|
||||
@Test fun diffDetectsAddedAndRemoved() {
|
||||
val dir = createTempDirectory("lq").toFile()
|
||||
val a = File(dir, "a.m4a").apply { writeText("x") }
|
||||
val b = File(dir, "b.m4a").apply { writeText("x") }
|
||||
val d = LibraryQueue.diff(setOf("gone.m4a"), listOf(a, b))
|
||||
assertEquals(listOf("a.m4a", "b.m4a"), d.added.map { it.name })
|
||||
assertEquals(setOf("gone.m4a"), d.removed)
|
||||
}
|
||||
|
||||
@Test fun autoQueueSkipsReportsInFlightAndFailed() {
|
||||
val dir = createTempDirectory("lq").toFile()
|
||||
val fresh = File(dir, "fresh.m4a").apply { writeText("x") }
|
||||
val done = File(dir, "done.m4a").apply { writeText("x") }
|
||||
File(dir, "done.transcript.md").writeText("# done")
|
||||
val running = File(dir, "running.m4a").apply { writeText("x") }
|
||||
val broken = File(dir, "broken.m4a").apply { writeText("x") }
|
||||
|
||||
val picked = LibraryQueue.autoQueueCandidates(
|
||||
entries = listOf(fresh, done, running, broken),
|
||||
reportExists = { f ->
|
||||
File(f.parentFile, "${f.nameWithoutExtension}.transcript.md").exists()
|
||||
},
|
||||
inFlightOrDone = setOf("running.m4a"),
|
||||
failed = setOf("broken.m4a"),
|
||||
)
|
||||
assertEquals(listOf("fresh.m4a"), picked.map { it.name })
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue