Commit 5a0a1c7

mo khan <mo@mokhan.ca>
2025-07-03 00:37:48
feat: integrate cached episodes with RSS feeds
- Episode list now shows both RSS and previously downloaded episodes - Cache indicators: ๐Ÿ’พ = cached (instant playback), ๐ŸŒ = streaming - Cached episodes play instantly from local files - RSS episodes that exist locally are marked as cached - Cache-only episodes appear for files without RSS data - Smart merging prevents duplicates - Prioritizes local files for instant playback experience Now you can see and play all your previously downloaded episodes alongside new ones from the RSS feed, just like the old Ruby script. ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c442980
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