feat: implement Phase 12 — voice message playback with ffplay
Add voice message playback infrastructure: - AudioPlayer using ffplay subprocess with SIGSTOP/SIGCONT for pause/resume - VoiceCache with LRU eviction (100 MB limit) - TDLib integration: VoiceInfo, VoiceDownloadState, PlaybackState types - download_voice_note() in TdClientTrait - Keybindings: Space (play/pause), ←/→ (seek ±5s) - Auto-stop playback on message navigation - Remove debug_log module
This commit is contained in:
158
src/audio/cache.rs
Normal file
158
src/audio/cache.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! Voice message cache management.
|
||||
//!
|
||||
//! Caches downloaded OGG voice files in ~/.cache/tele-tui/voice/
|
||||
//! with LRU eviction when cache size exceeds limit.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Maximum cache size in bytes (100 MB default)
|
||||
const MAX_CACHE_SIZE_BYTES: u64 = 100 * 1024 * 1024;
|
||||
|
||||
/// Cache for voice message files
|
||||
pub struct VoiceCache {
|
||||
cache_dir: PathBuf,
|
||||
/// file_id -> (path, size_bytes, access_count)
|
||||
files: HashMap<String, (PathBuf, u64, usize)>,
|
||||
access_counter: usize,
|
||||
max_size_bytes: u64,
|
||||
}
|
||||
|
||||
impl VoiceCache {
|
||||
/// Creates a new VoiceCache
|
||||
pub fn new() -> Result<Self, String> {
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.ok_or("Failed to get cache directory")?
|
||||
.join("tele-tui")
|
||||
.join("voice");
|
||||
|
||||
fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
|
||||
|
||||
Ok(Self {
|
||||
cache_dir,
|
||||
files: HashMap::new(),
|
||||
access_counter: 0,
|
||||
max_size_bytes: MAX_CACHE_SIZE_BYTES,
|
||||
})
|
||||
}
|
||||
|
||||
/// Gets the path for a cached voice file, if it exists
|
||||
pub fn get(&mut self, file_id: &str) -> Option<PathBuf> {
|
||||
if let Some((path, _, access)) = self.files.get_mut(file_id) {
|
||||
// Update access count for LRU
|
||||
self.access_counter += 1;
|
||||
*access = self.access_counter;
|
||||
Some(path.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores a voice file in the cache
|
||||
pub fn store(&mut self, file_id: &str, source_path: &Path) -> Result<PathBuf, String> {
|
||||
// Copy file to cache
|
||||
let filename = format!("{}.ogg", file_id.replace('/', "_"));
|
||||
let dest_path = self.cache_dir.join(&filename);
|
||||
|
||||
fs::copy(source_path, &dest_path)
|
||||
.map_err(|e| format!("Failed to copy voice file to cache: {}", e))?;
|
||||
|
||||
// Get file size
|
||||
let size = fs::metadata(&dest_path)
|
||||
.map_err(|e| format!("Failed to get file size: {}", e))?
|
||||
.len();
|
||||
|
||||
// Store in cache
|
||||
self.access_counter += 1;
|
||||
self.files
|
||||
.insert(file_id.to_string(), (dest_path.clone(), size, self.access_counter));
|
||||
|
||||
// Check if we need to evict
|
||||
self.evict_if_needed()?;
|
||||
|
||||
Ok(dest_path)
|
||||
}
|
||||
|
||||
/// Returns the total size of all cached files
|
||||
pub fn total_size(&self) -> u64 {
|
||||
self.files.values().map(|(_, size, _)| size).sum()
|
||||
}
|
||||
|
||||
/// Evicts oldest files until cache is under max size
|
||||
fn evict_if_needed(&mut self) -> Result<(), String> {
|
||||
while self.total_size() > self.max_size_bytes && !self.files.is_empty() {
|
||||
// Find least recently accessed file
|
||||
let oldest_id = self
|
||||
.files
|
||||
.iter()
|
||||
.min_by_key(|(_, (_, _, access))| access)
|
||||
.map(|(id, _)| id.clone());
|
||||
|
||||
if let Some(id) = oldest_id {
|
||||
self.evict(&id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evicts a specific file from cache
|
||||
fn evict(&mut self, file_id: &str) -> Result<(), String> {
|
||||
if let Some((path, _, _)) = self.files.remove(file_id) {
|
||||
fs::remove_file(&path)
|
||||
.map_err(|e| format!("Failed to remove cached file: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clears all cached files
|
||||
pub fn clear(&mut self) -> Result<(), String> {
|
||||
for (path, _, _) in self.files.values() {
|
||||
let _ = fs::remove_file(path); // Ignore errors
|
||||
}
|
||||
self.files.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn test_voice_cache_creation() {
|
||||
let cache = VoiceCache::new();
|
||||
assert!(cache.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_get_nonexistent() {
|
||||
let mut cache = VoiceCache::new().unwrap();
|
||||
assert!(cache.get("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_store_and_get() {
|
||||
let mut cache = VoiceCache::new().unwrap();
|
||||
|
||||
// Create temporary file
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_file = temp_dir.join("test_voice.ogg");
|
||||
let mut file = fs::File::create(&temp_file).unwrap();
|
||||
file.write_all(b"test audio data").unwrap();
|
||||
|
||||
// Store in cache
|
||||
let result = cache.store("test123", &temp_file);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Get from cache
|
||||
let cached_path = cache.get("test123");
|
||||
assert!(cached_path.is_some());
|
||||
assert!(cached_path.unwrap().exists());
|
||||
|
||||
// Cleanup
|
||||
fs::remove_file(&temp_file).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user