feat: Add API endpoint for listing oneliners
This commit introduces a new API endpoint `/api/oneliners` that allows authenticated users to retrieve a list of their recent oneliners. The endpoint supports conditional GET requests using the `ETag` header for efficient caching. It also allows specifying a time window for the oneliners via the `hours` query parameter, with sensible defaults and clamping. The implementation includes: - A new route handler `handle_oneliners`. - A new type `OnelinerWatermark` for tracking user-specific timestamps. - Logic for scanning user data directories, filtering by time, and handling deleted cases. - Test cases to cover various scenarios, including caching, time windowing, and edge cases.
This commit is contained in:
@@ -5,6 +5,7 @@ use tracing::{info, warn};
|
||||
use super::{TranscribeJob, TranscribeSender};
|
||||
use crate::config::Config;
|
||||
use crate::gazetteer::Gazetteer;
|
||||
use crate::OnelinerWatermark;
|
||||
|
||||
/// Walk `data_path/*/` and enqueue every recording pending transcription for
|
||||
/// every user. Intended for startup; the same primitive backs the per-user
|
||||
@@ -83,11 +84,13 @@ pub async fn enqueue_pending_for_user(
|
||||
}
|
||||
|
||||
/// Walk `data_path/*/*` and return every case directory that has at least
|
||||
/// one non-empty `*.transcript.txt` but no `oneliner.txt`. Pure filesystem
|
||||
/// scan, no LLM calls — separated from the regeneration wrapper so it can
|
||||
/// be unit-tested without mocking Ollama.
|
||||
pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
/// one non-empty `*.transcript.txt` but no `oneliner.txt`, paired with the
|
||||
/// user-slug (parent directory name). Pure filesystem scan, no LLM calls —
|
||||
/// separated from the regeneration wrapper so it can be unit-tested without
|
||||
/// mocking Ollama. The slug is carried through so the caller can bump the
|
||||
/// per-user watermark when regeneration succeeds.
|
||||
pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<(PathBuf, String)> {
|
||||
let mut out: Vec<(PathBuf, String)> = Vec::new();
|
||||
let Ok(mut users) = tokio::fs::read_dir(data_path).await else {
|
||||
return out;
|
||||
};
|
||||
@@ -96,6 +99,9 @@ pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<PathBuf> {
|
||||
if !user_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut cases) = tokio::fs::read_dir(&user_path).await else {
|
||||
continue;
|
||||
};
|
||||
@@ -108,11 +114,11 @@ pub(crate) async fn cases_needing_oneliner(data_path: &Path) -> Vec<PathBuf> {
|
||||
continue;
|
||||
}
|
||||
if has_non_empty_transcript(&case_dir).await {
|
||||
out.push(case_dir);
|
||||
out.push((case_dir, slug.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -146,14 +152,15 @@ pub async fn regenerate_missing_oneliners(
|
||||
client: &reqwest::Client,
|
||||
config: &Config,
|
||||
vocab: &Gazetteer,
|
||||
watermark: &OnelinerWatermark,
|
||||
) {
|
||||
let cases = cases_needing_oneliner(data_path).await;
|
||||
if cases.is_empty() {
|
||||
return;
|
||||
}
|
||||
info!(count = cases.len(), "Regenerating missing oneliners");
|
||||
for case_dir in cases {
|
||||
super::worker::update_oneliner(&case_dir, client, config, vocab).await;
|
||||
for (case_dir, slug) in cases {
|
||||
super::worker::update_oneliner(&case_dir, &slug, client, config, vocab, watermark).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +179,7 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let got = cases_needing_oneliner(data.path()).await;
|
||||
assert_eq!(got, vec![case]);
|
||||
assert_eq!(got, vec![(case, "user".to_owned())]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -2,12 +2,13 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use super::{ffmpeg, ollama, whisper, TranscribeReceiver};
|
||||
use crate::config::{Config, WhisperUserSettings};
|
||||
use crate::gazetteer::Gazetteer;
|
||||
use crate::{BusyGuard, WorkerBusy};
|
||||
use crate::{BusyGuard, OnelinerWatermark, WorkerBusy};
|
||||
|
||||
const ONELINER_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
@@ -19,6 +20,7 @@ pub async fn run(
|
||||
client: reqwest::Client,
|
||||
worker_busy: WorkerBusy,
|
||||
vocab: Arc<Gazetteer>,
|
||||
watermark: OnelinerWatermark,
|
||||
) {
|
||||
info!("Transcription worker started");
|
||||
let timeout = Duration::from_secs(config.whisper_timeout_seconds);
|
||||
@@ -88,7 +90,7 @@ pub async fn run(
|
||||
if let Some(case_dir) = audio_path.parent()
|
||||
&& !has_pending_recordings(case_dir).await
|
||||
{
|
||||
update_oneliner(case_dir, &client, &config, &vocab).await;
|
||||
update_oneliner(case_dir, &job.user_slug, &client, &config, &vocab, &watermark).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,9 +129,11 @@ async fn mark_failed(audio_path: &Path) {
|
||||
/// no-op — next transcript write retries.
|
||||
pub(crate) async fn update_oneliner(
|
||||
case_dir: &Path,
|
||||
user_slug: &str,
|
||||
client: &reqwest::Client,
|
||||
config: &Config,
|
||||
vocab: &Gazetteer,
|
||||
watermark: &OnelinerWatermark,
|
||||
) {
|
||||
let path = case_dir.join("oneliner.txt");
|
||||
|
||||
@@ -154,6 +158,12 @@ pub(crate) async fn update_oneliner(
|
||||
error!(path = %path.display(), error = %e, "writing oneliner failed");
|
||||
} else {
|
||||
info!(path = %path.display(), chars = line.chars().count(), "Oneliner updated");
|
||||
// Bump the per-user watermark so the next `/api/oneliners`
|
||||
// poll sees a fresh ETag and fetches the new content.
|
||||
watermark
|
||||
.write()
|
||||
.await
|
||||
.insert(user_slug.to_string(), OffsetDateTime::now_utc());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user