use super::audio::{m4a_duration_secs, ogg_duration_secs};
use crate::config::Config;
use crate::error::TalkError;
use crate::recording_cache;
use std::path::{Path, PathBuf};
const AUDIO_EXTENSIONS: &[&str] = &["ogg", "m4a", "mp4", "aac"];
fn has_audio_extension(path: &Path) -> bool {
let ext = match path.extension().and_then(|e| e.to_str()) {
Some(e) => e.to_ascii_lowercase(),
None => return false,
};
AUDIO_EXTENSIONS.iter().any(|&candidate| candidate == ext)
}
fn audio_duration_secs(path: &Path) -> Option<f64> {
let ext = path
.extension()
.and_then(|e| e.to_str())?
.to_ascii_lowercase();
match ext.as_str() {
"ogg" | "opus" => ogg_duration_secs(path),
"m4a" | "mp4" | "aac" => m4a_duration_secs(path),
_ => None,
}
}
pub(super) struct RecordingEntry {
pub(super) path: PathBuf,
pub(super) date_label: String,
pub(super) duration_label: String,
pub(super) size_label: String,
pub(super) transcript_full: String,
pub(super) transcript_preview: String,
pub(super) status: crate::recording_cache::TranscriptStatus,
}
const TRANSCRIPT_PREVIEW_CHARS: usize = 200;
fn transcript_variants(raw: &str) -> (String, String) {
let full = raw.replace('\n', " ");
let preview = if full.chars().count() > TRANSCRIPT_PREVIEW_CHARS {
let truncated: String = full.chars().take(TRANSCRIPT_PREVIEW_CHARS).collect();
format!("{truncated}…")
} else {
full.clone()
};
(full, preview)
}
fn date_label_from_stem(stem: &str) -> String {
if stem.len() >= 19 {
let date_part = &stem[..10];
let time_part = stem[11..19].replace('-', ":");
format!("{} {}", date_part, time_part)
} else {
stem.to_string()
}
}
pub(super) fn format_duration(secs: f64) -> String {
let total = secs.round() as u64;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 {
format!("{}:{:02}:{:02}", h, m, s)
} else {
format!("{}:{:02}", m, s)
}
}
pub(super) fn format_size(bytes: u64) -> String {
if bytes >= 1_000_000 {
format!("{:.1} MB", bytes as f64 / 1_000_000.0)
} else if bytes >= 1_000 {
format!("{} KB", bytes / 1_000)
} else {
format!("{} B", bytes)
}
}
fn collect_audio_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), TalkError> {
let entries = std::fs::read_dir(dir).map_err(|e| {
TalkError::Config(format!(
"failed to read recordings directory {}: {}",
dir.display(),
e
))
})?;
for entry in entries {
let entry = entry
.map_err(|e| TalkError::Config(format!("failed to read directory entry: {}", e)))?;
let path = entry.path();
if path.is_symlink() {
continue;
}
if path.is_dir() {
if let Err(err) = collect_audio_recursive(&path, out) {
log::warn!("list_audio: skipping subtree {}: {}", path.display(), err);
}
} else if has_audio_extension(&path) {
out.push(path);
}
}
Ok(())
}
pub(super) fn list_ogg_recordings() -> Result<Vec<RecordingEntry>, TalkError> {
let t = std::time::Instant::now();
let config = Config::load(None)?;
log::debug!("list_audio: config load {:.0?}", t.elapsed());
let dir = config.output_dir.clone();
if !dir.exists() {
return Ok(Vec::new());
}
let mut audio: Vec<PathBuf> = Vec::new();
collect_audio_recursive(&dir, &mut audio)?;
audio.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
audio.reverse();
let mut result = Vec::with_capacity(audio.len());
for audio_path in audio {
let stem = audio_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let date_label = date_label_from_stem(stem);
let duration_label = audio_duration_secs(&audio_path)
.map(format_duration)
.unwrap_or_else(|| "?:??".to_string());
let size_label = std::fs::metadata(&audio_path)
.map(|m| format_size(m.len()))
.unwrap_or_else(|_| "?".to_string());
let status = match crate::recording_cache::get_transcript(&audio_path) {
status @ crate::recording_cache::TranscriptStatus::InProgress => status,
_ => match crate::transcription::read_cached_transcript(&audio_path, &config) {
Some(text) => crate::recording_cache::TranscriptStatus::Available(text),
None => crate::recording_cache::TranscriptStatus::NotAvailable,
},
};
let (transcript_full, transcript_preview) = match &status {
crate::recording_cache::TranscriptStatus::Available(text) => transcript_variants(text),
_ => (String::new(), String::new()),
};
result.push(RecordingEntry {
path: audio_path,
date_label,
duration_label,
size_label,
transcript_full,
transcript_preview,
status,
});
}
log::debug!(
"list_audio: {} entries, total {:.0?}",
result.len(),
t.elapsed(),
);
Ok(result)
}
pub(super) fn list_cache_recordings() -> Result<Vec<RecordingEntry>, TalkError> {
let t = std::time::Instant::now();
let config = Config::load(None)?;
let dir = recording_cache::recordings_dir()?;
if !dir.exists() {
return Ok(Vec::new());
}
let entries = std::fs::read_dir(&dir).map_err(|e| {
TalkError::Config(format!(
"failed to read recordings directory {}: {}",
dir.display(),
e
))
})?;
let mut audio: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = entry
.map_err(|e| TalkError::Config(format!("failed to read directory entry: {}", e)))?;
let path = entry.path();
if path.is_symlink() {
continue;
}
if has_audio_extension(&path) {
audio.push(path);
}
}
audio.sort();
audio.reverse();
let mut result = Vec::with_capacity(audio.len());
for audio_path in audio {
let stem = audio_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let date_label = date_label_from_stem(stem);
let duration_label = audio_duration_secs(&audio_path)
.map(format_duration)
.unwrap_or_else(|| "?:??".to_string());
let size_label = std::fs::metadata(&audio_path)
.map(|m| format_size(m.len()))
.unwrap_or_else(|_| "?".to_string());
let status = match recording_cache::get_transcript(&audio_path) {
status @ recording_cache::TranscriptStatus::InProgress => status,
_ => match crate::transcription::read_cached_transcript(&audio_path, &config) {
Some(text) => recording_cache::TranscriptStatus::Available(text),
None => recording_cache::TranscriptStatus::NotAvailable,
},
};
let (transcript_full, transcript_preview) = match &status {
recording_cache::TranscriptStatus::Available(text) => transcript_variants(text),
_ => (String::new(), String::new()),
};
result.push(RecordingEntry {
path: audio_path,
date_label,
duration_label,
size_label,
transcript_full,
transcript_preview,
status,
});
}
log::debug!(
"list_cache: {} entries, total {:.0?}",
result.len(),
t.elapsed(),
);
Ok(result)
}
pub(super) fn delete_recording(file_path: &std::path::Path) -> Result<(), TalkError> {
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
let stem = file_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if let Err(e) = std::fs::remove_file(file_path) {
log::warn!("failed to remove {}: {}", file_path.display(), e);
}
let is_known_audio = ext == "wav" || AUDIO_EXTENSIONS.contains(&ext);
if is_known_audio && !stem.is_empty() {
if let Ok(dir) = recording_cache::recordings_dir() {
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with(stem)
&& path.extension().and_then(|e| e.to_str()) == Some("yml")
{
if let Err(e) = std::fs::remove_file(&path) {
log::warn!("failed to remove metadata {}: {}", path.display(), e);
}
}
}
}
let wf_path = dir.join(format!("{}.wf", stem));
if wf_path.exists() {
if let Err(e) = std::fs::remove_file(&wf_path) {
log::warn!(
"failed to remove waterfall cache {}: {}",
wf_path.display(),
e
);
}
}
}
}
Ok(())
}
pub(super) fn open_in_file_manager(file_path: &std::path::Path, parent_window: >k4::Window) {
let gio_file = gtk4::gio::File::for_path(file_path);
let launcher = gtk4::FileLauncher::new(Some(&gio_file));
launcher.open_containing_folder(
Some(parent_window),
gtk4::gio::Cancellable::NONE,
|result| {
if let Err(e) = result {
log::warn!("failed to open file manager: {}", e);
}
},
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transcript_variants_empty_stays_empty() {
let (full, preview) = transcript_variants("");
assert_eq!(full, "");
assert_eq!(preview, "");
}
#[test]
fn transcript_variants_short_text_is_not_truncated() {
let raw = "Hello, world!";
let (full, preview) = transcript_variants(raw);
assert_eq!(full, "Hello, world!");
assert_eq!(preview, "Hello, world!");
assert!(!preview.contains('…'));
}
#[test]
fn transcript_variants_collapses_newlines_to_spaces() {
let raw = "first line\nsecond line\nthird";
let (full, preview) = transcript_variants(raw);
assert_eq!(full, "first line second line third");
assert_eq!(preview, "first line second line third");
}
#[test]
fn transcript_variants_boundary_exactly_preview_chars() {
let raw: String = "a".repeat(TRANSCRIPT_PREVIEW_CHARS);
let (full, preview) = transcript_variants(&raw);
assert_eq!(full.chars().count(), TRANSCRIPT_PREVIEW_CHARS);
assert_eq!(preview.chars().count(), TRANSCRIPT_PREVIEW_CHARS);
assert_eq!(full, raw);
assert_eq!(preview, raw);
assert!(!preview.contains('…'));
}
#[test]
fn transcript_variants_long_text_is_truncated_with_ellipsis() {
let raw: String = "b".repeat(TRANSCRIPT_PREVIEW_CHARS + 1);
let (full, preview) = transcript_variants(&raw);
assert_eq!(full.chars().count(), TRANSCRIPT_PREVIEW_CHARS + 1);
assert_eq!(full, raw);
assert_eq!(preview.chars().count(), TRANSCRIPT_PREVIEW_CHARS + 1);
assert!(preview.ends_with('…'));
let without_ellipsis: String = preview.chars().take(TRANSCRIPT_PREVIEW_CHARS).collect();
assert_eq!(without_ellipsis, "b".repeat(TRANSCRIPT_PREVIEW_CHARS));
}
#[test]
fn transcript_variants_very_long_text_preserves_full() {
let raw: String = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let (full, preview) = transcript_variants(&raw);
assert_eq!(full, raw);
assert!(full.chars().count() > TRANSCRIPT_PREVIEW_CHARS);
assert!(preview.ends_with('…'));
let expected_prefix: String = full.chars().take(TRANSCRIPT_PREVIEW_CHARS).collect();
assert!(preview.starts_with(&expected_prefix));
}
#[test]
fn transcript_variants_multibyte_chars_counted_correctly() {
let raw: String = "漢".repeat(TRANSCRIPT_PREVIEW_CHARS + 1);
let (full, preview) = transcript_variants(&raw);
assert_eq!(full.chars().count(), TRANSCRIPT_PREVIEW_CHARS + 1);
assert_eq!(preview.chars().count(), TRANSCRIPT_PREVIEW_CHARS + 1);
assert!(preview.ends_with('…'));
let without_ellipsis: String = preview.chars().take(TRANSCRIPT_PREVIEW_CHARS).collect();
assert_eq!(without_ellipsis, "漢".repeat(TRANSCRIPT_PREVIEW_CHARS));
}
#[test]
fn transcript_variants_long_text_with_newlines() {
let long_line = "word ".repeat(60); let raw = format!("{long_line}\n{long_line}");
let (full, preview) = transcript_variants(&raw);
assert!(!full.contains('\n'));
assert!(!preview.contains('\n'));
assert!(full.chars().count() > TRANSCRIPT_PREVIEW_CHARS);
assert!(preview.ends_with('…'));
assert_eq!(preview.chars().count(), TRANSCRIPT_PREVIEW_CHARS + 1);
}
use tempfile::TempDir;
fn basename(p: &Path) -> &str {
p.file_name().and_then(|n| n.to_str()).unwrap_or("")
}
#[test]
fn collect_audio_flat_layout() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path();
std::fs::write(dir.join("a.ogg"), b"").unwrap();
std::fs::write(dir.join("b.ogg"), b"").unwrap();
std::fs::write(dir.join("ignored.txt"), b"").unwrap();
std::fs::write(dir.join("also-ignored.wf"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(dir, &mut out).expect("collect");
let mut names: Vec<_> = out.iter().map(|p| basename(p).to_string()).collect();
names.sort();
assert_eq!(names, vec!["a.ogg", "b.ogg"]);
}
#[test]
fn collect_audio_nested_layout() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path();
let nested_2026_04 = dir.join("2026").join("04");
let nested_2025_12 = dir.join("2025").join("12");
std::fs::create_dir_all(&nested_2026_04).unwrap();
std::fs::create_dir_all(&nested_2025_12).unwrap();
std::fs::write(nested_2026_04.join("2026-04-10T08-23-15+0200.ogg"), b"").unwrap();
std::fs::write(nested_2025_12.join("2025-12-27T04-31-23+0100.ogg"), b"").unwrap();
std::fs::write(
nested_2026_04.join("2026-04-10T08-23-15+0200-voxtral.json"),
b"",
)
.unwrap();
std::fs::write(nested_2026_04.join("2026-04-10T08-23-15+0200.txt"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(dir, &mut out).expect("collect");
assert_eq!(out.len(), 2, "should find exactly 2 nested .ogg files");
let mut names: Vec<_> = out.iter().map(|p| basename(p).to_string()).collect();
names.sort();
assert_eq!(
names,
vec![
"2025-12-27T04-31-23+0100.ogg".to_string(),
"2026-04-10T08-23-15+0200.ogg".to_string(),
]
);
}
#[test]
fn collect_audio_mixed_flat_and_nested() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path();
std::fs::write(dir.join("2026-04-05T10-00-00+0200.ogg"), b"").unwrap();
let nested = dir.join("2026").join("04");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("2026-04-10T10-00-00+0200.ogg"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(dir, &mut out).expect("collect");
assert_eq!(out.len(), 2, "should find both flat and nested files");
out.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
let names: Vec<_> = out.iter().map(|p| basename(p).to_string()).collect();
assert_eq!(
names,
vec![
"2026-04-05T10-00-00+0200.ogg".to_string(),
"2026-04-10T10-00-00+0200.ogg".to_string(),
],
"file-name-based sort must place the Apr-5 flat file before the Apr-10 nested file"
);
}
#[test]
fn collect_audio_skips_symlinks() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path();
std::fs::write(dir.join("real.ogg"), b"").unwrap();
std::os::unix::fs::symlink(dir.join("real.ogg"), dir.join("link.ogg")).unwrap();
std::os::unix::fs::symlink(dir, dir.join("self-link")).unwrap();
let mut out = Vec::new();
collect_audio_recursive(dir, &mut out).expect("collect");
assert_eq!(out.len(), 1, "symlinks must be skipped");
assert_eq!(basename(&out[0]), "real.ogg");
}
#[test]
fn collect_audio_empty_directory() {
let tmp = TempDir::new().expect("tempdir");
let mut out = Vec::new();
collect_audio_recursive(tmp.path(), &mut out).expect("collect");
assert!(out.is_empty());
}
#[test]
fn collect_audio_deep_nesting() {
let tmp = TempDir::new().expect("tempdir");
let deep = tmp.path().join("2026").join("04").join("10");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("memo.ogg"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(tmp.path(), &mut out).expect("collect");
assert_eq!(out.len(), 1);
assert_eq!(basename(&out[0]), "memo.ogg");
}
#[test]
fn collect_audio_picks_up_m4a_files() {
let tmp = TempDir::new().expect("tempdir");
let dir = tmp.path();
std::fs::write(dir.join("memo.m4a"), b"").unwrap();
std::fs::write(dir.join("clip.MP4"), b"").unwrap(); std::fs::write(dir.join("stream.aac"), b"").unwrap();
std::fs::write(dir.join("ignored.txt"), b"").unwrap();
std::fs::write(dir.join("also-ignored.wav"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(dir, &mut out).expect("collect");
let mut names: Vec<_> = out.iter().map(|p| basename(p).to_string()).collect();
names.sort();
assert_eq!(names, vec!["clip.MP4", "memo.m4a", "stream.aac"]);
}
#[test]
fn collect_audio_mixed_ogg_and_m4a() {
let tmp = TempDir::new().expect("tempdir");
let nested = tmp.path().join("2026").join("04");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join("2026-04-10T08-23-15+0200.ogg"), b"").unwrap();
std::fs::write(nested.join("imported-memo.m4a"), b"").unwrap();
let mut out = Vec::new();
collect_audio_recursive(tmp.path(), &mut out).expect("collect");
let mut names: Vec<_> = out.iter().map(|p| basename(p).to_string()).collect();
names.sort();
assert_eq!(
names,
vec![
"2026-04-10T08-23-15+0200.ogg".to_string(),
"imported-memo.m4a".to_string(),
]
);
}
#[test]
fn has_audio_extension_is_case_insensitive() {
assert!(has_audio_extension(Path::new("foo.M4A")));
assert!(has_audio_extension(Path::new("foo.m4a")));
assert!(has_audio_extension(Path::new("foo.OGG")));
assert!(has_audio_extension(Path::new("foo.mp4")));
assert!(has_audio_extension(Path::new("foo.aac")));
assert!(!has_audio_extension(Path::new("foo.wav"))); assert!(!has_audio_extension(Path::new("foo.txt")));
assert!(!has_audio_extension(Path::new("foo")));
}
#[test]
fn audio_duration_secs_dispatches_correctly() {
let tmp = TempDir::new().expect("tempdir");
let unknown = tmp.path().join("clip.flac");
std::fs::write(&unknown, b"x").unwrap();
assert_eq!(audio_duration_secs(&unknown), None);
let bare = tmp.path().join("noext");
std::fs::write(&bare, b"x").unwrap();
assert_eq!(audio_duration_secs(&bare), None);
}
}