Commit 5a0a1c7
src/app.rs
@@ -127,11 +127,18 @@ impl App {
pub fn play_episode(&mut self) -> Result<()> {
if let Some(feed) = self.feeds.get(self.selected_feed) {
if let Some(episode) = feed.episodes.get(self.selected_episode) {
- // Play with caching - episodes get saved to ~/Music/{feed_name}/
- self.player.play_with_cache(
- &episode.enclosure_url,
- Some((&feed.name, &episode.title))
- )?;
+ // Prioritize cached episodes for instant playback
+ if episode.is_cached && episode.local_path.is_some() {
+ // Play from local file instantly
+ let local_path = episode.local_path.as_ref().unwrap();
+ self.player.play_from_local_file(local_path)?;
+ } else {
+ // Download and cache if not cached
+ self.player.play_with_cache(
+ &episode.enclosure_url,
+ Some((&feed.name, &episode.title))
+ )?;
+ }
// Set current track info and switch to NowPlaying screen
self.current_track = Some(CurrentTrack {
src/feed.rs
@@ -2,6 +2,8 @@ use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use quick_xml::de::from_str;
use serde::Deserialize;
+use std::fs;
+use std::path::PathBuf;
#[derive(Debug, Clone)]
pub enum FeedState {
@@ -25,6 +27,8 @@ pub struct Episode {
pub enclosure_url: String,
pub published_at: DateTime<Utc>,
pub _duration: Option<String>,
+ pub is_cached: bool,
+ pub local_path: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -98,9 +102,115 @@ impl Feed {
// Sort episodes by date (newest first)
self.episodes.sort_by(|a, b| b.published_at.cmp(&a.published_at));
+ // Scan for cached episodes and merge them
+ self.scan_and_merge_cached_episodes();
+
Ok(())
}
+ fn scan_and_merge_cached_episodes(&mut self) {
+ // First, mark RSS episodes that are cached
+ self.mark_cached_episodes();
+
+ // Then, add cache-only episodes that aren't in RSS
+ self.add_cached_only_episodes();
+
+ // Sort again after merging
+ self.episodes.sort_by(|a, b| b.published_at.cmp(&a.published_at));
+ }
+
+ fn mark_cached_episodes(&mut self) {
+ let cache_dir = self.get_cache_directory();
+ if !cache_dir.exists() {
+ return;
+ }
+
+ // Collect cache paths first to avoid borrowing issues
+ let cache_paths: Vec<_> = self.episodes.iter()
+ .map(|ep| self.get_cache_path_for_url(&ep.enclosure_url))
+ .collect();
+
+ for (episode, cache_path) in self.episodes.iter_mut().zip(cache_paths.iter()) {
+ if cache_path.exists() && cache_path.metadata().map(|m| m.len() > 0).unwrap_or(false) {
+ episode.is_cached = true;
+ episode.local_path = Some(cache_path.to_string_lossy().to_string());
+ }
+ }
+ }
+
+ fn add_cached_only_episodes(&mut self) {
+ let cache_dir = self.get_cache_directory();
+ if !cache_dir.exists() {
+ return;
+ }
+
+ if let Ok(entries) = fs::read_dir(&cache_dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_file() && self.is_audio_file(&path) {
+ // Check if this cached file is already represented in RSS episodes
+ let filename = path.file_name().unwrap().to_string_lossy();
+ let already_exists = self.episodes.iter().any(|ep| {
+ ep.local_path.as_ref().map(|p| p.contains(&*filename)).unwrap_or(false)
+ });
+
+ if !already_exists {
+ // Create a cache-only episode
+ if let Some(cached_episode) = self.create_cached_episode(&path) {
+ self.episodes.push(cached_episode);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fn get_cache_directory(&self) -> PathBuf {
+ let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
+ home.join("Music").join(&self.name)
+ }
+
+ fn get_cache_path_for_url(&self, url: &str) -> PathBuf {
+ let cache_dir = self.get_cache_directory();
+ let filename = url.split('/').last().unwrap_or("episode.mp3");
+ let filename = filename.split('?').next().unwrap_or(filename);
+ let safe_filename = filename.chars()
+ .map(|c| if c.is_alphanumeric() || c == '.' || c == '-' { c } else { '_' })
+ .collect::<String>();
+ cache_dir.join(safe_filename)
+ }
+
+ fn is_audio_file(&self, path: &PathBuf) -> bool {
+ if let Some(ext) = path.extension() {
+ let ext = ext.to_string_lossy().to_lowercase();
+ matches!(ext.as_str(), "mp3" | "m4a" | "aac" | "ogg" | "flac" | "wav")
+ } else {
+ false
+ }
+ }
+
+ fn create_cached_episode(&self, path: &PathBuf) -> Option<Episode> {
+ let filename = path.file_stem()?.to_string_lossy();
+ let title = filename.replace('_', " ").replace('-', " ");
+
+ let published_at = path.metadata()
+ .ok()
+ .and_then(|m| m.modified().ok())
+ .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
+ .map(|d| DateTime::from_timestamp(d.as_secs() as i64, 0).unwrap_or_else(|| Utc::now()))
+ .unwrap_or_else(|| Utc::now());
+
+ Some(Episode {
+ title,
+ _description: "Cached episode (no RSS data)".to_string(),
+ enclosure_url: format!("file://{}", path.display()), // Local file URL
+ published_at,
+ _duration: None,
+ is_cached: true,
+ local_path: Some(path.to_string_lossy().to_string()),
+ })
+ }
+
fn parse_episode(&self, item: RssItem) -> Option<Episode> {
let title = item.title?;
let enclosure_url = item.enclosure?.url;
@@ -120,6 +230,8 @@ impl Feed {
enclosure_url,
published_at,
_duration: item.duration,
+ is_cached: false, // Will be updated by scan_cached_episodes
+ local_path: None,
})
}
}
\ No newline at end of file
src/main.rs
@@ -213,8 +213,11 @@ Navigation:
.episodes
.iter()
.map(|ep| {
+ let cache_indicator = if ep.is_cached { "๐พ " } else { "๐ " };
+ let title_with_cache = format!("{}{}", cache_indicator, ep.title);
+
ListItem::new(Line::from(vec![
- Span::raw(&ep.title),
+ Span::raw(title_with_cache),
Span::styled(
format!(" ({})", ep.published_at.format("%Y-%m-%d")),
Style::default().fg(Color::DarkGray),
src/player.rs
@@ -35,20 +35,32 @@ impl Player {
}
pub fn play_with_cache(&mut self, url: &str, cache_info: Option<(&str, &str)>) -> Result<()> {
+ self.play_from_source(url, cache_info, false)
+ }
+
+ pub fn play_from_local_file(&mut self, file_path: &str) -> Result<()> {
+ self.play_from_source(file_path, None, true)
+ }
+
+ fn play_from_source(&mut self, source: &str, cache_info: Option<(&str, &str)>, is_local_file: bool) -> Result<()> {
// Stop any currently playing audio
self.stop();
- let audio_data = if let Some((feed_name, episode_title)) = cache_info {
+ let audio_data = if is_local_file {
+ // Read directly from local file
+ fs::read(source)
+ .with_context(|| format!("Failed to read local audio file: {}", source))?
+ } else if let Some((feed_name, episode_title)) = cache_info {
// Try to load from cache first
- if let Ok(cached_data) = self.load_from_cache(feed_name, episode_title, url) {
+ if let Ok(cached_data) = self.load_from_cache(feed_name, episode_title, source) {
cached_data
} else {
// Download and cache
- self.download_and_cache(url, feed_name, episode_title)?
+ self.download_and_cache(source, feed_name, episode_title)?
}
} else {
// Stream directly without caching
- self.download_audio(url)?
+ self.download_audio(source)?
};
// Create a cursor from the audio data