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*
|
* framework. Talks to the SHONAR backend through the *shared*
|
||||||
* [CustomShonarProvider] (same files the Android app compiles).
|
* [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 }
|
enum class Screen { ENGINE, LIBRARY, DETAIL, SETTINGS }
|
||||||
|
|
||||||
|
|
@ -46,6 +46,8 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
||||||
data class LibraryEntry(
|
data class LibraryEntry(
|
||||||
val file: File,
|
val file: File,
|
||||||
val hasReport: Boolean,
|
val hasReport: Boolean,
|
||||||
|
val status: FileStatus = FileStatus.NEW,
|
||||||
|
val statusNote: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DetailUi(
|
data class DetailUi(
|
||||||
|
|
@ -119,9 +121,25 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
||||||
|
|
||||||
private var pollJob: Job? = null
|
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 {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000"
|
_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 } }
|
prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } }
|
||||||
?.let { setFolder(it, silent = true) }
|
?.let { setFolder(it, silent = true) }
|
||||||
ensureReady()
|
ensureReady()
|
||||||
|
|
@ -150,6 +168,8 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
||||||
}
|
}
|
||||||
_engine.value = EngineState.STOPPED
|
_engine.value = EngineState.STOPPED
|
||||||
_screen.value = Screen.ENGINE
|
_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
|
_connected.value = true
|
||||||
refreshModels()
|
refreshModels()
|
||||||
if (_screen.value == Screen.ENGINE) _screen.value = Screen.LIBRARY
|
if (_screen.value == Screen.ENGINE) _screen.value = Screen.LIBRARY
|
||||||
|
if (_autoTranscribe.value) pumpNewFiles()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun engineUp(): Boolean = runCatching {
|
private fun engineUp(): Boolean = runCatching {
|
||||||
|
|
@ -164,6 +185,15 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
||||||
.contains("\"ok\"")
|
.contains("\"ok\"")
|
||||||
}.getOrDefault(false)
|
}.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. */
|
/** Register-or-login the private local account. True when connected. */
|
||||||
private suspend fun provisionLocalAccount(): Boolean {
|
private suspend fun provisionLocalAccount(): Boolean {
|
||||||
val parsed = ServerUrl.parse(_serverUrl.value).getOrNull()
|
val parsed = ServerUrl.parse(_serverUrl.value).getOrNull()
|
||||||
|
|
@ -232,11 +262,12 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
||||||
// ---- library -----------------------------------------------------------
|
// ---- library -----------------------------------------------------------
|
||||||
|
|
||||||
// ---- local engine ------------------------------------------------------
|
// ---- local engine ------------------------------------------------------
|
||||||
// The transcription engine is the SHONAR backend+worker running on this
|
// The transcription engine is the SHONAR backend running on this
|
||||||
// machine. The app starts it on demand; the user never sees "servers".
|
// 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 apiProc: Process? = null
|
||||||
private var workerProc: Process? = null
|
|
||||||
|
|
||||||
fun startEngine() {
|
fun startEngine() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|
@ -260,30 +291,44 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
||||||
prefs.putString(KEY_SECRET, fresh)
|
prefs.putString(KEY_SECRET, fresh)
|
||||||
fresh
|
fresh
|
||||||
}
|
}
|
||||||
val env = mapOf(
|
val env = mutableMapOf(
|
||||||
"SHONAR_SECRET_KEY" to secret,
|
"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_PROVIDER" to "faster_whisper",
|
||||||
"SHONAR_TRANSCRIPTION_MODEL" to "base",
|
"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()
|
stopEngine()
|
||||||
apiProc = spawn(backend, File(venv, "uvicorn").absolutePath,
|
apiProc = spawn(backend, File(venv, "uvicorn").absolutePath,
|
||||||
listOf("shonar.main:app", "--port", "8000"), env)
|
listOf("shonar.main:app", "--port", "8000"), env)
|
||||||
workerProc = spawn(backend, File(venv, "arq").absolutePath,
|
|
||||||
listOf("shonar.worker.WorkerSettings"), env)
|
|
||||||
var up = false
|
var up = false
|
||||||
repeat(40) {
|
var waited = 0.0
|
||||||
delay(3000)
|
while (waited < 120.0) {
|
||||||
if (engineUp()) {
|
delay(1000)
|
||||||
|
waited += 1.0
|
||||||
|
if (apiProc?.isAlive == false) break
|
||||||
|
if (engineReady()) {
|
||||||
up = true
|
up = true
|
||||||
return@repeat
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!up) {
|
if (!up) {
|
||||||
stopEngine()
|
stopEngine()
|
||||||
_engine.value = EngineState.FAILED
|
_engine.value = EngineState.FAILED
|
||||||
_engineError.value = "Engine started but never answered " +
|
_engineError.value = "Engine started but never became " +
|
||||||
"at ${_serverUrl.value}."
|
"ready at ${_serverUrl.value}. See ${engineLogFile()}."
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
_engine.value = EngineState.RUNNING
|
_engine.value = EngineState.RUNNING
|
||||||
|
|
@ -304,9 +349,24 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
||||||
|
|
||||||
fun stopEngine() {
|
fun stopEngine() {
|
||||||
apiProc?.destroy()
|
apiProc?.destroy()
|
||||||
workerProc?.destroy()
|
|
||||||
apiProc = null
|
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? {
|
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 =
|
private fun spawn(dir: File, bin: String, args: List<String>, env: Map<String, String>): Process =
|
||||||
ProcessBuilder(listOf(bin) + args).directory(dir).apply {
|
ProcessBuilder(listOf(bin) + args).directory(dir).apply {
|
||||||
environment().putAll(env)
|
environment().putAll(env)
|
||||||
redirectOutput(File.createTempFile("shonar-desktop-", ".log").also { it.deleteOnExit() })
|
val log = engineLogFile()
|
||||||
|
log.parentFile?.mkdirs()
|
||||||
|
redirectOutput(log)
|
||||||
redirectErrorStream(true)
|
redirectErrorStream(true)
|
||||||
}.start()
|
}.start()
|
||||||
|
|
||||||
|
|
@ -336,11 +398,152 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
||||||
|
|
||||||
fun rescan() {
|
fun rescan() {
|
||||||
val dir = _folder.value ?: return
|
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 }
|
?.filter { it.isFile && !it.name.startsWith(".") && it.extension.lowercase() in AUDIO_EXTS }
|
||||||
?.sortedBy { it.name.lowercase() }
|
?.sortedBy { it.name.lowercase() }
|
||||||
?.map { f -> LibraryEntry(f, reportFile(f).exists()) }
|
|
||||||
.orEmpty()
|
.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) {
|
fun setQuery(q: String) {
|
||||||
|
|
@ -493,6 +696,9 @@ class DesktopState(appDir: File = defaultAppDir()) {
|
||||||
const val KEY_FOLDER = "library.folder"
|
const val KEY_FOLDER = "library.folder"
|
||||||
const val KEY_PASSWORD = "local.password"
|
const val KEY_PASSWORD = "local.password"
|
||||||
const val KEY_SECRET = "local.secret"
|
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).
|
// Must satisfy the engine's email validation (period in domain).
|
||||||
const val LOCAL_EMAIL = "desktop@app.shonar"
|
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.foundation.lazy.items
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -112,6 +113,13 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) {
|
OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) {
|
||||||
Text("Choose folder…")
|
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) },
|
OutlinedTextField(query, { state.setQuery(it) },
|
||||||
label = { Text("Search saved transcripts") },
|
label = { Text("Search saved transcripts") },
|
||||||
|
|
@ -150,14 +158,28 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
Text(e.file.nameWithoutExtension,
|
Text(e.file.nameWithoutExtension,
|
||||||
style = MaterialTheme.typography.titleSmall)
|
style = MaterialTheme.typography.titleSmall)
|
||||||
Text(
|
Text(
|
||||||
"%.1f MB%s".format(
|
"%.1f MB • %s".format(
|
||||||
e.file.length() / 1e6,
|
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,
|
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") }
|
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