Refactor busy flags and worker guards

The `WorkerBusy` type was previously a single `Arc<AtomicBool>` shared
between the analyze and transcribe workers. This commit refactors this
to:

- Introduce `AnalyzeBusy` and `TranscribeBusy` newtype wrappers around
  `WorkerBusy` to distinguish between the two flags. This allows
  `axum::extract::State` to target them individually.
- Move the `BusyGuard` RAII guard into `src/lib.rs` and make it generic
  to work with any `WorkerBusy` instance.
- Update the `main.rs`, `worker.rs`, and `routes/user_web.rs` files to
  use the new types and guards, ensuring correct state management for
  both pipelines.
- Enhance the transcription recovery scan
  (`transcribe::recovery::scan_and_enqueue`) to iterate over user
  directories and call `enqueue_pending_for_user` for each, making it
  more robust and aligned with the per-user self-healing mechanism.
- Add a check for `transcribe_busy` in the `case_detail.html` template
  to correctly display "Transkription läuft…" only when the transcribe
  worker is actually active.
This commit is contained in:
2026-04-16 00:32:45 +02:00
parent 9072d7a833
commit 2f62f11563
8 changed files with 175 additions and 118 deletions
+65 -69
View File
@@ -1,85 +1,81 @@
use std::path::{Path, PathBuf};
use std::path::Path;
use tracing::{info, warn};
use super::{TranscribeJob, TranscribeSender};
/// Walk `data_path/*/*/*.m4a` and enqueue every recording that does not
/// yet have a `.transcript.txt` sibling.
///
/// Intended to run once at startup (spawn as a task — sends may back-pressure
/// if many recordings are pending and the queue fills).
/// Walk `data_path/*/` and enqueue every recording pending transcription for
/// every user. Intended for startup; the same primitive backs the per-user
/// self-heal triggered by web handlers.
pub async fn scan_and_enqueue(data_path: &Path, tx: &TranscribeSender) {
let pending = collect_pending(data_path).await;
let total = pending.len();
info!(count = total, "Recovery scan found pending recordings");
let Ok(mut users) = tokio::fs::read_dir(data_path).await else {
return;
};
// Collect + sort by slug so enqueue order is deterministic across
// users (within a user, the primitive sorts by timestamp).
let mut user_dirs: Vec<(String, std::path::PathBuf)> = Vec::new();
while let Ok(Some(user_entry)) = users.next_entry().await {
if let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) {
user_dirs.push((slug, user_entry.path()));
}
}
user_dirs.sort_by(|a, b| a.0.cmp(&b.0));
for (user_slug, audio_path) in pending {
let mut total = 0;
for (slug, path) in user_dirs {
total += enqueue_pending_for_user(&path, &slug, tx).await;
}
info!(count = total, "Transcribe recovery scan finished");
}
/// Scan a single `<user_root>/<case>/*.m4a` set and enqueue every recording
/// whose `.transcript.txt` is missing. Returns the number sent. Channel send
/// is awaited (bounded channel); only fails if the worker is gone.
pub async fn enqueue_pending_for_user(
user_root: &Path,
slug: &str,
tx: &TranscribeSender,
) -> usize {
let Ok(mut cases) = tokio::fs::read_dir(user_root).await else {
return 0;
};
let mut pending: Vec<std::path::PathBuf> = Vec::new();
while let Ok(Some(case_entry)) = cases.next_entry().await {
let case_dir = case_entry.path();
if !case_dir.is_dir() || crate::paths::is_deleted(&case_dir).await {
continue;
}
let Ok(mut files) = tokio::fs::read_dir(&case_dir).await else {
continue;
};
while let Ok(Some(file)) = files.next_entry().await {
let path = file.path();
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
continue;
}
if path.with_extension("transcript.txt").exists() {
continue;
}
pending.push(path);
}
}
// Oldest first — filenames are ISO timestamps so path order = chronological.
pending.sort();
let mut sent = 0;
for path in pending {
if tx
.send(TranscribeJob {
audio_path: audio_path.clone(),
user_slug: user_slug.clone(),
audio_path: path.clone(),
user_slug: slug.to_owned(),
})
.await
.is_err()
{
warn!(file = %audio_path.display(), "Recovery send failed (worker gone)");
break;
warn!(file = %path.display(), "recovery send failed (worker gone)");
return sent;
}
sent += 1;
}
}
/// Walks `$data_path/<slug>/<case>/*.m4a` and returns `(slug, audio_path)`
/// tuples for every recording without a `.transcript.txt` sibling. The slug
/// comes from the top-level user directory name — that's the only ownership
/// signal after a restart (auth context is gone).
async fn collect_pending(data_path: &Path) -> Vec<(String, PathBuf)> {
let mut out: Vec<(String, PathBuf)> = Vec::new();
let mut users = match tokio::fs::read_dir(data_path).await {
Ok(r) => r,
Err(_) => return out,
};
while let Ok(Some(user_entry)) = users.next_entry().await {
let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) else {
continue;
};
let mut cases = match tokio::fs::read_dir(user_entry.path()).await {
Ok(r) => r,
Err(_) => continue,
};
while let Ok(Some(case_entry)) = cases.next_entry().await {
let case_dir = case_entry.path();
if !case_dir.is_dir() {
continue;
}
if crate::paths::is_deleted(&case_dir).await {
continue;
}
let mut files = match tokio::fs::read_dir(&case_dir).await {
Ok(r) => r,
Err(_) => continue,
};
while let Ok(Some(file)) = files.next_entry().await {
let path = file.path();
if path.extension().and_then(|s| s.to_str()) != Some("m4a") {
continue;
}
let transcript = path.with_extension("transcript.txt");
if !transcript.exists() {
out.push((slug.clone(), path));
}
}
}
}
// Oldest first (filenames are ISO timestamps). Path ordering puts same-user
// entries together, which is fine — we still process strictly by timestamp
// within a user directory.
out.sort_by(|a, b| a.1.cmp(&b.1));
out
sent
}
+8 -1
View File
@@ -6,16 +6,23 @@ use tracing::{error, info, warn};
use super::{ffmpeg, ollama, whisper, TranscribeReceiver};
use crate::config::{Config, WhisperUserSettings};
use crate::{BusyGuard, WorkerBusy};
const ONELINER_TIMEOUT: Duration = Duration::from_secs(60);
/// Consume transcription jobs sequentially. Whisper holds the GPU, so parallelism
/// here would only cause contention. One job in flight at a time.
pub async fn run(mut rx: TranscribeReceiver, config: Arc<Config>, client: reqwest::Client) {
pub async fn run(
mut rx: TranscribeReceiver,
config: Arc<Config>,
client: reqwest::Client,
worker_busy: WorkerBusy,
) {
info!("Transcription worker started");
let timeout = Duration::from_secs(config.whisper_timeout_seconds);
while let Some(job) = rx.recv().await {
let _guard = BusyGuard::new(worker_busy.clone());
let audio_path = job.audio_path;
let transcript_path = audio_path.with_extension("transcript.txt");