#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ BookStorm - Accessible Book Reader A book reader with text-to-speech support for DAISY, EPUB, and PDF formats. Uses piper-tts for high-quality speech synthesis. """ import sys import argparse import threading import gc import os import time from pathlib import Path import subprocess try: from setproctitle import setproctitle HAS_SETPROCTITLE = True except ImportError: HAS_SETPROCTITLE = False try: import pygame HAS_PYGAME = True # Define custom pygame event for speech-dispatcher callbacks SPEECH_FINISHED_EVENT = pygame.USEREVENT + 1 except ImportError: HAS_PYGAME = False SPEECH_FINISHED_EVENT = None from src.daisy_parser import DaisyParser from src.epub_parser import EpubParser from src.pdf_parser import PdfParser from src.txt_parser import TxtParser from src.audio_parser import AudioParser from src.folder_audiobook_parser import FolderAudiobookParser from src.bookmark_manager import BookmarkManager from src.tts_engine import TtsEngine from src.config_manager import ConfigManager from src.voice_selector import VoiceSelector from src.book_selector import BookSelector from src.mpv_player import MpvPlayer from src.speech_engine import SpeechEngine from src.options_menu import OptionsMenu from src.sleep_timer_menu import SleepTimerMenu from src.recent_books_menu import RecentBooksMenu from src.audiobookshelf_client import AudiobookshelfClient from src.audiobookshelf_menu import AudiobookshelfMenu from src.server_link_manager import ServerLinkManager from src.bookmarks_menu import BookmarksMenu from src.wav_exporter import WavExporter from src.braille_output import BrailleOutput from src.braille_menu import BrailleMenu class BookReader: """Main book reader class""" def __init__(self, bookPath, config=None): """ Initialize book reader Args: bookPath: Path to book file (or None for server streaming) config: ConfigManager instance """ self.bookPath = Path(bookPath) if bookPath else None self.book = None self.currentChapter = 0 self.currentParagraph = 0 self.config = config or ConfigManager() # Initialize components self.parser = None # Will be set based on file type self.bookmarkManager = BookmarkManager() self.speechEngine = SpeechEngine() # UI feedback self.audioPlayer = MpvPlayer() # Used for both audio books and TTS playback # Configure audio player from saved settings savedVolume = self.config.get_volume() self.audioPlayer.set_volume(savedVolume) # Configure speech engine from saved settings speechRate = self.config.get_speech_rate() self.speechEngine.set_rate(speechRate) # Initialize Braille output (before options menu) brailleEnabled = self.config.get_braille_enabled() brailleTable = self.config.get_braille_translation_table() brailleSyncTts = self.config.get_braille_sync_with_tts() brailleShowStatus = self.config.get_braille_show_status() brailleMuteVoice = self.config.get_braille_mute_voice() self.brailleOutput = BrailleOutput( enabled=brailleEnabled, translationTable=brailleTable, syncWithTts=brailleSyncTts, showStatus=brailleShowStatus, muteVoice=brailleMuteVoice ) # Initialize options menu voiceSelector = VoiceSelector(self.config.get_voice_dir()) # Create callback reference for TTS engine reloading reloadCallback = self.reload_tts_engine self.optionsMenu = OptionsMenu( self.config, self.speechEngine, voiceSelector, self.audioPlayer, ttsReloadCallback=reloadCallback, brailleOutput=self.brailleOutput ) # Initialize book selector # Use library directory if set, otherwise use last books directory libraryDir = self.config.get_library_directory() if libraryDir and Path(libraryDir).exists(): booksDir = libraryDir else: booksDir = self.config.get_books_directory() supportedFormats = ['.zip', '.epub', '.pdf', '.txt', '.m4b', '.m4a', '.mp3'] self.bookSelector = BookSelector(booksDir, supportedFormats, self.speechEngine) # Initialize sleep timer menu self.sleepTimerMenu = SleepTimerMenu(self.speechEngine) # Initialize recent books menu self.recentBooksMenu = RecentBooksMenu(self.bookmarkManager, self.speechEngine) # Initialize bookmarks menu self.bookmarksMenu = BookmarksMenu(self.bookmarkManager, self.speechEngine) # Initialize Audiobookshelf client and menu (lazy init - only create when accessed) self.absClient = None self.absMenu = None self.serverLinkManager = ServerLinkManager() self.serverBook = None # Server book metadata for streaming self.isStreaming = False # Track if currently streaming self.sessionId = None # Active listening session ID # Initialize reading engine based on config readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': # Use separate speech-dispatcher session for reading # (UI uses self.speechEngine, reading uses self.readingEngine) self.ttsEngine = None self.readingEngine = SpeechEngine() # Separate session for book reading # Apply saved speech-dispatcher settings to reading engine savedModule = self.config.get_speechd_output_module() if savedModule: self.readingEngine.set_output_module(savedModule) savedVoice = self.config.get_speechd_voice() if savedVoice: self.readingEngine.set_voice(savedVoice) # Apply speech rate to reading engine self.readingEngine.set_rate(speechRate) else: # Use piper-tts (check if available first) if not TtsEngine.is_available(): # piper-tts not found, fall back to speech-dispatcher message = "Warning: piper-tts not found. Falling back to speech-dispatcher." print(message) self.speechEngine.speak(message) # Switch to speech-dispatcher mode self.ttsEngine = None self.readingEngine = SpeechEngine() self.readingEngine.set_rate(speechRate) # Update config to remember this fallback self.config.set_reader_engine('speechd') else: # piper-tts is available self.readingEngine = None voiceModel = self.config.get_voice_model() self.ttsEngine = TtsEngine(voiceModel) # Playback state self.isRunning = False self.isPlaying = False # Sleep timer fade-out state self.isFadingOut = False self.fadeStartVolume = None self.fadeStartTime = None # Audio buffering for seamless playback self.bufferedAudio = None # Pre-generated next paragraph self.bufferThread = None self.cancelBuffer = False self.bufferLock = threading.Lock() # Audio bookmark state self.savedAudioPosition = 0.0 # Saved audio position for resume self.bookmarkCleared = False # Track if user explicitly cleared bookmark def load_book(self): """Load and parse the book""" # Check if bookPath is a directory (folder audiobook) if self.bookPath.is_dir(): self.speechEngine.speak(f"Loading audiobook folder {self.bookPath.name}") # Use folder audiobook parser self.parser = FolderAudiobookParser() self.book = self.parser.parse(self.bookPath) else: message = f"Loading book {self.bookPath.stem}" print(message) self.speechEngine.speak(message) # Detect format and create appropriate parser suffix = self.bookPath.suffix.lower() if suffix in ['.epub']: self.parser = EpubParser() self.book = self.parser.parse(self.bookPath) elif suffix in ['.zip']: # Assume DAISY format for zip files self.parser = DaisyParser() self.book = self.parser.parse(self.bookPath) elif suffix in ['.pdf']: self.parser = PdfParser() self.book = self.parser.parse(self.bookPath) elif suffix in ['.txt']: self.parser = TxtParser() self.book = self.parser.parse(self.bookPath) elif suffix in ['.m4b', '.m4a', '.mp3']: # Audio book file self.parser = AudioParser() self.book = self.parser.parse(self.bookPath) else: raise ValueError(f"Unsupported book format: {self.bookPath.suffix}") # If it's an audio book, load it into the player if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: # Get saved playback speed from config playbackSpeed = self.config.get_playback_speed() # Check if multi-file audiobook (folder) if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile: # Multi-file audiobook - load playlist into player if not self.audioPlayer.load_audio_playlist(self.book.audioFiles, playbackSpeed=playbackSpeed): raise Exception("Failed to load audio playlist") else: # Single-file audiobook if not self.audioPlayer.load_audio_file(self.book.audioPath, playbackSpeed=playbackSpeed): raise Exception("Failed to load audio file") # Restore saved volume setting savedVolume = self.config.get_volume() self.audioPlayer.set_volume(savedVolume) # Inform user about navigation capabilities if self.book.get_total_chapters() == 1: self.speechEngine.speak("Audio book loaded. No chapter markers found. Only basic playback controls available.") else: self.speechEngine.speak(f"Audio book loaded with {self.book.get_total_chapters()} chapters. Chapter navigation enabled.") # Check if this book is linked to Audiobookshelf server # If so, prioritize server progress over local bookmark serverLink = self.serverLinkManager.get_link(str(self.bookPath)) serverProgressLoaded = False if serverLink and self.absClient and self.absClient.is_authenticated(): serverId = serverLink.get('server_id') if serverId: try: serverProgress = self.absClient.get_progress(serverId) if serverProgress: progressTime = serverProgress.get('currentTime', 0.0) if progressTime > 0: # For audio books, save exact position if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: self.savedAudioPosition = progressTime # Find chapter that contains this time for i, chap in enumerate(self.book.chapters): if hasattr(chap, 'startTime'): chapterEnd = chap.startTime + chap.duration if chap.startTime <= progressTime < chapterEnd: self.currentChapter = i break else: # Text book - use chapter/paragraph from server if available # (Audiobookshelf doesn't track paragraph, so we'd need to enhance this) pass serverProgressLoaded = True except: pass # Fall back to local bookmark if no server progress if not serverProgressLoaded: bookmark = self.bookmarkManager.get_bookmark(self.bookPath) if bookmark: self.currentChapter = bookmark['chapterIndex'] self.currentParagraph = bookmark['paragraphIndex'] self.savedAudioPosition = bookmark.get('audioPosition', 0.0) else: self.currentChapter = 0 self.currentParagraph = 0 self.savedAudioPosition = 0.0 def read_current_paragraph(self): """Read the current paragraph aloud""" chapter = self.book.get_chapter(self.currentChapter) if not chapter: return False paragraph = chapter.get_paragraph(self.currentParagraph) if not paragraph: return False # Show what we're reading print(f"\n[Chapter {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title}]") print(f"[Paragraph {self.currentParagraph + 1}/{chapter.get_total_paragraphs()}]") print(f"\n{paragraph}\n") # Show on Braille display if self.brailleOutput.enabled: self.brailleOutput.show_paragraph(paragraph) # Generate and play audio (unless muted for Braille-only mode) if not self.brailleOutput.muteVoice: try: wavData = self.ttsEngine.text_to_wav_data(paragraph) if wavData: completed = self.audioPlayer.play_wav_data(wavData, blocking=True) return completed except Exception as e: print(f"Error during playback: {e}") return False return True def next_paragraph(self): """Move to next paragraph""" chapter = self.book.get_chapter(self.currentChapter) if not chapter: return False if self.currentParagraph < chapter.get_total_paragraphs() - 1: self.currentParagraph += 1 return True else: # Move to next chapter return self.next_chapter() def previous_paragraph(self): """Move to previous paragraph""" if self.currentParagraph > 0: self.currentParagraph -= 1 return True else: # Move to previous chapter if self.previous_chapter(): # Go to last paragraph of previous chapter chapter = self.book.get_chapter(self.currentChapter) if chapter: self.currentParagraph = chapter.get_total_paragraphs() - 1 return True return False def next_chapter(self): """Move to next chapter""" if self.currentChapter < self.book.get_total_chapters() - 1: self.currentChapter += 1 self.currentParagraph = 0 return True return False def previous_chapter(self): """Move to previous chapter""" if self.currentChapter > 0: self.currentChapter -= 1 self.currentParagraph = 0 return True return False def save_bookmark(self, speakFeedback=True): """Save current position as bookmark Args: speakFeedback: Whether to speak "Bookmark saved" (default True) """ # Don't save if no book is loaded if not self.book: return # Don't save if user explicitly cleared bookmark if self.bookmarkCleared: return # For multi-file audiobooks, sync currentChapter with mpv playlist position FIRST # This prevents saving bookmarks with stale chapter indices if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile: if self.audioPlayer.is_audio_file_loaded(): playlistIndex = self.audioPlayer.get_current_playlist_index() if playlistIndex != self.currentChapter: self.currentChapter = playlistIndex # For audio books, calculate current playback position audioPosition = 0.0 if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: # Get current chapter start time chapter = self.book.get_chapter(self.currentChapter) if chapter and hasattr(chapter, 'startTime'): chapterStartTime = chapter.startTime else: chapterStartTime = 0.0 # Get playback position within the audio file if self.audioPlayer.is_audio_file_playing() or self.audioPlayer.is_paused(): playbackPos = self.audioPlayer.get_audio_position() # Total position = chapter start + position within current playback audioPosition = chapterStartTime + playbackPos self.bookmarkManager.save_bookmark( self.bookPath, self.book.title, self.currentChapter, self.currentParagraph, audioPosition=audioPosition ) # Sync progress to server if streaming or server-linked self._sync_progress_to_server(audioPosition) if speakFeedback: self.speechEngine.speak("Bookmark saved") def _sync_progress_to_server(self, audioPosition=0.0): """ Sync progress to Audiobookshelf server Args: audioPosition: Current audio position in seconds """ # Only sync if we have an active ABS client if not self.absClient or not self.absClient.is_authenticated(): return # Check if this is a streaming book or server-linked book serverId = None if self.serverBook: # Streaming book serverId = self.serverBook.get('id') else: # Check if local book is linked to server serverLink = self.serverLinkManager.get_link(str(self.bookPath)) if serverLink: serverId = serverLink.get('server_id') if not serverId: return # Calculate progress duration = 0.0 if hasattr(self.book, 'totalDuration'): duration = self.book.totalDuration if duration <= 0: return currentTime = audioPosition progress = min(currentTime / duration, 1.0) if duration > 0 else 0.0 # Upload progress to server success = self.absClient.update_progress(serverId, currentTime, duration, progress) if success: print(f"Progress synced to server: {progress * 100:.1f}%") # Also sync session if active if self.sessionId: syncSuccess = self.absClient.sync_session(self.sessionId, currentTime, duration, progress) if syncSuccess: # Update session in server link to persist it if self.bookPath: self.serverLinkManager.update_session(str(self.bookPath), self.sessionId) else: # Session sync failed - might be expired, create new one print(f"Session sync failed, creating new session...") newSessionId = self.absClient.create_session(serverId) if newSessionId: self.sessionId = newSessionId if self.bookPath: self.serverLinkManager.update_session(str(self.bookPath), self.sessionId) print(f"Created new session: {self.sessionId}") def reload_tts_engine(self): """Reload TTS engine with current config settings""" readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': # Using speech-dispatcher, apply settings to reading engine self.ttsEngine = None # Recreate reading engine self.readingEngine = SpeechEngine() # Apply saved speech-dispatcher settings savedModule = self.config.get_speechd_output_module() if savedModule: self.readingEngine.set_output_module(savedModule) savedVoice = self.config.get_speechd_voice() if savedVoice: self.readingEngine.set_voice(savedVoice) # Apply speech rate speechRate = self.config.get_speech_rate() self.readingEngine.set_rate(speechRate) message = "Speech-dispatcher settings reloaded successfully" print(message) self.speechEngine.speak(message) else: # Reload piper-tts with new voice (check if available first) if not TtsEngine.is_available(): # piper-tts not found, fall back to speech-dispatcher message = "Warning: piper-tts not found. Falling back to speech-dispatcher." print(message) self.speechEngine.speak(message) # Switch to speech-dispatcher mode self.ttsEngine = None self.readingEngine = SpeechEngine() speechRate = self.config.get_speech_rate() self.readingEngine.set_rate(speechRate) # Update config to remember this fallback self.config.set_reader_engine('speechd') else: # piper-tts is available self.readingEngine = None voiceModel = self.config.get_voice_model() self.ttsEngine = TtsEngine(voiceModel) message = "Voice reloaded successfully" print(message) self.speechEngine.speak(message) def run_interactive(self): """Run in interactive mode with pygame event loop""" if not HAS_PYGAME: print("\nError: pygame is required for BookStorm") print("Install with: pip install pygame") return if not self.audioPlayer.is_available(): print("\nError: Could not initialize pygame audio") return # Initialize pygame display with larger window for large print text pygame.init() self.screen = pygame.display.set_mode((1600, 900)) # Set caption - handle case where no book is loaded yet if self.book: pygame.display.set_caption(f"BookStorm - {self.book.title}") else: pygame.display.set_caption("BookStorm - Press A/B/R to load book") # Initialize font for large print display (72pt for severe visual impairment) self.font = pygame.font.Font(None, 96) # 96 pixels ≈ 72pt self.smallFont = pygame.font.Font(None, 36) # For status info (27pt) # Colors self.bgColor = (0, 0, 0) # Black background self.textColor = (255, 255, 255) # White text self.statusColor = (180, 180, 180) # Gray for status # Current display text if self.book: self.displayText = "Press SPACE to start reading" self.statusText = f"Book: {self.book.title}" # Determine book type for message isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook bookType = "Audio book" if isAudioBook else "Book" self.speechEngine.speak(f"{bookType} loaded, press h for help.") else: self.displayText = "No book loaded" self.statusText = "Press A for Audiobookshelf, B for local books, R for recent books" # Speech message already given earlier # Cached rendered surfaces to prevent memory leak from re-rendering 30 FPS self.cachedDisplayText = None self.cachedStatusText = None self.cachedSurfaces = [] self._run_pygame_loop() def _render_screen(self): """Render text to pygame window""" self.screen.fill(self.bgColor) # Check if text display is enabled showText = self.config.get_show_text() if showText: # Only re-render if text changed (prevents massive Surface object leak) if self.cachedDisplayText != self.displayText or self.cachedStatusText != self.statusText: # Explicitly delete old cached surfaces before clearing for surfaceType, surface, position in self.cachedSurfaces: del surface self.cachedSurfaces.clear() # Render status text at top statusSurface = self.smallFont.render(self.statusText, True, self.statusColor) self.cachedSurfaces.append(('status', statusSurface, (20, 20))) # Render main text with word wrapping words = self.displayText.split(' ') lines = [] currentLine = [] for word in words: testLine = ' '.join(currentLine + [word]) testSurface = self.font.render(testLine, True, self.textColor) if testSurface.get_width() < 1560: # Leave 40px margin (1600-40) currentLine.append(word) del testSurface # Delete test surface immediately else: del testSurface # Delete test surface immediately if currentLine: lines.append(' '.join(currentLine)) currentLine = [word] if currentLine: lines.append(' '.join(currentLine)) # Render wrapped lines and cache them yPos = 100 for line in lines: if yPos > 850: # Don't render beyond window (900-50 margin) break textSurface = self.font.render(line, True, self.textColor) self.cachedSurfaces.append(('text', textSurface, (20, yPos))) yPos += 110 # Line spacing for 96px font # Update cache markers self.cachedDisplayText = self.displayText self.cachedStatusText = self.statusText # Blit cached surfaces for surfaceType, surface, position in self.cachedSurfaces: self.screen.blit(surface, position) else: # Show simple message when text display is off message = "Text display off (press O for options)" textSurface = self.smallFont.render(message, True, self.statusColor) textRect = textSurface.get_rect(center=(800, 450)) self.screen.blit(textSurface, textRect) del textSurface pygame.display.flip() def _run_pygame_loop(self): """Main pygame event loop""" self.isRunning = True self.isPlaying = False clock = pygame.time.Clock() gcCounter = 0 # Counter for periodic garbage collection memoryWarningShown = False # Track if we've warned about high memory try: while self.isRunning: # Process pygame events events = pygame.event.get() for event in events: if event.type == pygame.QUIT: self.isRunning = False elif event.type == pygame.KEYDOWN: self._handle_pygame_key(event) elif event.type == SPEECH_FINISHED_EVENT: # Speech-dispatcher paragraph finished, advance to next # Don't auto-advance if in any menu inAnyMenu = (self.optionsMenu.is_in_menu() or self.bookSelector.is_in_browser() or self.sleepTimerMenu.is_in_menu() or self.recentBooksMenu.is_in_menu() or (self.absMenu and self.absMenu.is_in_menu())) if self.isPlaying and not inAnyMenu and self.book: if not self.next_paragraph(): self.displayText = "End of book reached" self.isPlaying = False self.save_bookmark(speakFeedback=False) else: # Start next paragraph self._start_paragraph_playback() # Check for Braille display key presses (panning, routing, etc.) if self.brailleOutput.enabled: brailleKey = self.brailleOutput.read_key() if brailleKey: self._handle_braille_key(brailleKey) # Explicitly delete event objects to help GC del events # Handle sleep timer fade-out (10 seconds before expiration) if not self.isFadingOut and self.sleepTimerMenu.should_start_fadeout(): # Start fade-out self.isFadingOut = True self.fadeStartVolume = self.audioPlayer.get_volume() self.fadeStartTime = time.time() # Update volume during fade-out if self.isFadingOut: elapsed = time.time() - self.fadeStartTime fadeProgress = elapsed / 10.0 # 10 second fade if fadeProgress >= 1.0: # Fade complete - stop at 25% volume targetVolume = int(self.fadeStartVolume * 0.25) self.audioPlayer.set_volume(targetVolume) else: # Calculate current volume (linear fade from start to 25%) # Formula: startVolume - (startVolume * 0.75 * progress) # This fades from 100% → 25% over 10 seconds currentVolume = int(self.fadeStartVolume * (1.0 - 0.75 * fadeProgress)) self.audioPlayer.set_volume(currentVolume) # Check if sleep timer has expired if self.sleepTimerMenu.check_timer(): self.speechEngine.speak("Sleep timer expired. Goodbye.") self.isRunning = False self.isPlaying = False # Check if we need to advance to next paragraph/chapter # Speech-dispatcher uses callbacks for auto-advance isAudioBook = self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook readerEngine = self.config.get_reader_engine() # Don't auto-advance if in any menu inAnyMenu = (self.optionsMenu.is_in_menu() or self.bookSelector.is_in_browser() or self.sleepTimerMenu.is_in_menu() or self.recentBooksMenu.is_in_menu() or (self.absMenu and self.absMenu.is_in_menu())) if self.isPlaying and not inAnyMenu and self.book: if isAudioBook: # For multi-file audiobooks, sync playlist position periodically # This keeps currentChapter in sync with mpv's actual position if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile: if self.audioPlayer.is_audio_file_loaded(): playlistIndex = self.audioPlayer.get_current_playlist_index() if playlistIndex != self.currentChapter: self.currentChapter = playlistIndex # Update status display with new chapter info chapter = self.book.get_chapter(self.currentChapter) if chapter: self.statusText = f"Ch {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title}" # Check if audio file playback finished if not self.audioPlayer.is_audio_file_playing() and not self.audioPlayer.is_paused(): # Audio chapter finished, advance to next chapter if not self.next_chapter(): # Book finished - restart from beginning self.displayText = "End of book reached - restarting from chapter 1" self.speechEngine.speak("Book finished. Restarting from chapter 1.") self.isPlaying = False self.currentChapter = 0 self.currentParagraph = 0 self.savedAudioPosition = 0.0 self.save_bookmark(speakFeedback=False) # Stop playback completely - user must press SPACE to restart self.audioPlayer.stop_audio_file() else: # Start next chapter self._start_paragraph_playback() elif readerEngine == 'piper': # Check if TTS audio has finished playing # Only auto-advance if NOT paused (to avoid skipping when user pauses) if not self.audioPlayer.is_paused(): playbackFinished = not self.audioPlayer.is_audio_file_playing() if playbackFinished: # Current paragraph finished, advance if not self.next_paragraph(): self.displayText = "End of book reached" self.isPlaying = False self.save_bookmark(speakFeedback=False) else: # Start next paragraph with error recovery try: self._start_paragraph_playback() except Exception as e: print(f"Error starting playback: {e}") self.speechEngine.speak("Playback error") self.isPlaying = False # Render the screen self._render_screen() # Periodic garbage collection to prevent memory creep # Every ~10 seconds (300 frames at 30 FPS) run GC gcCounter += 1 if gcCounter >= 300: # Clear any accumulated pygame events before GC pygame.event.clear() # Alternate between fast (gen 0) and full GC if gcCounter % 600 == 0: gc.collect() # Full collection every 20 seconds else: gc.collect(generation=0) # Fast collection every 10 seconds # Memory watchdog: warn if exceeding 2GB (50% on Pi 4GB) try: import resource # pylint: disable=no-member memUsage = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 # MB if memUsage > 2048 and not memoryWarningShown: memoryWarningShown = True self.speechEngine.speak("Warning: High memory usage detected. Consider restarting BookStorm soon.") except: pass gcCounter = 0 # Limit to 30 FPS to avoid CPU spinning clock.tick(30) except KeyboardInterrupt: print("\n\nInterrupted") finally: # Save bookmark BEFORE stopping (so we can get current position) self.save_bookmark(speakFeedback=False) # Stop playback readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': self.readingEngine.cancel_reading() else: # Stop audio player (handles both TTS and audio books) self.audioPlayer.stop() if self.audioPlayer.is_audio_file_loaded(): self.audioPlayer.stop_audio_file() # Close Audiobookshelf session if active if self.sessionId and self.absClient: try: self.absClient.close_session(self.sessionId) except: pass # Clean up speech engines if self.speechEngine: self.speechEngine.close() if readerEngine == 'speechd' and self.readingEngine: self.readingEngine.close() # Clean up audio player if self.audioPlayer: try: self.audioPlayer.cleanup() except Exception as e: print(f"Error cleaning up audio player: {e}") # Clear pygame resources self.cachedSurfaces.clear() pygame.quit() def _handle_braille_key(self, keyCode): """ Handle key presses from Braille display. Args: keyCode: Key code from brltty """ # BrlAPI key codes (common ones) # Forward panning: typically CMD_FWINRT or specific device codes # Backward panning: typically CMD_FWINLT # The exact codes depend on the device, so we check the command type try: # Convert key code to command # BrlAPI provides key codes as integers # Common commands (from brltty documentation): # FWINRT (forward) and FWINLT (backward) for panning # Check if it's a panning command # The key format varies by device, but we can check ranges # Typically: 0x04000000 range for panning commands # Try to parse as BrlAPI command command = keyCode & 0x00FFFFFF # Extract command part # Common commands (approximate values, device-specific) CMD_FWINRT = 0x0001 # Pan forward/right CMD_FWINLT = 0x0002 # Pan backward/left if command == CMD_FWINRT or (keyCode & 0xFF) == 0x01: # Forward panning self.brailleOutput.pan_forward() elif command == CMD_FWINLT or (keyCode & 0xFF) == 0x02: # Backward panning self.brailleOutput.pan_backward() except: pass def _handle_pygame_key(self, event): """Handle pygame key event""" # Check if in Audiobookshelf menu if self.absMenu and self.absMenu.is_in_menu(): self._handle_audiobookshelf_key(event) return # Check if in recent books menu if self.recentBooksMenu.is_in_menu(): self._handle_recent_books_key(event) return # Check if in bookmarks menu if self.bookmarksMenu.is_in_menu(): self._handle_bookmarks_key(event) return # Check if in book browser if self.bookSelector.is_in_browser(): self._handle_browser_key(event) return # Check if in sleep timer menu if self.sleepTimerMenu.is_in_menu(): self._handle_sleep_timer_key(event) return # Check if in options menu if self.optionsMenu.is_in_menu(): self._handle_menu_key(event) return # Check for shift modifier mods = pygame.key.get_mods() shiftPressed = mods & pygame.KMOD_SHIFT # Control key by itself: Stop speech-dispatcher (interrupt speech) if event.key == pygame.K_LCTRL or event.key == pygame.K_RCTRL: if self.speechEngine: self.speechEngine.stop() return if event.key == pygame.K_SPACE: # Toggle play/pause (only if book is loaded) if not self.book: self.speechEngine.speak("No book loaded. Press A for Audiobookshelf, B for local books, or R for recent books.") return isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook readerEngine = self.config.get_reader_engine() if not self.isPlaying: self.isPlaying = True self._start_paragraph_playback() else: # Toggle pause/resume if isAudioBook: # Handle audio book pause/resume if self.audioPlayer.is_paused(): self.audioPlayer.resume_audio_file() else: self.audioPlayer.pause_audio_file() elif readerEngine == 'speechd': # Handle speech-dispatcher pause/resume if self.readingEngine.is_reading_paused(): self.readingEngine.resume_reading() else: self.readingEngine.pause_reading() else: # Handle piper-tts pause/resume (now uses audio file methods) if self.audioPlayer.is_paused(): self.audioPlayer.resume_audio_file() else: self.audioPlayer.pause_audio_file() elif event.key == pygame.K_n: if not self.book: self.speechEngine.speak("No book loaded") return isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook if shiftPressed or isAudioBook: # Next chapter (or for audio books, always go to next chapter) wasPlaying = self.isPlaying # For audio books, just pause - don't stop if isAudioBook: if self.audioPlayer.is_audio_file_playing(): self.audioPlayer.pause_audio_file() else: self._stop_playback() if self.next_chapter(): if wasPlaying: self.isPlaying = True self._start_paragraph_playback() else: self.speechEngine.speak("No next chapter") self.isPlaying = False else: # Next paragraph (text books only) self._stop_playback() if self.next_paragraph(): self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("End of book") self.isPlaying = False elif event.key == pygame.K_p: if not self.book: self.speechEngine.speak("No book loaded") return isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook if shiftPressed or isAudioBook: # Previous chapter (or for audio books, always go to previous chapter) wasPlaying = self.isPlaying # For audio books, just pause - don't stop if isAudioBook: if self.audioPlayer.is_audio_file_playing(): self.audioPlayer.pause_audio_file() else: self._stop_playback() if self.previous_chapter(): if wasPlaying: self.isPlaying = True self._start_paragraph_playback() else: self.speechEngine.speak("No previous chapter") else: # Previous paragraph (text books only) self._stop_playback() if self.previous_paragraph(): self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("Beginning of book") elif event.key == pygame.K_s: if not self.book: self.speechEngine.speak("No book loaded") return readerEngine = self.config.get_reader_engine() # Pause playback while saving wasPaused = False if readerEngine == 'speechd': wasPaused = self.readingEngine.is_reading_paused() if not wasPaused and self.readingEngine.is_reading_active(): self.readingEngine.pause_reading() else: wasPaused = self.audioPlayer.is_paused() if not wasPaused and self.audioPlayer.is_playing(): self.audioPlayer.pause() # Re-enable auto-saving if it was disabled self.bookmarkCleared = False # Speak feedback (safe with separate sessions) self.save_bookmark(speakFeedback=True) # Resume playback if not wasPaused and self.isPlaying: if readerEngine == 'speechd': self.readingEngine.resume_reading() else: self.audioPlayer.resume() elif event.key == pygame.K_PAGEUP: # Increase speech rate readerEngine = self.config.get_reader_engine() currentRate = self.config.get_speech_rate() newRate = min(100, currentRate + 10) self.config.set_speech_rate(newRate) self.speechEngine.set_rate(newRate) # Apply to reading engine as well if readerEngine == 'speechd': self.readingEngine.set_rate(newRate) # If currently reading, restart paragraph to apply new rate immediately if self.isPlaying and self.readingEngine.is_reading_active(): self._restart_current_paragraph() self.speechEngine.speak(f"Speech rate: {newRate}") elif event.key == pygame.K_PAGEDOWN: # Decrease speech rate readerEngine = self.config.get_reader_engine() currentRate = self.config.get_speech_rate() newRate = max(-100, currentRate - 10) self.config.set_speech_rate(newRate) self.speechEngine.set_rate(newRate) # Apply to reading engine as well if readerEngine == 'speechd': self.readingEngine.set_rate(newRate) # If currently reading, restart paragraph to apply new rate immediately if self.isPlaying and self.readingEngine.is_reading_active(): self._restart_current_paragraph() self.speechEngine.speak(f"Speech rate: {newRate}") elif event.key == pygame.K_b: # Open book browser - reset to library directory if set libraryDir = self.config.get_library_directory() if libraryDir and Path(libraryDir).exists(): self.bookSelector.reset_to_directory(libraryDir) self.bookSelector.enter_browser() elif event.key == pygame.K_r: # Open recent books menu self.recentBooksMenu.enter_menu() elif event.key == pygame.K_k: # Open bookmarks menu if self.book: self.bookmarksMenu.enter_menu(str(self.bookPath)) else: self.speechEngine.speak("No book loaded") elif event.key == pygame.K_a: # Open Audiobookshelf browser self._open_audiobookshelf_browser() elif event.key == pygame.K_o: # Open options menu self.optionsMenu.enter_menu() elif event.key == pygame.K_h: # Help if self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: # Audio book help self.speechEngine.speak("SPACE: play pause. n: next chapter. p: previous chapter. LEFT RIGHT arrows: seek 5 seconds. UP DOWN arrows: seek 1 minute. SHIFT LEFT RIGHT: seek 30 seconds. SHIFT UP DOWN: seek 5 minutes. s: save bookmark. k: bookmarks menu. r: recent books. b: browse books. a: audiobookshelf. o: options menu. i: current info. Right bracket: increase playback speed. Left bracket: decrease playback speed. Backspace: reset playback speed. 0: increase volume. 9: decrease volume. SHIFT HOME: clear bookmark and jump to beginning. CONTROL: stop speech. t: time remaining. h: help. q: quit or sleep timer") else: # Text book help self.speechEngine.speak("SPACE: play pause. n: next paragraph. p: previous paragraph. Shift N: next chapter. Shift P: previous chapter. LEFT RIGHT arrows: previous next paragraph. SHIFT LEFT RIGHT: previous next chapter. UP DOWN arrows: skip 5 paragraphs. SHIFT UP DOWN: first last paragraph. s: save bookmark. k: bookmarks menu. r: recent books. b: browse books. a: audiobookshelf. o: options menu. i: current info. Page Up Down: adjust speech rate. Right bracket: increase playback speed. Left bracket: decrease playback speed. Backspace: reset playback speed. SHIFT HOME: clear bookmark and jump to beginning. CONTROL: stop speech. t: time remaining. h: help. q: quit or sleep timer") elif event.key == pygame.K_i: if not self.book: self.speechEngine.speak("No book loaded. Press H for help.") return # Speak current position info chapter = self.book.get_chapter(self.currentChapter) if chapter: info = f"{self.book.title}. {self.book.get_total_chapters()} chapters. Currently at chapter {self.currentChapter + 1}: {chapter.title}. Paragraph {self.currentParagraph + 1} of {chapter.get_total_paragraphs()}" self.speechEngine.speak(info) elif event.key == pygame.K_t: # Speak time remaining on sleep timer if self.sleepTimerMenu.is_timer_active(): timeRemaining = self.sleepTimerMenu.get_time_remaining() if timeRemaining: minutes, seconds = timeRemaining if minutes > 0: self.speechEngine.speak(f"{minutes} minutes {seconds} seconds remaining") else: self.speechEngine.speak(f"{seconds} seconds remaining") else: self.speechEngine.speak("No sleep timer active") elif event.key == pygame.K_q or event.key == pygame.K_ESCAPE: # Open sleep timer menu self.sleepTimerMenu.enter_menu() elif event.key == pygame.K_RIGHTBRACKET: # Increase playback speed (works for all books!) if self.book: currentSpeed = self.config.get_playback_speed() newSpeed = min(2.0, currentSpeed + 0.1) newSpeed = round(newSpeed, 1) # Round to 1 decimal place self._change_playback_speed(newSpeed) else: self.speechEngine.speak("No book loaded") elif event.key == pygame.K_LEFTBRACKET: # Decrease playback speed (works for all books!) if self.book: currentSpeed = self.config.get_playback_speed() newSpeed = max(0.5, currentSpeed - 0.1) newSpeed = round(newSpeed, 1) # Round to 1 decimal place self._change_playback_speed(newSpeed) else: self.speechEngine.speak("No book loaded") elif event.key == pygame.K_BACKSPACE: # Reset playback speed to 1.0 # Only if not in a menu (menus handle backspace themselves) if not (self.optionsMenu.is_in_menu() or self.bookSelector.is_in_browser() or self.sleepTimerMenu.is_in_menu() or self.recentBooksMenu.is_in_menu() or (self.absMenu and self.absMenu.is_in_menu())): if self.book: self._change_playback_speed(1.0) else: self.speechEngine.speak("No book loaded") elif event.key == pygame.K_0: # Increase volume (audio books and Piper-TTS - mpv handles volume control) if self.book: isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook isPiperMode = self.config.get_reader_engine() == 'piper' if isAudioBook or isPiperMode: newVolume = self.audioPlayer.increase_volume(5) self.config.set_volume(newVolume) self.speechEngine.speak(f"Volume {newVolume}") else: self.speechEngine.speak("Volume control only works for audio books and Piper TTS mode") else: self.speechEngine.speak("No book loaded") elif event.key == pygame.K_9: # Decrease volume (audio books and Piper-TTS - mpv handles volume control) if self.book: isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook isPiperMode = self.config.get_reader_engine() == 'piper' if isAudioBook or isPiperMode: newVolume = self.audioPlayer.decrease_volume(5) self.config.set_volume(newVolume) self.speechEngine.speak(f"Volume {newVolume}") else: self.speechEngine.speak("Volume control only works for audio books and Piper TTS mode") else: self.speechEngine.speak("No book loaded") elif event.key == pygame.K_HOME and shiftPressed: # Shift+Home: Clear bookmark and jump to beginning of book if not self.book: self.speechEngine.speak("No book loaded") else: # Stop current playback wasPlaying = self.isPlaying self.isPlaying = False self._stop_playback() # Delete bookmark for current book self.bookmarkManager.delete_bookmark(self.bookPath) self.bookmarkCleared = True # Mark that bookmark was explicitly cleared # Jump to beginning self.currentChapter = 0 self.currentParagraph = 0 self.savedAudioPosition = 0.0 # For audio books, seek to beginning if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile: # Multi-file: seek to first file in playlist if self.audioPlayer.is_audio_file_loaded(): self.audioPlayer.seek_to_playlist_index(0) self.audioPlayer.seek_audio(0.0) else: # Single-file: seek to time 0 if self.audioPlayer.is_audio_file_loaded(): self.audioPlayer.seek_audio(0.0) self.speechEngine.speak("Bookmark cleared. Jumped to beginning of book.") # Resume playback if it was playing if wasPlaying: self.isPlaying = True self._start_paragraph_playback() elif event.key == pygame.K_LEFT: # Left arrow: Seek backward (audio books) or previous paragraph (text books) if not self.book: self.speechEngine.speak("No book loaded") return isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook if isAudioBook: # Audio book: Seek backward by 5 or 30 seconds seekTime = -30 if shiftPressed else -5 self._seek_audio_by_time(seekTime) else: # Text book: Previous paragraph or chapter if shiftPressed: # Shift+Left: Previous chapter self._stop_playback() if self.previous_chapter(): if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("No previous chapter") else: # Left: Previous paragraph self._stop_playback() if self.previous_paragraph(): self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("Beginning of book") elif event.key == pygame.K_RIGHT: # Right arrow: Seek forward (audio books) or next paragraph (text books) if not self.book: self.speechEngine.speak("No book loaded") return isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook if isAudioBook: # Audio book: Seek forward by 5 or 30 seconds seekTime = 30 if shiftPressed else 5 self._seek_audio_by_time(seekTime) else: # Text book: Next paragraph or chapter if shiftPressed: # Shift+Right: Next chapter self._stop_playback() if self.next_chapter(): if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("No next chapter") self.isPlaying = False else: # Right: Next paragraph self._stop_playback() if self.next_paragraph(): self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("End of book") self.isPlaying = False elif event.key == pygame.K_UP: # Up arrow: Seek backward (audio books) or skip paragraphs backward (text books) if not self.book: self.speechEngine.speak("No book loaded") return isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook if isAudioBook: # Audio book: Seek backward by 1 or 5 minutes seekTime = -300 if shiftPressed else -60 self._seek_audio_by_time(seekTime) else: # Text book: Jump to first paragraph of chapter (Shift) or skip back 5 paragraphs if shiftPressed: # Shift+Up: Jump to first paragraph of chapter if self.currentParagraph > 0: self._stop_playback() self.currentParagraph = 0 self.speechEngine.speak(f"First paragraph") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("Already at first paragraph") else: # Up: Skip back 5 paragraphs self._stop_playback() targetParagraph = max(0, self.currentParagraph - 5) moved = self.currentParagraph != targetParagraph self.currentParagraph = targetParagraph if moved: self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("Beginning of chapter") elif event.key == pygame.K_DOWN: # Down arrow: Seek forward (audio books) or skip paragraphs forward (text books) if not self.book: self.speechEngine.speak("No book loaded") return isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook if isAudioBook: # Audio book: Seek forward by 1 or 5 minutes seekTime = 300 if shiftPressed else 60 self._seek_audio_by_time(seekTime) else: # Text book: Jump to last paragraph of chapter (Shift) or skip forward 5 paragraphs chapter = self.book.get_chapter(self.currentChapter) if not chapter: return if shiftPressed: # Shift+Down: Jump to last paragraph of chapter lastParagraph = chapter.get_total_paragraphs() - 1 if self.currentParagraph < lastParagraph: self._stop_playback() self.currentParagraph = lastParagraph self.speechEngine.speak(f"Last paragraph") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("Already at last paragraph") else: # Down: Skip forward 5 paragraphs self._stop_playback() maxParagraph = chapter.get_total_paragraphs() - 1 targetParagraph = min(maxParagraph, self.currentParagraph + 5) moved = self.currentParagraph != targetParagraph self.currentParagraph = targetParagraph if moved: self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("End of chapter") def _handle_recent_books_key(self, event): """Handle key events when in recent books menu""" if event.key == pygame.K_UP: self.recentBooksMenu.navigate_menu('up') elif event.key == pygame.K_DOWN: self.recentBooksMenu.navigate_menu('down') elif event.key == pygame.K_RETURN: # Select book selectedBook = self.recentBooksMenu.activate_current_item() if selectedBook: # Book was selected, load it self.recentBooksMenu.exit_menu() self._load_new_book(selectedBook) elif event.key == pygame.K_ESCAPE: self.recentBooksMenu.exit_menu() def _handle_bookmarks_key(self, event): """Handle key events when in bookmarks menu""" if event.key == pygame.K_UP: self.bookmarksMenu.navigate_menu('up') elif event.key == pygame.K_DOWN: self.bookmarksMenu.navigate_menu('down') elif event.key == pygame.K_RETURN: # Activate current item result = self.bookmarksMenu.activate_current_item() if result: action = result.get('action') if action == 'jump': # Jump to bookmark bookmark = result.get('bookmark') self._jump_to_bookmark(bookmark) self.bookmarksMenu.exit_menu() elif action == 'create': # Create new bookmark self._create_named_bookmark() elif event.key == pygame.K_DELETE or event.key == pygame.K_d: # Delete current bookmark self.bookmarksMenu.delete_current_bookmark() elif event.key == pygame.K_ESCAPE: self.bookmarksMenu.exit_menu() def _jump_to_bookmark(self, bookmark): """ Jump to a named bookmark Args: bookmark: Bookmark dictionary """ chapterIndex = bookmark['chapterIndex'] paragraphIndex = bookmark['paragraphIndex'] audioPosition = bookmark.get('audioPosition', 0.0) bookmarkName = bookmark['name'] # Stop current playback self.isPlaying = False if self.ttsEngine: self.audioPlayer.stop() else: self.readingEngine.stop() self.audioPlayer.stop_audio_file() # Update position self.currentChapter = chapterIndex self.currentParagraph = paragraphIndex # For audio books, seek to audio position if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: # Find chapter that contains this audio position for i, chapter in enumerate(self.book.chapters): if hasattr(chapter, 'startTime'): chapterEnd = chapter.startTime + chapter.duration if chapter.startTime <= audioPosition < chapterEnd: self.currentChapter = i # Position within chapter positionInChapter = audioPosition - chapter.startTime # For multi-file audiobooks, seek to correct file in playlist if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile: if self.audioPlayer.is_audio_file_loaded(): if self.audioPlayer.seek_to_playlist_index(self.currentChapter): # Seek to position within the file self.audioPlayer.seek_audio(positionInChapter) else: # Single-file audiobook: seek to absolute position if self.audioPlayer.is_audio_file_loaded(): self.audioPlayer.seek_audio(audioPosition) break # Speak feedback if self.speechEngine: chapter = self.book.get_chapter(self.currentChapter) chapterTitle = chapter.title if chapter else "Unknown" self.speechEngine.speak(f"Jumped to bookmark: {bookmarkName}. Chapter: {chapterTitle}") # Update display if self.config.get_show_text(): self._render_screen() def _create_named_bookmark(self): """Create a new named bookmark""" import getpass if self.speechEngine: self.speechEngine.speak("Enter bookmark name. Check terminal.") print("\n=== Create Bookmark ===") bookmarkName = input("Bookmark name: ").strip() if not bookmarkName: if self.speechEngine: self.speechEngine.speak("Cancelled") return # Calculate audio position if audio book audioPosition = 0.0 if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: # For multi-file audiobooks, sync currentChapter with playlist position if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile: playlistIndex = self.audioPlayer.get_current_playlist_index() self.currentChapter = playlistIndex chapter = self.book.get_chapter(self.currentChapter) if chapter and hasattr(chapter, 'startTime'): chapterStartTime = chapter.startTime else: chapterStartTime = 0.0 if self.audioPlayer.is_audio_file_playing() or self.audioPlayer.is_paused(): playbackPos = self.audioPlayer.get_audio_position() audioPosition = chapterStartTime + playbackPos # Create bookmark success = self.bookmarkManager.create_named_bookmark( self.bookPath, bookmarkName, self.currentChapter, self.currentParagraph, audioPosition=audioPosition ) if success: if self.speechEngine: self.speechEngine.speak(f"Bookmark created: {bookmarkName}") print(f"Bookmark '{bookmarkName}' created!") # Reload bookmarks in menu self.bookmarksMenu._load_bookmarks() self.bookmarksMenu._speak_current_item() else: if self.speechEngine: self.speechEngine.speak(f"Bookmark name already exists: {bookmarkName}") print(f"ERROR: Bookmark with name '{bookmarkName}' already exists") def _open_audiobookshelf_browser(self): """Open Audiobookshelf browser""" # Check if server is configured if not self.config.is_abs_configured(): self.speechEngine.speak("Audiobookshelf not configured. Please set server URL and username in config.") print("\nAudiobookshelf not configured.") print("Edit ~/.config/stormux/bookstorm/settings.ini and add:") print("[Audiobookshelf]") print("server_url = https://your-server.com") print("username = your-username") print("\nThen login with password when prompted.") return # Initialize client if needed if not self.absClient: serverUrl = self.config.get_abs_server_url() self.absClient = AudiobookshelfClient(serverUrl, self.config) # Check if already authenticated if not self.absClient.is_authenticated(): # Need to login self.speechEngine.speak("Audiobookshelf login required. Check terminal for password prompt.") print("\n=== Audiobookshelf Login ===") username = self.config.get_abs_username() print(f"Username: {username}") # Get password from user import getpass password = getpass.getpass("Password: ") if not self.absClient.login(username, password): self.speechEngine.speak("Login failed. Check username and password.") return self.speechEngine.speak("Login successful") # Test connection if not self.absClient.test_connection(): self.speechEngine.speak("Connection test failed. Check server URL.") return # Initialize menu if needed if not self.absMenu: self.absMenu = AudiobookshelfMenu(self.absClient, self.config, self.speechEngine) # Open browser self.absMenu.enter_menu() def _handle_audiobookshelf_key(self, event): """Handle key events when in Audiobookshelf menu""" if event.key == pygame.K_UP: self.absMenu.navigate_menu('up') elif event.key == pygame.K_DOWN: self.absMenu.navigate_menu('down') elif event.key == pygame.K_LEFT: self.absMenu.change_view('left') elif event.key == pygame.K_RIGHT: self.absMenu.change_view('right') elif event.key == pygame.K_RETURN: # Select item (library or book) result = self.absMenu.activate_current_item() if result: action = result.get('action') if action == 'open_local': # Open local copy of book localPath = result.get('path') if localPath: if self.absMenu: self.absMenu.exit_menu() self._load_new_book(localPath) elif action == 'stream': # Stream from server serverBook = result.get('serverBook') if serverBook: self._stream_audiobook(serverBook) elif action == 'download': # Download book to library serverBook = result.get('serverBook') if serverBook: self._download_audiobook(serverBook) elif event.key == pygame.K_BACKSPACE: # Go back if self.absMenu: self.absMenu.go_back() elif event.key == pygame.K_ESCAPE: if self.absMenu: self.absMenu.exit_menu() def _handle_browser_key(self, event): """Handle key events when in book browser""" if event.key == pygame.K_UP: self.bookSelector.navigate_browser('up') elif event.key == pygame.K_DOWN: self.bookSelector.navigate_browser('down') elif event.key == pygame.K_RETURN: # Select item (book or directory) selectedBook = self.bookSelector.activate_current_item() if selectedBook: # Book was selected, load it self.bookSelector.exit_browser() self._load_new_book(selectedBook) elif event.key == pygame.K_BACKSPACE or event.key == pygame.K_LEFT: # Go to parent directory self.bookSelector.go_parent_directory() elif event.key == pygame.K_l: # Set current directory as library directory currentDir = self.bookSelector.get_current_directory() self.config.set_library_directory(str(currentDir)) dirName = currentDir.name if currentDir.name else str(currentDir) self.speechEngine.speak(f"Library set to {dirName}") elif event.key == pygame.K_ESCAPE: self.bookSelector.exit_browser() def _handle_menu_key(self, event): """Handle key events when in options menu""" # Check if in restart confirmation dialog if self.optionsMenu.is_in_restart_menu(): if event.key == pygame.K_UP: self.optionsMenu.navigate_restart_menu('up') elif event.key == pygame.K_DOWN: self.optionsMenu.navigate_restart_menu('down') elif event.key == pygame.K_RETURN: self.optionsMenu.select_restart_option() elif event.key == pygame.K_ESCAPE: self.optionsMenu.exit_restart_menu() # Check if in voice selection submenu elif self.optionsMenu.is_in_voice_menu(): if event.key == pygame.K_UP: self.optionsMenu.navigate_voice_menu('up') elif event.key == pygame.K_DOWN: self.optionsMenu.navigate_voice_menu('down') elif event.key == pygame.K_RETURN: self.optionsMenu.select_current_voice() elif event.key == pygame.K_ESCAPE: self.optionsMenu.exit_voice_menu() # Check if in output module selection submenu elif self.optionsMenu.is_in_module_menu(): if event.key == pygame.K_UP: self.optionsMenu.navigate_module_menu('up') elif event.key == pygame.K_DOWN: self.optionsMenu.navigate_module_menu('down') elif event.key == pygame.K_RETURN: self.optionsMenu.select_current_module() elif event.key == pygame.K_ESCAPE: self.optionsMenu.exit_module_menu() else: # Main options menu if event.key == pygame.K_UP: self.optionsMenu.navigate_menu('up') elif event.key == pygame.K_DOWN: self.optionsMenu.navigate_menu('down') elif event.key == pygame.K_RETURN: # Activate current menu item stayInMenu = self.optionsMenu.activate_current_item() if not stayInMenu: self.optionsMenu.exit_menu() elif event.key == pygame.K_ESCAPE: self.speechEngine.speak("Closing options menu") self.optionsMenu.exit_menu() def _handle_sleep_timer_key(self, event): """Handle key events when in sleep timer menu""" if event.key == pygame.K_UP: self.sleepTimerMenu.navigate_menu('up') elif event.key == pygame.K_DOWN: self.sleepTimerMenu.navigate_menu('down') elif event.key == pygame.K_RETURN: # Activate current menu item shouldQuitNow, shouldContinue = self.sleepTimerMenu.activate_current_item() if shouldQuitNow: # User selected "Quit now" self.isRunning = False self.isPlaying = False elif shouldContinue: # New timer set - reset fade-out state and restore volume if needed if self.isFadingOut and self.fadeStartVolume is not None: self.audioPlayer.set_volume(self.fadeStartVolume) self.isFadingOut = False self.fadeStartVolume = None self.fadeStartTime = None elif event.key == pygame.K_ESCAPE: self.speechEngine.speak("Cancelled") self.sleepTimerMenu.exit_menu() def _stream_audiobook(self, serverBook): """ Stream audiobook from Audiobookshelf server Args: serverBook: Server book dictionary from API """ # Extract book metadata # Try different ID fields (structure varies by API endpoint) serverId = serverBook.get('id') or serverBook.get('libraryItemId') if not serverId: self.speechEngine.speak("Error: Book ID not found") print(f"\nERROR: No valid ID found in book metadata") print(f"Available keys: {list(serverBook.keys())}") return media = serverBook.get('media', {}) metadata = media.get('metadata', {}) title = metadata.get('title', 'Unknown') author = metadata.get('authorName', '') duration = media.get('duration', 0.0) # Get streaming URL (pass full book details to avoid re-fetching) self.speechEngine.speak(f"Loading stream for {title}. Please wait.") print(f"\nGetting stream URL for: {title}") streamUrl = self.absClient.get_stream_url(serverId, itemDetails=serverBook) if not streamUrl: self.speechEngine.speak("Failed to get stream URL. Check terminal for errors.") print("\nERROR: Could not get stream URL") print(f"Book structure keys: {list(serverBook.keys())}") if 'media' in serverBook: print(f"Media keys: {list(serverBook['media'].keys())}") return # Get chapters from server (from the book details we already have) serverChapters = media.get('chapters', []) # Create AudioBook object with stream URL from src.audio_parser import AudioBook, AudioChapter book = AudioBook(title=title, author=author, audioPath=streamUrl) book.totalDuration = duration # Add chapters from server if serverChapters: for chapterData in serverChapters: chapterTitle = chapterData.get('title', 'Untitled') startTime = chapterData.get('start', 0.0) chapterDuration = chapterData.get('end', 0.0) - startTime chapter = AudioChapter( title=chapterTitle, startTime=startTime, duration=chapterDuration ) book.add_chapter(chapter) else: # No chapters - treat entire book as single chapter chapter = AudioChapter( title=title, startTime=0.0, duration=duration ) book.add_chapter(chapter) # Store server metadata for progress sync self.serverBook = serverBook self.isStreaming = True # Load the book self.book = book self.bookPath = streamUrl # Store URL as path for tracking # Save server book reference for resume on restart # Use special format: abs://{server_id} so we can detect and resume self.config.set_last_book(f"abs://{serverId}") # Create listening session (only if we don't already have one from resume) if not self.sessionId: self.sessionId = self.absClient.create_session(serverId) if self.sessionId: print(f"Created listening session: {self.sessionId}") else: print(f"Using existing session ID: {self.sessionId}") # Save session and server metadata to server link manager # This allows resuming the stream with the same session self.serverLinkManager.create_link( bookPath=streamUrl, serverUrl=self.absClient.serverUrl, serverId=serverId, libraryId=serverBook.get('libraryId', ''), title=title, author=author, duration=duration, chapters=len(book.chapters), sessionId=self.sessionId, serverBook=serverBook ) # Try to load progress from server serverProgress = self.absClient.get_progress(serverId) if serverProgress: progressTime = serverProgress.get('currentTime', 0.0) minutes = int(progressTime // 60) seconds = int(progressTime % 60) # Save the exact position for playback resume self.savedAudioPosition = progressTime # Find chapter that contains this time for i, chap in enumerate(book.chapters): if hasattr(chap, 'startTime'): chapterEnd = chap.startTime + chap.duration if chap.startTime <= progressTime < chapterEnd: self.currentChapter = i break else: # No server progress, start from beginning self.currentChapter = 0 self.savedAudioPosition = 0.0 # Initialize position self.currentParagraph = 0 # Load stream URL directly - mpv will handle streaming natively print(f"Loading stream: {streamUrl[:80]}...") self.speechEngine.speak("Loading stream. This may take a moment.") # Load the stream URL - mpv handles streaming with auth headers # Pass auth token for authentication # Use saved playback speed from config playbackSpeed = self.config.get_playback_speed() if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken, playbackSpeed=playbackSpeed): self.speechEngine.speak("Failed to load stream. Check terminal for errors.") print("\nERROR: Failed to load stream from server") print("Make sure mpv is installed: sudo pacman -S mpv") return # Restore saved volume setting savedVolume = self.config.get_volume() self.audioPlayer.set_volume(savedVolume) # Success! Start playing self.speechEngine.speak(f"Now streaming {title}. Press space to pause.") print(f"\nNow streaming: {title} by {author}") print(f"Chapters: {len(book.chapters)}") # Exit menu (if we came from menu - not needed when resuming from startup) if self.absMenu: self.absMenu.exit_menu() # Update UI if enabled (only if screen is initialized) if self.config.get_show_text() and hasattr(self, 'screen') and self.screen: self._render_screen() # Start playback from saved position (if any) startPos = self.savedAudioPosition if self.savedAudioPosition > 0 else 0.0 if startPos > 0: minutes = int(startPos // 60) seconds = int(startPos % 60) print(f"Seeking to {minutes}m {seconds}s...") self.audioPlayer.play_audio_file(startPosition=startPos) self.isPlaying = True self.isAudioBook = True # Clear saved position after using it self.savedAudioPosition = 0.0 def _download_audiobook(self, serverBook): """ Download audiobook from Audiobookshelf server Args: serverBook: Server book dictionary from API """ # Check library directory is set libraryDir = self.config.get_library_directory() if not libraryDir: self.speechEngine.speak("Library directory not set. Please set library directory first. Press B then L.") print("\nERROR: Library directory not set.") print("Press 'b' to browse files, then 'L' to set library directory.") return libraryPath = Path(libraryDir) if not libraryPath.exists(): self.speechEngine.speak("Library directory does not exist.") print(f"\nERROR: Library directory does not exist: {libraryDir}") return # Extract book metadata # Try different ID fields (structure varies by API endpoint) serverId = serverBook.get('id') or serverBook.get('libraryItemId') if not serverId: self.speechEngine.speak("Error: Book ID not found") print(f"\nERROR: No valid ID found in book metadata") print(f"Available keys: {list(serverBook.keys())}") return media = serverBook.get('media', {}) metadata = media.get('metadata', {}) title = metadata.get('title', 'Unknown') author = metadata.get('authorName', '') duration = media.get('duration', 0.0) numChapters = media.get('numChapters', 0) # Create sanitized filename safeTitle = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip() safeTitle = safeTitle.replace(' ', '_') # Determine file extension from server audioFiles = media.get('audioFiles', []) if audioFiles: firstFile = audioFiles[0] fileMetadata = firstFile.get('metadata', {}) fileName = fileMetadata.get('filename', '') fileExt = Path(fileName).suffix if fileName else '.m4b' else: fileExt = '.m4b' outputPath = libraryPath / f"{safeTitle}{fileExt}" # Check if file already exists if outputPath.exists(): self.speechEngine.speak("Book already downloaded.") print(f"\nBook already exists: {outputPath}") # Open it anyway if self.absMenu: self.absMenu.exit_menu() self._load_new_book(str(outputPath)) return # Notify user self.speechEngine.speak(f"Downloading {title}. This may take several minutes.") print(f"\nDownloading: {title}") print(f"Output: {outputPath}") print("Please wait...") # Progress callback lastPercent = 0 def progress_callback(percent): nonlocal lastPercent # Only update every 10% if percent >= lastPercent + 10: print(f"Progress: {percent}%") lastPercent = percent # Download file success = self.absClient.download_audio_file(serverId, str(outputPath), progress_callback) if not success: self.speechEngine.speak("Download failed. Check terminal for errors.") print("\nDownload failed!") return # Create server link serverUrl = self.config.get_abs_server_url() libraryId = serverBook.get('libraryId', '') self.serverLinkManager.create_link( bookPath=str(outputPath), serverUrl=serverUrl, serverId=serverId, libraryId=libraryId, title=title, author=author, duration=duration, chapters=numChapters ) # Success! self.speechEngine.speak(f"Download complete. Opening {title}.") print(f"\nDownload complete! Opening book...") # Exit menu and open the book if self.absMenu: self.absMenu.exit_menu() self._load_new_book(str(outputPath)) def _change_playback_speed(self, newSpeed): """ Change audio playback speed (instant with mpv!) Args: newSpeed: New playback speed (0.5 to 2.0) """ # Clamp speed to valid range newSpeed = max(0.5, min(2.0, float(newSpeed))) # Save to config self.config.set_playback_speed(newSpeed) # Apply speed change based on reader engine and book type readerEngine = self.config.get_reader_engine() isAudioBook = self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook if isAudioBook or (readerEngine == 'piper' and self.isPlaying): # Both audio books and Piper-TTS use MpvPlayer: instant speed change self.audioPlayer.set_speed(newSpeed) # For Piper-TTS, restart current paragraph to apply new speed immediately if not isAudioBook and self.isPlaying: self.audioPlayer.stop_audio_file() self._start_paragraph_playback() # Speak feedback speedPercent = int(newSpeed * 100) self.speechEngine.speak(f"Speed {speedPercent} percent") def _seek_audio_by_time(self, seconds): """ Seek audio forward/backward by specified seconds (audio books only) Args: seconds: Number of seconds to seek (positive = forward, negative = backward) Returns: True if seek successful, False otherwise """ # Only works for audio books if not self.book: return False isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook if not isAudioBook: return False # Check if audio is actually loaded/playing if not self.audioPlayer.is_audio_file_loaded(): self.speechEngine.speak("Nothing playing") return False # Get current position currentPos = self.audioPlayer.get_audio_position() # Calculate new position newPos = max(0.0, currentPos + seconds) # Seek to new position silently (audio feedback is enough) return self.audioPlayer.seek_audio(newPos) def _load_new_book(self, bookPath): """ Load a new book from file path Args: bookPath: Path to new book file """ # Save bookmark for current book BEFORE stopping playback # (so we can capture the current audio position) if self.book: self.save_bookmark(speakFeedback=False) # Stop current playback self.audioPlayer.stop() self._cancel_buffer() self.isPlaying = False # Update book path and config self.bookPath = Path(bookPath) self.config.set_last_book(bookPath) self.config.set_books_directory(str(self.bookPath.parent)) # Reset audio position state self.savedAudioPosition = 0.0 self.bookmarkCleared = False # Load new book (which will restore bookmark if it exists) try: self.load_book() self.speechEngine.speak("Ready") except Exception as e: message = f"Error loading book: {e}" print(message) self.speechEngine.speak(message) def _stop_playback(self): """Stop current playback (audio books, piper-tts, and speech-dispatcher)""" # Handle case where no book is loaded if not self.book: return isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook readerEngine = self.config.get_reader_engine() if isAudioBook or readerEngine == 'piper': # Stop audio playback (audio books or TTS via MpvPlayer) if readerEngine == 'piper': self._cancel_buffer() if self.audioPlayer.is_audio_file_loaded(): self.audioPlayer.stop_audio_file() else: self.audioPlayer.stop() elif readerEngine == 'speechd': # Cancel speech-dispatcher reading self.readingEngine.cancel_reading() def _restart_current_paragraph(self): """ Restart current paragraph playback (for speech-dispatcher rate changes) This is needed because speech-dispatcher only applies rate changes to the next speech utterance, not to currently playing speech. """ # Cancel current speech self.readingEngine.cancel_reading() # Restart from current paragraph self._start_paragraph_playback() def _start_paragraph_playback(self): """Start playing current paragraph""" chapter = self.book.get_chapter(self.currentChapter) if not chapter: print("ERROR: No chapter found!") return # Check if this is an audio book if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: # Audio book playback self._start_audio_chapter_playback(chapter) return paragraph = chapter.get_paragraph(self.currentParagraph) if not paragraph: print("ERROR: No paragraph found!") return # Update display text and status self.displayText = paragraph self.statusText = f"Ch {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title} | Para {self.currentParagraph + 1}/{chapter.get_total_paragraphs()}" # Use configured reader engine readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': # Use speech-dispatcher for reading with callback def on_speech_finished(finishType): """ Callback when speech-dispatcher finishes speaking. Must not call speechd commands directly (causes deadlock). Post pygame event instead. """ if finishType == 'COMPLETED' and self.isPlaying: # Post pygame event to handle in main loop pygame.event.post(pygame.event.Event(SPEECH_FINISHED_EVENT)) # Show on Braille display (unless muted for Braille-only mode) if self.brailleOutput.enabled: self.brailleOutput.show_paragraph(paragraph) # Speak (unless muted for Braille-only mode) if not self.brailleOutput.muteVoice: self.readingEngine.speak_reading(paragraph, callback=on_speech_finished) else: # Use piper-tts for reading with buffering # Show on Braille display if self.brailleOutput.enabled: self.brailleOutput.show_paragraph(paragraph) # Only generate/play audio if voice not muted if not self.brailleOutput.muteVoice: wavData = None try: # Check if we have buffered audio ready with self.bufferLock: if self.bufferedAudio is not None: # Use pre-generated audio wavData = self.bufferedAudio self.bufferedAudio = None else: # Generate audio now (first paragraph or after navigation) wavData = self.ttsEngine.text_to_wav_data(paragraph) if wavData: # Stop any existing audio playback if self.audioPlayer.is_audio_file_playing(): self.audioPlayer.stop_audio_file() # Get current playback speed from config playbackSpeed = self.config.get_playback_speed() # Play WAV data through MpvPlayer (which supports pause/resume) if self.audioPlayer.play_wav_data(wavData, playbackSpeed=playbackSpeed): # Start buffering next paragraph in background self._buffer_next_paragraph() else: print("Error: Failed to start TTS playback") self.isPlaying = False # Explicitly delete wavData after playback starts to free memory del wavData wavData = None else: print("Warning: No audio data generated") except Exception as e: print(f"Error during playback: {e}") # Stop playback on error to prevent infinite error loop self.isPlaying = False raise finally: # The variable is already set to None in all relevant paths pass def _start_audio_chapter_playback(self, chapter): """Start playing audio book chapter""" # Update display text and status self.displayText = f"Playing: {chapter.title}" self.statusText = f"Ch {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title}" # Check if multi-file audiobook (each chapter is a separate file) isMultiFile = hasattr(self.book, 'isMultiFile') and self.book.isMultiFile # Determine start position # If we have a saved audio position and we're on the saved chapter, use it # Save the position before clearing it (we'll need it later for multi-file) resumePosition = self.savedAudioPosition if resumePosition > 0.0: startTime = resumePosition # Clear saved position so we don't use it again (only for initial resume) self.savedAudioPosition = 0.0 minutes = int(startTime // 60) seconds = int(startTime % 60) else: # Get start time from audio chapter if hasattr(chapter, 'startTime'): startTime = chapter.startTime else: startTime = 0.0 # Seek to position and play if self.audioPlayer.audioFileLoaded: if isMultiFile: # For multi-file audiobooks, seek to the correct file in playlist # Chapter index = file index (each file is a chapter) # Validate chapter index is within bounds totalChapters = self.book.get_total_chapters() if self.currentChapter < 0 or self.currentChapter >= totalChapters: print(f"ERROR: Invalid chapter index {self.currentChapter} (total chapters: {totalChapters})") self.currentChapter = 0 print(f"Reset to chapter 0") if self.audioPlayer.seek_to_playlist_index(self.currentChapter): # Calculate position within current file # If resuming (saved position > 0), use position relative to chapter start positionInFile = 0.0 if resumePosition > 0.0: # Get current chapter's start time to calculate position within file chapter = self.book.get_chapter(self.currentChapter) if chapter and hasattr(chapter, 'startTime'): positionInFile = resumePosition - chapter.startTime # Ensure position is within file bounds if positionInFile < 0: positionInFile = 0.0 elif hasattr(chapter, 'duration') and positionInFile > chapter.duration: positionInFile = chapter.duration # Start playback at calculated position self.audioPlayer.play_audio_file(startPosition=positionInFile) else: print(f"ERROR: Could not seek to playlist index {self.currentChapter}") self.isPlaying = False else: # Single-file audiobook with chapter markers self.audioPlayer.play_audio_file(startPosition=startTime) else: print("ERROR: Audio file not loaded!") self.isPlaying = False def _buffer_next_paragraph(self): """Start buffering next paragraph in background thread""" # Only for piper-tts (speech-dispatcher handles buffering internally) readerEngine = self.config.get_reader_engine() if readerEngine != 'piper': return # Don't start a new buffer thread if one is already running # This prevents thread accumulation when playback outruns buffering if self.bufferThread and self.bufferThread.is_alive(): return # CRITICAL: Clear any stale buffered audio before starting new thread # This happens when buffer thread finishes AFTER we already generated audio synchronously with self.bufferLock: if self.bufferedAudio is not None: print("Warning: Discarding stale buffered audio (orphaned buffer)") del self.bufferedAudio self.bufferedAudio = None # Calculate next paragraph position nextChapter = self.currentChapter nextParagraph = self.currentParagraph + 1 chapter = self.book.get_chapter(nextChapter) if not chapter: return # Check if we need to move to next chapter if nextParagraph >= chapter.get_total_paragraphs(): nextChapter += 1 nextParagraph = 0 chapter = self.book.get_chapter(nextChapter) if not chapter: return # End of book # Get the paragraph to buffer paragraph = chapter.get_paragraph(nextParagraph) if not paragraph: return def buffer_thread(): """Background thread to generate audio""" _wavData_to_cleanup = None try: # Generate audio wavData_generated = self.ttsEngine.text_to_wav_data(paragraph) _wavData_to_cleanup = wavData_generated # Check if cancelled if self.cancelBuffer: del _wavData_to_cleanup _wavData_to_cleanup = None return # Store buffered audio with self.bufferLock: if not self.cancelBuffer: self.bufferedAudio = _wavData_to_cleanup _wavData_to_cleanup = None # Transfer ownership except Exception as e: print(f"Error buffering paragraph: {e}") with self.bufferLock: self.bufferedAudio = None if _wavData_to_cleanup is not None: del _wavData_to_cleanup _wavData_to_cleanup = None finally: # The variable is already set to None in all relevant paths pass # Clear any cancelled buffer state with self.bufferLock: self.cancelBuffer = False # Clean up previous buffer thread reference before starting new one if self.bufferThread and not self.bufferThread.is_alive(): self.bufferThread = None # Start new buffer thread self.bufferThread = threading.Thread(target=buffer_thread, daemon=True) self.bufferThread.start() def _cancel_buffer(self): """Cancel in-progress buffering""" if self.bufferThread and self.bufferThread.is_alive(): self.cancelBuffer = True # Wait longer for TTS generation to finish (piper-tts can be slow) # If thread doesn't finish, it will be abandoned (daemon thread) self.bufferThread.join(timeout=3.0) if self.bufferThread.is_alive(): print("Warning: Buffer thread did not finish in time") # Clear buffered audio and explicitly delete to free memory with self.bufferLock: if self.bufferedAudio is not None: del self.bufferedAudio self.bufferedAudio = None self.cancelBuffer = False # Reset thread reference self.bufferThread = None def cleanup(self): """Cleanup resources""" # Close active listening session if any if self.sessionId and self.absClient: print(f"Closing listening session: {self.sessionId}") self.absClient.close_session(self.sessionId) # Clear session from link metadata if self.bookPath: self.serverLinkManager.clear_session(str(self.bookPath)) self.sessionId = None # Save current volume setting if audio book or Piper-TTS was used if self.book: isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook isPiperMode = self.config.get_reader_engine() == 'piper' if isAudioBook or isPiperMode: # If we're fading out, save the original volume (not the faded volume) if self.isFadingOut and self.fadeStartVolume is not None: currentVolume = self.fadeStartVolume else: currentVolume = self.audioPlayer.get_volume() self.config.set_volume(currentVolume) self._cancel_buffer() # Cleanup audio player (handles both TTS and audio books) self.audioPlayer.cleanup() self.speechEngine.cleanup() if self.readingEngine: self.readingEngine.cleanup() if self.parser: self.parser.cleanup() # Cleanup Braille display if self.brailleOutput: self.brailleOutput.close() def main(): """Main entry point""" # Set process title for easier identification if HAS_SETPROCTITLE: setproctitle("BookStorm") parser = argparse.ArgumentParser( description="BookStorm - Accessible book reader with TTS", epilog="Press 'o' in the reader for options menu" ) parser.add_argument( 'book', nargs='?', help='Path to book file (EPUB, PDF, TXT, or DAISY zip). If not provided, will resume last book' ) parser.add_argument( '--wav', action='store_true', help='Export book to WAV files (by chapter) instead of interactive reading' ) parser.add_argument( '--output-dir', dest='outputDir', help='Output directory for exported audio (default: ./book_audio/)', default=None ) args = parser.parse_args() # Load configuration config = ConfigManager() # Determine which book to use bookPath = None if args.book: # Book provided on command line bookPath = args.book else: # Try to use last book lastBook = config.get_last_book() # Check if last book was an Audiobookshelf stream if lastBook and lastBook.startswith('abs://'): # Extract server book ID serverId = lastBook[6:] # Remove 'abs://' prefix # Try to restore from cached server link from src.server_link_manager import ServerLinkManager serverLinkManager = ServerLinkManager() # Try to find cached server book metadata by server ID # The bookPath in the link will be the stream URL from last session cachedLink = None for sidecarPath in (Path.home() / ".bookstorm" / "server_links").glob("*.json"): try: import json with open(sidecarPath, 'r') as f: linkData = json.load(f) if linkData.get('server_id') == serverId: cachedLink = linkData break except: continue if cachedLink and cachedLink.get('server_book'): # We have cached metadata - try to resume using it print(f"Found cached server book metadata, resuming stream...") if config.is_abs_configured(): try: # Initialize Audiobookshelf client from src.audiobookshelf_client import AudiobookshelfClient serverUrl = config.get_abs_server_url() absClient = AudiobookshelfClient(serverUrl, config) if absClient.is_authenticated() and absClient.test_connection(): # Use cached server book metadata instead of re-fetching serverBook = cachedLink['server_book'] print(f"Restoring from cached metadata...") try: from src.speech_engine import SpeechEngine speechEngine = SpeechEngine() reader = BookReader(None, config) reader.absClient = absClient # Restore session ID if it was saved savedSessionId = cachedLink.get('session_id') if savedSessionId: print(f"Restoring listening session: {savedSessionId}") reader.sessionId = savedSessionId reader._stream_audiobook(serverBook) reader.run_interactive() return 0 except Exception as e: print(f"Error resuming stream: {e}") import traceback traceback.print_exc() print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books") else: print("Cannot connect to Audiobookshelf server or session expired") print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") except Exception as e: print(f"Error connecting to Audiobookshelf: {e}") print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") else: print("Audiobookshelf not configured") print("Opening BookStorm anyway - use 'o' to configure server or 'b' for local books") else: # No cached metadata - try fetching from server print(f"No cached metadata, fetching from server...") if config.is_abs_configured(): try: # Initialize Audiobookshelf client from src.audiobookshelf_client import AudiobookshelfClient serverUrl = config.get_abs_server_url() absClient = AudiobookshelfClient(serverUrl, config) if absClient.is_authenticated() and absClient.test_connection(): # Get book details print(f"Fetching book details from server...") bookDetails = absClient.get_library_item_details(serverId) if bookDetails: # Successfully got book details - try to stream print(f"Found book on server, preparing to stream...") try: from src.speech_engine import SpeechEngine speechEngine = SpeechEngine() reader = BookReader(None, config) reader.absClient = absClient reader._stream_audiobook(bookDetails) reader.run_interactive() return 0 except Exception as e: print(f"Error resuming stream: {e}") print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books") else: print(f"Book not found on server (may have been deleted)") print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books") else: print("Cannot connect to Audiobookshelf server or session expired") print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") except Exception as e: print(f"Error connecting to Audiobookshelf: {e}") print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") else: print("Audiobookshelf not configured") print("Opening BookStorm anyway - use 'o' to configure server or 'b' for local books") # Fall through to open BookStorm in interactive mode # Clear last_book so we don't loop on this error config.set_last_book('') # Open BookStorm without a book - user can browse print("\nStarting BookStorm in interactive mode...") print("Press 'a' for Audiobookshelf, 'b' for local books, 'r' for recent books") try: from src.speech_engine import SpeechEngine speechEngine = SpeechEngine() speechEngine.speak("Could not resume stream. Press A for Audiobookshelf, B for local books, or R for recent books.") # Create a minimal book to satisfy BookReader initialization # We'll use a dummy book that tells user what to do reader = BookReader(None, config) reader.book = None # No book loaded yet reader.run_interactive() # User can use menus to load a book return 0 except Exception as e: print(f"Error starting BookStorm: {e}") import traceback traceback.print_exc() return 1 elif lastBook and Path(lastBook).exists(): bookPath = lastBook else: # No book available - open in interactive mode print("BookStorm - Accessible Book Reader") if lastBook: print(f"\nNote: Last book no longer exists: {lastBook}") else: print("\nNo previous book found") print("Starting in interactive mode...") print("Press 'a' for Audiobookshelf, 'b' for local books, 'r' for recent books\n") try: from src.speech_engine import SpeechEngine speechEngine = SpeechEngine() speechEngine.speak("BookStorm ready. Press A for Audiobookshelf, B for local books, or R for recent books.") reader = BookReader(None, config) reader.book = None reader.run_interactive() return 0 except Exception as e: print(f"Error starting BookStorm: {e}") import traceback traceback.print_exc() print("\nUsage:") print(" python bookstorm.py # Read EPUB book") print(" python bookstorm.py # Read PDF book") print(" python bookstorm.py # Read TXT book") print(" python bookstorm.py # Read DAISY book") print(" python bookstorm.py book.epub --wav # Export to WAV files") return 1 # Check if book exists (only for local books) if bookPath and not Path(bookPath).exists(): print(f"Error: Book file not found: {bookPath}") return 1 # Handle export mode if args.wav: exporter = WavExporter(config) return exporter.export(bookPath, args.outputDir) # Interactive reading mode config.set_last_book(bookPath) try: reader = BookReader(bookPath, config) reader.load_book() reader.run_interactive() except Exception as e: print(f"Error: {e}", file=sys.stderr) return 1 finally: if 'reader' in locals(): reader.cleanup() return 0 if __name__ == '__main__': sys.exit(main())