Refactor upload and polling logic

This commit consolidates the upload and polling functionalities into a
single `ServerSync` worker. The `oneliner_poller` and `uploader` modules
have been removed, and their responsibilities are now handled by
`server_sync`.

The `ServerSync` worker manages both uploading pending recordings and
polling the `/api/oneliners` endpoint. Uploads take precedence, and
polling only occurs when the pending upload directory is empty. The
worker now uses a unified HTTP client and a single `tokio::select!` loop
for managing these tasks.

Key changes include:
- Removal of `oneliner_poller.rs` and `uploader.rs`.
- Introduction of `server_sync.rs` and `upload.rs` in
  `doctate-client-core`.
- `ServerSync` now handles upload events (`UploadEvent`) and provides a
  `WorkerSnapshot` for UI feedback.
- Upload logic has been refactored to handle transient and terminal
  errors more robustly, including file deletion for terminal failures.
- Polling logic is integrated into the `ServerSync` loop, utilizing the
  existing `SnapshotCache` and `CaseStore`.
- The `App` component in `client-desktop` has been updated to use
  `ServerSync` instead of the separate poller and uploader.
- Documentation comments have been updated to reflect the new structure.
This commit is contained in:
2026-04-18 13:24:38 +02:00
parent cab71e6a26
commit 305d739f56
10 changed files with 1473 additions and 1169 deletions
+166 -143
View File
@@ -1,12 +1,21 @@
//! The egui application: config panel, record/stop controls, case list,
//! state transitions driven by recorder + uploader + poller channels.
//! The egui application: config panel, record/stop controls, case list.
//!
//! State transitions are driven by:
//! - user clicks (new/continue/stop/open/save-config/dismiss-error)
//! - recorder events (ffmpeg finalizing → RecorderEvent::Finished)
//! - upload events (terminal failure → AppState::Error)
//!
//! Connectivity and upload-progress indicators live in the footer; they
//! never block the main state machine. See
//! `berpr-fe-den-plan-lively-journal.md` for the design rationale.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use doctate_client_core::{
cleanup_orphan_audio, CaseMarker, CaseStore, OnelinerPoller, PollerConfig, SnapshotCache,
cleanup_orphan_audio, write_sidecar, CaseMarker, CaseStore, PendingUpload, ServerSync,
SnapshotCache, SyncConfig, UploadEvent, WorkerSnapshot, WorkerState,
};
use doctate_common::timestamp::{now_rfc3339, recorded_at_to_filename_stem};
use eframe::egui;
@@ -17,8 +26,7 @@ use uuid::Uuid;
use crate::config::Config;
use crate::recorder::{Recorder, RecorderEvent};
use crate::state::AppState;
use crate::uploader::{write_sidecar, PendingUpload, UploadEvent, Uploader};
use crate::state::{pick_footer_status, AppState, FooterStatus};
pub struct DoctateApp {
runtime: Arc<Runtime>,
@@ -37,10 +45,15 @@ pub struct DoctateApp {
recorder: Option<Recorder>,
recorder_events: Option<mpsc::UnboundedReceiver<RecorderEvent>>,
uploader: Option<Uploader>,
server_sync: Option<ServerSync>,
worker_rx: Option<watch::Receiver<WorkerSnapshot>>,
upload_events: Option<mpsc::UnboundedReceiver<UploadEvent>>,
poller: Option<OnelinerPoller>,
/// Context for an in-flight ffmpeg flush: we left `AppState::Recording`
/// for `AppState::Idle` on Stop (so the UI stays responsive), but we
/// still need `case_id` + `recorded_at` when the recorder emits
/// `Finished`. `None` when no flush is in progress.
finalizing: Option<(Uuid, String)>,
}
enum UiAction {
@@ -77,8 +90,6 @@ impl DoctateApp {
.map(|c| c.api_key.clone())
.unwrap_or_default();
// Open the case store before any background task — the store
// owns the watch::Sender that drives the UI list.
let (store, snapshot_rx) = runtime
.block_on(async { CaseStore::open(cases_dir).await })
.expect("open case store");
@@ -86,25 +97,19 @@ impl DoctateApp {
let snapshot_cache = Arc::new(SnapshotCache::new(snapshot_cache_path));
let http_client = Arc::new(reqwest::Client::new());
// Startup cleanup — runs once, before the poller and uploader
// spawn. Keeps the client a lean ephemeral microphone:
// - markers older than 72 h (and server-confirmed) drop out,
// - orphan audio older than 24 h is reaped (patient-data
// minimization; a file that never found its sidecar will
// never be uploaded).
// Startup cleanup — one-shot, before the worker spawns.
{
let store = case_store.clone();
let pending = pending_dir.clone();
runtime.block_on(async move {
let marker_cutoff =
time::OffsetDateTime::now_utc() - time::Duration::hours(72);
let marker_cutoff = time::OffsetDateTime::now_utc() - time::Duration::hours(72);
match store.cleanup_stale(marker_cutoff).await {
Ok(n) if n > 0 => info!(count = n, "startup cleanup: stale markers removed"),
Ok(_) => {}
Err(e) => warn!(error = %e, "startup cleanup: marker sweep failed"),
}
let audio_cutoff = std::time::SystemTime::now()
- std::time::Duration::from_secs(24 * 3600);
let audio_cutoff =
std::time::SystemTime::now() - std::time::Duration::from_secs(24 * 3600);
let n = cleanup_orphan_audio(&pending, audio_cutoff).await;
if n > 0 {
info!(count = n, "startup cleanup: orphan audio removed");
@@ -112,8 +117,8 @@ impl DoctateApp {
});
}
let (state, uploader, upload_events, poller) = if let Some(cfg) = &config {
let (up, rx, pol) = spawn_background(
let (state, server_sync, worker_rx, upload_events) = if let Some(cfg) = &config {
let (sync, events, rx) = spawn_worker(
&runtime,
cfg.clone(),
pending_dir.clone(),
@@ -121,7 +126,7 @@ impl DoctateApp {
case_store.clone(),
snapshot_cache.clone(),
);
(AppState::Idle, Some(up), Some(rx), Some(pol))
(AppState::Idle, Some(sync), Some(rx), Some(events))
} else {
(AppState::NotConfigured, None, None, None)
};
@@ -139,9 +144,10 @@ impl DoctateApp {
snapshot_cache,
recorder: None,
recorder_events: None,
uploader,
server_sync,
worker_rx,
upload_events,
poller,
finalizing: None,
}
}
@@ -167,8 +173,6 @@ impl DoctateApp {
}
info!("config saved");
// New API key = potentially different user. Wipe local state so
// we don't leak old user's cases into the new user's view.
let store = self.case_store.clone();
let cache = self.snapshot_cache.clone();
self.runtime.block_on(async move {
@@ -180,13 +184,12 @@ impl DoctateApp {
}
});
// Drop old background tasks explicitly so their AbortHandle /
// channel-close fires before the new ones spawn.
self.poller = None;
self.uploader = None;
// Drop old worker explicitly so its abort fires before new spawn.
self.server_sync = None;
self.worker_rx = None;
self.upload_events = None;
let (up, rx, pol) = spawn_background(
let (sync, events, rx) = spawn_worker(
&self.runtime,
cfg.clone(),
self.pending_dir.clone(),
@@ -194,11 +197,12 @@ impl DoctateApp {
self.case_store.clone(),
self.snapshot_cache.clone(),
);
self.uploader = Some(up);
self.upload_events = Some(rx);
self.poller = Some(pol);
self.server_sync = Some(sync);
self.worker_rx = Some(rx);
self.upload_events = Some(events);
self.config = Some(cfg);
self.state = AppState::Idle;
self.finalizing = None;
}
fn on_start_new(&mut self) {
@@ -210,7 +214,9 @@ impl DoctateApp {
}
fn start_recording_for(&mut self, case_id: Uuid, is_continue: bool) {
if !matches!(self.state, AppState::Idle) {
// Two-part guard: user-visible state AND no lingering recorder
// from a previous stop that is still flushing.
if !matches!(self.state, AppState::Idle) || self.recorder.is_some() {
return;
}
if let Err(e) = std::fs::create_dir_all(&self.pending_dir) {
@@ -225,8 +231,7 @@ impl DoctateApp {
let output_path = self.pending_dir.join(format!("{stem}.m4a"));
// Persist the case marker before recording so the UI list
// reflects the new case immediately (even if the recorder fails
// to start, the marker stays — harmless and reproducible).
// reflects the new case immediately.
let store = self.case_store.clone();
let now = time::OffsetDateTime::now_utc();
self.runtime.block_on(async move {
@@ -273,10 +278,10 @@ impl DoctateApp {
if let Some(rec) = self.recorder.take() {
rec.stop();
}
self.state = AppState::FinalizingRecording {
case_id,
recorded_at,
};
// UI is ready for the next recording immediately — the finalizing
// ffmpeg flush shows up as a footer spinner, not a blocking state.
self.finalizing = Some((case_id, recorded_at));
self.state = AppState::Idle;
}
fn on_open_web(&self, case_id: Uuid) {
@@ -309,6 +314,7 @@ impl DoctateApp {
}
RecorderEvent::Failed { error: err } => {
error!(error = %err, "recorder failed");
self.finalizing = None;
self.state = AppState::Error {
message: format!("Aufnahme: {err}"),
};
@@ -320,25 +326,27 @@ impl DoctateApp {
}
fn handle_recording_finished(&mut self, output: PathBuf) {
let (case_id, recorded_at) = match &self.state {
AppState::FinalizingRecording {
case_id,
recorded_at,
} => (*case_id, recorded_at.clone()),
AppState::Recording {
case_id,
recorded_at,
..
} => (*case_id, recorded_at.clone()),
other => {
warn!(state = ?other, "recorder finished in unexpected state");
self.recorder_events = None;
self.recorder = None;
return;
}
// Prefer the explicit finalizing context (Stop-flow). Fall back
// to AppState::Recording (recorder terminated before Stop — rare
// but possible).
let (case_id, recorded_at) = if let Some((id, rec)) = self.finalizing.take() {
(id, rec)
} else if let AppState::Recording {
case_id,
recorded_at,
..
} = &self.state
{
let pair = (*case_id, recorded_at.clone());
self.state = AppState::Idle;
pair
} else {
warn!(state = ?self.state, "recorder finished in unexpected state");
self.recorder_events = None;
self.recorder = None;
return;
};
// Bump activity on the marker so the case rises in the list.
let store = self.case_store.clone();
let now = time::OffsetDateTime::now_utc();
self.runtime.block_on(async move {
@@ -357,24 +365,19 @@ impl DoctateApp {
self.state = AppState::Error {
message: format!("Sidecar schreiben: {e}"),
};
self.recorder_events = None;
self.recorder = None;
return;
}
let Some(uploader) = &self.uploader else {
self.state = AppState::Error {
message: "Upload-Worker nicht verfügbar".into(),
};
return;
};
if let Err(e) = uploader.submit(upload) {
self.state = AppState::Error {
message: format!("Upload-Submit: {e}"),
};
return;
// Kick the worker so it picks up the fresh file without waiting
// for the next poll-tick.
if let Some(sync) = &self.server_sync {
sync.notify();
} else {
warn!("no server_sync active — upload stays on disk until next launch");
}
self.state = AppState::Uploading { case_id };
self.recorder_events = None;
self.recorder = None;
}
@@ -394,11 +397,6 @@ impl DoctateApp {
}
UploadEvent::Succeeded { case_id, status } => {
info!(case_id = %case_id, status = ?status, "upload succeeded");
if let AppState::Uploading { case_id: current } = &self.state
&& *current == case_id
{
self.state = AppState::Idle;
}
}
UploadEvent::Failed {
case_id,
@@ -406,10 +404,8 @@ impl DoctateApp {
will_retry,
} => {
warn!(case_id = %case_id, reason = %reason, will_retry, "upload failed");
if !will_retry
&& let AppState::Uploading { case_id: current } = &self.state
&& *current == case_id
{
if !will_retry {
// Terminal = operator action needed. Block UI.
self.state = AppState::Error { message: reason };
}
}
@@ -456,16 +452,6 @@ impl DoctateApp {
}
None
}
AppState::FinalizingRecording { .. } => {
ui.spinner();
ui.label("Speichere Aufnahme…");
None
}
AppState::Uploading { .. } => {
ui.spinner();
ui.label("Upload läuft…");
None
}
AppState::Error { message } => {
ui.colored_label(egui::Color32::RED, message);
ui.add_space(8.0);
@@ -498,7 +484,9 @@ impl DoctateApp {
}
let mut action = None;
let idle = matches!(self.state, AppState::Idle);
// Ready-for-new means the user-intent state is Idle AND no ffmpeg
// instance is still flushing. Upload-queue depth does NOT gate.
let idle = matches!(self.state, AppState::Idle) && self.recorder.is_none();
egui::ScrollArea::vertical().show(ui, |ui| {
for marker in visible {
@@ -530,30 +518,60 @@ impl DoctateApp {
action
}
fn render_staleness(&self, ui: &mut egui::Ui) {
let Some(poller) = &self.poller else {
fn render_footer_status(&self, ui: &mut egui::Ui) {
let Some(worker_rx) = &self.worker_rx else {
ui.colored_label(egui::Color32::GRAY, "");
return;
};
let Some(last) = poller.last_success() else {
ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung");
return;
};
let age = last.elapsed();
let secs = age.as_secs();
let (color, text) = if secs < 30 {
(egui::Color32::DARK_GREEN, format!("🟢 zuletzt: vor {secs} s"))
} else if secs < 120 {
(
egui::Color32::from_rgb(200, 140, 0),
format!("🟡 zuletzt: vor {secs} s"),
)
} else {
(
egui::Color32::DARK_RED,
format!("🔴 zuletzt: vor {} s", secs),
)
};
ui.colored_label(color, text);
let snap = worker_rx.borrow().clone();
let last_age = snap.last_success.map(|i| i.elapsed());
let threshold = self
.config
.as_ref()
.map(|c| std::cmp::max(3 * c.poll_interval(), Duration::from_secs(30)))
.unwrap_or(Duration::from_secs(30));
let status = pick_footer_status(
self.finalizing.is_some(),
snap.state,
snap.queue_len,
last_age,
threshold,
);
match status {
FooterStatus::Finalizing => {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Speichere Aufnahme…");
});
}
FooterStatus::Uploading { count } => {
ui.horizontal(|ui| {
ui.spinner();
if count <= 1 {
ui.label("Uploading…");
} else {
ui.label(format!("Uploading ({count} offen)…"));
}
});
}
FooterStatus::Synchronizing => {
ui.horizontal(|ui| {
ui.spinner();
ui.label("Synchronisiere…");
});
}
FooterStatus::NeverConnected => {
ui.colored_label(egui::Color32::GRAY, "⚪ noch keine Verbindung");
}
FooterStatus::Offline => {
ui.colored_label(egui::Color32::DARK_RED, "🔴 Offline");
}
FooterStatus::Online => {
ui.colored_label(egui::Color32::DARK_GREEN, "🟢 Online");
}
}
}
fn handle_action(&mut self, action: UiAction) {
@@ -566,32 +584,41 @@ impl DoctateApp {
UiAction::DismissError => self.state = AppState::Idle,
}
}
fn worker_is_active(&self) -> bool {
let Some(rx) = &self.worker_rx else {
return false;
};
let snap = rx.borrow();
snap.state != WorkerState::Idle || snap.queue_len > 0
}
}
fn spawn_background(
fn spawn_worker(
runtime: &Runtime,
config: Config,
pending_dir: PathBuf,
http: Arc<reqwest::Client>,
case_store: Arc<CaseStore>,
snapshot_cache: Arc<SnapshotCache>,
) -> (Uploader, mpsc::UnboundedReceiver<UploadEvent>, OnelinerPoller) {
) -> (
ServerSync,
mpsc::UnboundedReceiver<UploadEvent>,
watch::Receiver<WorkerSnapshot>,
) {
let _guard = runtime.enter();
let poll_interval = config.poll_interval();
let window_hours = config.window_hours();
let (uploader, rx) = Uploader::spawn(config.clone(), pending_dir);
let poller = OnelinerPoller::spawn(
http,
PollerConfig {
server_url: config.server_url,
api_key: config.api_key,
poll_interval,
window_hours,
},
case_store,
snapshot_cache,
);
(uploader, rx, poller)
let sync_config = SyncConfig {
server_url: config.server_url,
api_key: config.api_key,
poll_interval,
window_hours,
};
let (sync, events_rx) =
ServerSync::spawn(http, sync_config, case_store, snapshot_cache, pending_dir);
let snapshot_rx = sync.snapshot();
(sync, events_rx, snapshot_rx)
}
fn parse_rfc3339(s: &str) -> Option<time::OffsetDateTime> {
@@ -605,10 +632,7 @@ fn extract_hhmm(ts: &str) -> String {
match parse_rfc3339(ts) {
Some(t) => {
let local = t
.to_offset(
time::UtcOffset::current_local_offset()
.unwrap_or(time::UtcOffset::UTC),
);
.to_offset(time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC));
format!("{:02}:{:02}", local.hour(), local.minute())
}
None => ts.chars().take(16).collect(),
@@ -634,7 +658,7 @@ impl eframe::App for DoctateApp {
egui::TopBottomPanel::bottom("footer").show(ctx, |ui| {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
self.render_staleness(ui);
self.render_footer_status(ui);
});
ui.add_space(4.0);
});
@@ -647,7 +671,6 @@ impl eframe::App for DoctateApp {
}
ui.add_space(8.0);
});
// Case list fills the remaining space.
if !matches!(self.state, AppState::NotConfigured)
&& let Some(a) = self.render_case_list(ui)
{
@@ -659,14 +682,14 @@ impl eframe::App for DoctateApp {
self.handle_action(action);
}
// Repaint periodically so the staleness indicator + watch
// snapshots reach the UI without a user event.
match self.state {
AppState::Recording { .. } => ctx.request_repaint_after(Duration::from_millis(250)),
AppState::FinalizingRecording { .. } | AppState::Uploading { .. } => {
ctx.request_repaint_after(Duration::from_millis(100));
}
_ => ctx.request_repaint_after(Duration::from_secs(1)),
}
// Repaint cadence: fast while visibly active (spinner, REC-timer),
// slow (1 Hz) in the quiet steady state.
let active_spinner = self.finalizing.is_some() || self.worker_is_active();
let interval = match self.state {
AppState::Recording { .. } => Duration::from_millis(250),
_ if active_spinner => Duration::from_millis(100),
_ => Duration::from_secs(1),
};
ctx.request_repaint_after(interval);
}
}