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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user