Commit ac98c30

mo khan <mo@mokhan.ca>
2025-07-03 00:25:54
feat: add offline episode caching to ~/Music directory
- Episodes now cache to ~/Music/{feed_name}/ like the old Ruby script - First playback downloads and caches episode locally - Subsequent plays use cached file for instant playback - Remove debug output that interfered with TUI - Fix compiler warnings This matches the behavior of the original podcast script, providing fast offline playback for previously listened episodes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent e29234a
Changed files (2)
src/app.rs
@@ -114,7 +114,11 @@ 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) {
-                self.player.play(&episode.enclosure_url)?;
+                // Play with caching - episodes get saved to ~/Music/{feed_name}/
+                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/player.rs
@@ -1,6 +1,8 @@
 use anyhow::{Context, Result};
 use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};
+use std::fs;
 use std::io::Cursor;
+use std::path::PathBuf;
 use std::sync::{Arc, Mutex};
 use std::time::Instant;
 
@@ -28,30 +30,34 @@ impl Player {
         })
     }
 
-    pub fn play(&mut self, url: &str) -> Result<()> {
+    pub fn _play(&mut self, url: &str) -> Result<()> {
+        self.play_with_cache(url, None)
+    }
+
+    pub fn play_with_cache(&mut self, url: &str, cache_info: Option<(&str, &str)>) -> Result<()> {
         // Stop any currently playing audio
         self.stop();
 
+        let audio_data = 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) {
+                cached_data
+            } else {
+                // Download and cache
+                self.download_and_cache(url, feed_name, episode_title)?
+            }
+        } else {
+            // Stream directly without caching
+            self.download_audio(url)?
+        };
 
-
-        // Download the audio file
-        let response = reqwest::blocking::get(url)
-            .with_context(|| format!("Failed to fetch audio from URL: {}", url))?;
-
-        let audio_data = response.bytes()
-            .with_context(|| "Failed to read audio data")?;
-
-
-
-        // Create a cursor from the downloaded data
+        // Create a cursor from the audio data
         let cursor = Cursor::new(audio_data);
 
         // Decode the audio
         let source = Decoder::new(cursor)
             .with_context(|| "Failed to decode audio file. Unsupported format?")?;
 
-
-
         // Create a new sink
         let sink = Sink::try_new(&self.stream_handle)
             .with_context(|| "Failed to create audio sink")?;
@@ -67,10 +73,63 @@ impl Player {
         self.start_time = Some(Instant::now());
         self.is_paused = false;
 
-
         Ok(())
     }
 
+    fn download_audio(&self, url: &str) -> Result<Vec<u8>> {
+        let response = reqwest::blocking::get(url)
+            .with_context(|| format!("Failed to fetch audio from URL: {}", url))?;
+
+        let audio_data = response.bytes()
+            .with_context(|| "Failed to read audio data")?;
+
+        Ok(audio_data.to_vec())
+    }
+
+    fn cache_path(&self, feed_name: &str, _episode_title: &str, url: &str) -> PathBuf {
+        let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
+        let music_dir = home.join("Music").join(feed_name);
+        
+        // Create filename from URL, handling query parameters
+        let filename = url.split('/').last().unwrap_or("episode.mp3");
+        let filename = filename.split('?').next().unwrap_or(filename);
+        
+        // Sanitize filename
+        let safe_filename = filename.chars()
+            .map(|c| if c.is_alphanumeric() || c == '.' || c == '-' { c } else { '_' })
+            .collect::<String>();
+        
+        music_dir.join(safe_filename)
+    }
+
+    fn load_from_cache(&self, feed_name: &str, episode_title: &str, url: &str) -> Result<Vec<u8>> {
+        let cache_path = self.cache_path(feed_name, episode_title, url);
+        
+        if cache_path.exists() && cache_path.metadata()?.len() > 0 {
+            fs::read(&cache_path)
+                .with_context(|| format!("Failed to read cached file: {}", cache_path.display()))
+        } else {
+            Err(anyhow::anyhow!("No valid cache file found"))
+        }
+    }
+
+    fn download_and_cache(&self, url: &str, feed_name: &str, episode_title: &str) -> Result<Vec<u8>> {
+        let audio_data = self.download_audio(url)?;
+        let cache_path = self.cache_path(feed_name, episode_title, url);
+        
+        // Create directory if it doesn't exist
+        if let Some(parent) = cache_path.parent() {
+            fs::create_dir_all(parent)
+                .with_context(|| format!("Failed to create cache directory: {}", parent.display()))?;
+        }
+        
+        // Write to cache file
+        fs::write(&cache_path, &audio_data)
+            .with_context(|| format!("Failed to write cache file: {}", cache_path.display()))?;
+        
+        Ok(audio_data)
+    }
+
     pub fn stop(&mut self) {
         if let Some(sink) = &self.sink {
             if let Ok(sink) = sink.lock() {
@@ -111,7 +170,6 @@ impl Player {
         // Note: rodio doesn't support seeking easily
         // This is a limitation, but for now we'll just return Ok
         // In the future, we could implement this with symphonia directly
-
         Ok(())
     }