diff --git a/bookstorm.py b/bookstorm.py index 6b89219..9b45f39 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -2289,8 +2289,9 @@ class BookReader: wavData = self.ttsEngine.text_to_wav_data(paragraph) if wavData: - # Stop any existing audio playback - if self.audioPlayer.is_audio_file_playing(): + # Stop any existing audio file (playing, paused, or idle) + # This ensures temp files are cleaned up immediately + if self.audioPlayer.is_audio_file_loaded(): self.audioPlayer.stop_audio_file() # Get current playback speed from config diff --git a/src/mpv_player.py b/src/mpv_player.py index 73ff262..1a5f656 100644 --- a/src/mpv_player.py +++ b/src/mpv_player.py @@ -10,6 +10,7 @@ Supports real-time speed control without re-encoding. import os from pathlib import Path import threading +from concurrent.futures import ThreadPoolExecutor try: import mpv @@ -34,6 +35,8 @@ class MpvPlayer: self.currentPlaylistIndex = 0 # Current file index in playlist self.activeTempFiles = [] # Track temp files for cleanup self.tempFileLock = threading.Lock() # Protect temp file list + # Thread pool for cleanup tasks (prevents daemon thread accumulation) + self.cleanupExecutor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="mpv-cleanup") if not HAS_MPV: print("Warning: python-mpv not installed. Audio playback will not work.") @@ -49,6 +52,10 @@ class MpvPlayer: video=False, # Audio only ytdl=False, # Don't use youtube-dl quiet=True, # Minimal console output + demuxer_max_bytes='20M', # Reduced from 50M - limit demuxer cache + demuxer_max_back_bytes='5M', # Reduced from 10M - limit backward cache + cache=False, # Disable additional caching for local files + audio_buffer=0.5, # Reduce audio buffer to 0.5 seconds (default is 2s) ) # Register event handler for cleanup @@ -102,11 +109,11 @@ class MpvPlayer: success = self.play_audio_file() # Schedule cleanup after mpv loads the file - # Use shorter delay and clean up old files too + # Use ThreadPoolExecutor instead of daemon threads to prevent accumulation if tempFile: import time def cleanup_temp_file(filepath): - time.sleep(2) # Reduced from 5s - mpv loads files quickly + time.sleep(2) # mpv loads files quickly try: os.unlink(filepath) except: @@ -117,7 +124,8 @@ class MpvPlayer: self.activeTempFiles.remove(filepath) except ValueError: pass # Already removed - threading.Thread(target=cleanup_temp_file, args=(tempFile.name,), daemon=True).start() + # Use thread pool instead of creating new daemon threads + self.cleanupExecutor.submit(cleanup_temp_file, tempFile.name) return success @@ -263,6 +271,7 @@ class MpvPlayer: def _cleanup_temp_files(self): """Immediately clean up all active temp files""" + import gc with self.tempFileLock: for tempPath in self.activeTempFiles: try: @@ -273,6 +282,41 @@ class MpvPlayer: pass # Clear the list self.activeTempFiles = [] + # CRITICAL: Force FULL garbage collection to free memory from deleted files + # After 30+ minutes of reading, temp file handles accumulate in older generations + gc.collect() # Full collection across all generations + + def _purge_mpv_buffers(self): + """ + Aggressively purge mpv internal buffers and caches + Call this between paragraphs to prevent memory accumulation + """ + if not self.isInitialized or not self.player: + return + + try: + # Stop any playback first to release audio buffers + self.player.stop() + + # Wait a moment for mpv to fully stop and release buffers + import time + time.sleep(0.05) + + # Force mpv to clear its internal demuxer cache + # This releases memory held by the demuxer + try: + # Setting these properties forces mpv to flush caches + # pylint: disable=no-member + self.player.command('seek', 0, 'absolute') # Dummy seek to flush + except: + pass # Might fail if nothing is loaded, that's ok + + # Mark as unloaded so next load is clean + self.audioFileLoaded = False + + except Exception as e: + # Don't fail if buffer purge fails + pass def cleanup(self): """Cleanup resources""" @@ -288,6 +332,11 @@ class MpvPlayer: self.isInitialized = False # Clean up any remaining temp files self._cleanup_temp_files() + # Shutdown cleanup thread pool (wait for pending tasks to finish) + try: + self.cleanupExecutor.shutdown(wait=True, cancel_futures=False) + except: + pass def is_available(self): """Check if mpv is available""" @@ -314,6 +363,11 @@ class MpvPlayer: audioPath = str(audioPath) self.playbackSpeed = max(0.5, min(2.0, float(playbackSpeed))) + # Aggressively purge mpv buffers before loading new file + # This prevents memory accumulation over long reading sessions + if self.audioFileLoaded: + self._purge_mpv_buffers() + # Check if this is a URL (for streaming from Audiobookshelf) isUrl = audioPath.startswith('http://') or audioPath.startswith('https://') @@ -365,6 +419,10 @@ class MpvPlayer: self.currentPlaylistIndex = 0 self.playbackSpeed = max(0.5, min(2.0, float(playbackSpeed))) + # Aggressively purge mpv buffers before loading new playlist + if self.audioFileLoaded: + self._purge_mpv_buffers() + # Load first file in playlist firstFile = self.playlist[0]