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:
2026-04-18 09:27:23 +02:00
parent 6fbb549ddf
commit 94392e72d8
10 changed files with 658 additions and 16 deletions
+17 -10
View File
@@ -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]
+12 -2
View File
@@ -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) => {