diff --git a/bookstorm.py b/bookstorm.py index 6a7d7ea..6d459b7 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -139,10 +139,25 @@ class BookReader: # Apply speech rate to reading engine self.readingEngine.set_rate(speechRate) else: - # Use piper-tts - self.readingEngine = None - voiceModel = self.config.get_voice_model() - self.ttsEngine = TtsEngine(voiceModel) + # 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 @@ -156,6 +171,7 @@ class BookReader: # 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""" @@ -276,6 +292,8 @@ class BookReader: print(f"Resuming from chapter {self.currentChapter + 1}, paragraph {self.currentParagraph + 1}") else: print("Starting from beginning") + self.currentChapter = 0 + self.currentParagraph = 0 self.savedAudioPosition = 0.0 def read_current_paragraph(self): @@ -361,14 +379,21 @@ class BookReader: 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: - # 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 - # Get current chapter start time chapter = self.book.get_chapter(self.currentChapter) if chapter and hasattr(chapter, 'startTime'): @@ -482,13 +507,29 @@ class BookReader: print(message) self.speechEngine.speak(message) else: - # Reload piper-tts with new voice - self.readingEngine = None - voiceModel = self.config.get_voice_model() - self.ttsEngine = TtsEngine(voiceModel) - message = "Voice reloaded successfully" - print(message) - self.speechEngine.speak(message) + # 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""" @@ -665,13 +706,32 @@ class BookReader: 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(): - self.displayText = "End of book reached" + # 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() @@ -749,6 +809,17 @@ class BookReader: else: self.audioPlayer.stop() + # Clean up TTS mpv subprocess if it's still running + if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None: + try: + self.ttsMpvProcess.terminate() + self.ttsMpvProcess.wait(timeout=2) + except subprocess.TimeoutExpired: + print("Warning: TTS subprocess didn't terminate, force killing") + self.ttsMpvProcess.kill() + except Exception as e: + print(f"Error cleaning up TTS subprocess: {e}") + # Close Audiobookshelf session if active if self.sessionId and self.absClient: try: @@ -762,6 +833,13 @@ class BookReader: 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() @@ -802,6 +880,12 @@ class BookReader: 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: @@ -852,11 +936,20 @@ class BookReader: if shiftPressed or isAudioBook: # Next chapter (or for audio books, always go to next chapter) - self._stop_playback() + 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(): - chapter = self.book.get_chapter(self.currentChapter) - self.speechEngine.speak(f"Chapter {self.currentChapter + 1}: {chapter.title}") - if self.isPlaying: + # Just say "Next chapter" without title to avoid confusion + self.speechEngine.speak("Next chapter") + if wasPlaying: + self.isPlaying = True self._start_paragraph_playback() else: self.speechEngine.speak("No next chapter") @@ -881,11 +974,20 @@ class BookReader: if shiftPressed or isAudioBook: # Previous chapter (or for audio books, always go to previous chapter) - self._stop_playback() + 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(): - chapter = self.book.get_chapter(self.currentChapter) - self.speechEngine.speak(f"Chapter {self.currentChapter + 1}: {chapter.title}") - if self.isPlaying: + # Just say "Previous chapter" without title to avoid confusion + self.speechEngine.speak("Previous chapter") + if wasPlaying: + self.isPlaying = True self._start_paragraph_playback() else: self.speechEngine.speak("No previous chapter") @@ -917,6 +1019,9 @@ class BookReader: 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) @@ -937,6 +1042,9 @@ class BookReader: # 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: @@ -949,6 +1057,9 @@ class BookReader: # 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: @@ -979,7 +1090,12 @@ class BookReader: elif event.key == pygame.K_h: # Help - self.speechEngine.speak("SPACE: play pause. n: next paragraph. p: previous paragraph. Shift N: next chapter. Shift P: previous chapter. 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. t: time remaining. h: help. q: quit or sleep timer") + 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. 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: @@ -1042,6 +1158,194 @@ class BookReader: 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(): + self.speechEngine.speak("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(): + self.speechEngine.speak("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: @@ -1121,9 +1425,17 @@ class BookReader: self.currentChapter = i # Position within chapter positionInChapter = audioPosition - chapter.startTime - # Seek to position - if self.audioPlayer.is_audio_file_loaded(): - self.audioPlayer.seek_audio(audioPosition) + + # 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 @@ -1154,6 +1466,11 @@ class BookReader: # 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 @@ -1684,6 +2001,50 @@ class BookReader: 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 + + # Get current position + currentPos = self.audioPlayer.get_audio_position() + + # Calculate new position + newPos = max(0.0, currentPos + seconds) + + # Seek to new position + if self.audioPlayer.seek_audio(newPos): + # Format time for speech feedback + absSeconds = abs(seconds) + if absSeconds >= 60: + minutes = int(absSeconds // 60) + secs = int(absSeconds % 60) + if secs > 0: + timeStr = f"{minutes} minutes {secs} seconds" + else: + timeStr = f"{minutes} minutes" + else: + timeStr = f"{int(absSeconds)} seconds" + + direction = "forward" if seconds > 0 else "backward" + self.speechEngine.speak(f"Seek {timeStr} {direction}") + return True + + return False + def _load_new_book(self, bookPath): """ Load a new book from file path @@ -1738,10 +2099,31 @@ class BookReader: self._cancel_buffer() # Terminate the TTS mpv subprocess if it's running if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None: - self.ttsMpvProcess.terminate() - self.ttsMpvProcess.wait(timeout=1) + try: + self.ttsMpvProcess.terminate() + self.ttsMpvProcess.wait(timeout=1) + except subprocess.TimeoutExpired: + # Force kill if it doesn't terminate gracefully + print("Warning: TTS subprocess didn't terminate, force killing") + self.ttsMpvProcess.kill() + self.ttsMpvProcess.wait(timeout=1) + except Exception as e: + print(f"Error terminating TTS subprocess: {e}") self.audioPlayer.stop() + 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) @@ -1899,6 +2281,14 @@ class BookReader: 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 diff --git a/src/audiobookshelf_client.py b/src/audiobookshelf_client.py index 1e35eb2..cd91a28 100644 --- a/src/audiobookshelf_client.py +++ b/src/audiobookshelf_client.py @@ -297,27 +297,27 @@ class AudiobookshelfClient: print(f"DEBUG: Downloading from: {downloadUrl}") # Download with streaming to handle large files - response = requests.get(downloadUrl, headers=headers, stream=True, timeout=30) + # Use context manager to ensure response cleanup + with requests.get(downloadUrl, headers=headers, stream=True, timeout=30) as response: + if response.status_code != 200: + print(f"Download failed: {response.status_code}") + return False - if response.status_code != 200: - print(f"Download failed: {response.status_code}") - return False + # Get total file size + totalSize = int(response.headers.get('content-length', 0)) - # Get total file size - totalSize = int(response.headers.get('content-length', 0)) + # Download to file + downloaded = 0 + with open(outputPath, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded += len(chunk) - # Download to file - downloaded = 0 - with open(outputPath, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - if chunk: - f.write(chunk) - downloaded += len(chunk) - - # Progress callback - if progressCallback and totalSize > 0: - percent = int((downloaded / totalSize) * 100) - progressCallback(percent) + # Progress callback + if progressCallback and totalSize > 0: + percent = int((downloaded / totalSize) * 100) + progressCallback(percent) print(f"Download complete: {outputPath}") return True diff --git a/src/bookmark_manager.py b/src/bookmark_manager.py index 3a42dd6..26f681e 100644 --- a/src/bookmark_manager.py +++ b/src/bookmark_manager.py @@ -34,45 +34,44 @@ class BookmarkManager: def _init_db(self): """Initialize database schema""" - conn = sqlite3.connect(self.dbPath) - cursor = conn.cursor() + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() - cursor.execute(''' - CREATE TABLE IF NOT EXISTS bookmarks ( - book_id TEXT PRIMARY KEY, - book_path TEXT NOT NULL, - book_title TEXT, - chapter_index INTEGER NOT NULL DEFAULT 0, - paragraph_index INTEGER NOT NULL DEFAULT 0, - sentence_index INTEGER NOT NULL DEFAULT 0, - last_accessed TEXT, - created_at TEXT - ) - ''') + cursor.execute(''' + CREATE TABLE IF NOT EXISTS bookmarks ( + book_id TEXT PRIMARY KEY, + book_path TEXT NOT NULL, + book_title TEXT, + chapter_index INTEGER NOT NULL DEFAULT 0, + paragraph_index INTEGER NOT NULL DEFAULT 0, + sentence_index INTEGER NOT NULL DEFAULT 0, + last_accessed TEXT, + created_at TEXT + ) + ''') - # Add audio_position column if it doesn't exist (migration for existing databases) - try: - cursor.execute('ALTER TABLE bookmarks ADD COLUMN audio_position REAL DEFAULT 0.0') - except sqlite3.OperationalError: - # Column already exists - pass + # Add audio_position column if it doesn't exist (migration for existing databases) + try: + cursor.execute('ALTER TABLE bookmarks ADD COLUMN audio_position REAL DEFAULT 0.0') + except sqlite3.OperationalError: + # Column already exists + pass - # Create named_bookmarks table for multiple bookmarks per book - cursor.execute(''' - CREATE TABLE IF NOT EXISTS named_bookmarks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - book_id TEXT NOT NULL, - name TEXT NOT NULL, - chapter_index INTEGER NOT NULL DEFAULT 0, - paragraph_index INTEGER NOT NULL DEFAULT 0, - audio_position REAL DEFAULT 0.0, - created_at TEXT NOT NULL, - UNIQUE(book_id, name) - ) - ''') + # Create named_bookmarks table for multiple bookmarks per book + cursor.execute(''' + CREATE TABLE IF NOT EXISTS named_bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book_id TEXT NOT NULL, + name TEXT NOT NULL, + chapter_index INTEGER NOT NULL DEFAULT 0, + paragraph_index INTEGER NOT NULL DEFAULT 0, + audio_position REAL DEFAULT 0.0, + created_at TEXT NOT NULL, + UNIQUE(book_id, name) + ) + ''') - conn.commit() - conn.close() + conn.commit() def _get_book_id(self, bookPath): """Generate unique book ID from file path""" @@ -94,20 +93,19 @@ class BookmarkManager: bookId = self._get_book_id(bookPath) timestamp = datetime.now().isoformat() - conn = sqlite3.connect(self.dbPath) - cursor = conn.cursor() + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() - cursor.execute(''' - INSERT OR REPLACE INTO bookmarks - (book_id, book_path, book_title, chapter_index, paragraph_index, - sentence_index, audio_position, last_accessed, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, - COALESCE((SELECT created_at FROM bookmarks WHERE book_id = ?), ?)) - ''', (bookId, str(bookPath), bookTitle, chapterIndex, paragraphIndex, - sentenceIndex, audioPosition, timestamp, bookId, timestamp)) + cursor.execute(''' + INSERT OR REPLACE INTO bookmarks + (book_id, book_path, book_title, chapter_index, paragraph_index, + sentence_index, audio_position, last_accessed, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, + COALESCE((SELECT created_at FROM bookmarks WHERE book_id = ?), ?)) + ''', (bookId, str(bookPath), bookTitle, chapterIndex, paragraphIndex, + sentenceIndex, audioPosition, timestamp, bookId, timestamp)) - conn.commit() - conn.close() + conn.commit() def get_bookmark(self, bookPath): """ @@ -121,18 +119,17 @@ class BookmarkManager: """ bookId = self._get_book_id(bookPath) - conn = sqlite3.connect(self.dbPath) - cursor = conn.cursor() + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() - cursor.execute(''' - SELECT chapter_index, paragraph_index, sentence_index, - book_title, last_accessed, audio_position - FROM bookmarks - WHERE book_id = ? - ''', (bookId,)) + cursor.execute(''' + SELECT chapter_index, paragraph_index, sentence_index, + book_title, last_accessed, audio_position + FROM bookmarks + WHERE book_id = ? + ''', (bookId,)) - row = cursor.fetchone() - conn.close() + row = cursor.fetchone() if row: return { @@ -155,13 +152,10 @@ class BookmarkManager: """ bookId = self._get_book_id(bookPath) - conn = sqlite3.connect(self.dbPath) - cursor = conn.cursor() - - cursor.execute('DELETE FROM bookmarks WHERE book_id = ?', (bookId,)) - - conn.commit() - conn.close() + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM bookmarks WHERE book_id = ?', (bookId,)) + conn.commit() def list_bookmarks(self): """ @@ -170,18 +164,17 @@ class BookmarkManager: Returns: List of dictionaries with bookmark data """ - conn = sqlite3.connect(self.dbPath) - cursor = conn.cursor() + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() - cursor.execute(''' - SELECT book_path, book_title, chapter_index, paragraph_index, - sentence_index, last_accessed - FROM bookmarks - ORDER BY last_accessed DESC - ''') + cursor.execute(''' + SELECT book_path, book_title, chapter_index, paragraph_index, + sentence_index, last_accessed + FROM bookmarks + ORDER BY last_accessed DESC + ''') - rows = cursor.fetchall() - conn.close() + rows = cursor.fetchall() bookmarks = [] for row in rows: @@ -213,23 +206,21 @@ class BookmarkManager: bookId = self._get_book_id(bookPath) timestamp = datetime.now().isoformat() - conn = sqlite3.connect(self.dbPath) - cursor = conn.cursor() - try: - cursor.execute(''' - INSERT INTO named_bookmarks - (book_id, name, chapter_index, paragraph_index, audio_position, created_at) - VALUES (?, ?, ?, ?, ?, ?) - ''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp)) + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() - conn.commit() - conn.close() + cursor.execute(''' + INSERT INTO named_bookmarks + (book_id, name, chapter_index, paragraph_index, audio_position, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp)) + + conn.commit() return True except sqlite3.IntegrityError: # Bookmark with this name already exists - conn.close() return False def get_named_bookmarks(self, bookPath): @@ -244,18 +235,17 @@ class BookmarkManager: """ bookId = self._get_book_id(bookPath) - conn = sqlite3.connect(self.dbPath) - cursor = conn.cursor() + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() - cursor.execute(''' - SELECT id, name, chapter_index, paragraph_index, audio_position, created_at - FROM named_bookmarks - WHERE book_id = ? - ORDER BY created_at DESC - ''', (bookId,)) + cursor.execute(''' + SELECT id, name, chapter_index, paragraph_index, audio_position, created_at + FROM named_bookmarks + WHERE book_id = ? + ORDER BY created_at DESC + ''', (bookId,)) - rows = cursor.fetchall() - conn.close() + rows = cursor.fetchall() bookmarks = [] for row in rows: @@ -277,13 +267,10 @@ class BookmarkManager: Args: bookmarkId: Bookmark ID """ - conn = sqlite3.connect(self.dbPath) - cursor = conn.cursor() - - cursor.execute('DELETE FROM named_bookmarks WHERE id = ?', (bookmarkId,)) - - conn.commit() - conn.close() + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM named_bookmarks WHERE id = ?', (bookmarkId,)) + conn.commit() def get_named_bookmark_by_id(self, bookmarkId): """ @@ -295,17 +282,16 @@ class BookmarkManager: Returns: Bookmark dictionary or None if not found """ - conn = sqlite3.connect(self.dbPath) - cursor = conn.cursor() + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() - cursor.execute(''' - SELECT name, chapter_index, paragraph_index, audio_position - FROM named_bookmarks - WHERE id = ? - ''', (bookmarkId,)) + cursor.execute(''' + SELECT name, chapter_index, paragraph_index, audio_position + FROM named_bookmarks + WHERE id = ? + ''', (bookmarkId,)) - row = cursor.fetchone() - conn.close() + row = cursor.fetchone() if row: return { diff --git a/src/daisy_parser.py b/src/daisy_parser.py index a4d455f..8ee5b87 100644 --- a/src/daisy_parser.py +++ b/src/daisy_parser.py @@ -47,13 +47,18 @@ class DaisyParser: # Detect DAISY version and parse accordingly if (tempPath / "ncc.html").exists(): - return self._parse_daisy2(tempPath) + book = self._parse_daisy2(tempPath) elif (tempPath / "navigation.ncx").exists() or list(tempPath.glob("*.ncx")): - return self._parse_daisy3(tempPath) + book = self._parse_daisy3(tempPath) else: raise ValueError("Unknown DAISY format: no ncc.html or navigation.ncx found") + # Cleanup before returning + self.cleanup() + return book + except Exception as e: + # Ensure cleanup on error self.cleanup() raise e diff --git a/src/folder_audiobook_parser.py b/src/folder_audiobook_parser.py index 3f7a4cc..c022647 100644 --- a/src/folder_audiobook_parser.py +++ b/src/folder_audiobook_parser.py @@ -152,6 +152,7 @@ class FolderAudiobookParser: try: audioFile = self.mutagen.File(audioPath) if not audioFile: + # File format not recognized by mutagen - use filename fallback return metadata # Get duration @@ -235,8 +236,10 @@ class FolderAudiobookParser: except: pass - except Exception as e: - print(f"Warning: Could not read metadata from {audioPath.name}: {e}") + except Exception: + # Silently fall back to filename-based metadata + # This is normal for files without tags or unsupported formats + pass return metadata diff --git a/src/mpv_player.py b/src/mpv_player.py index ffdb771..f6fca50 100644 --- a/src/mpv_player.py +++ b/src/mpv_player.py @@ -325,11 +325,19 @@ class MpvPlayer: return False try: - self.player.seek(position, reference='absolute') + # For very small positions (< 1 second), just skip seeking + # This avoids issues with mpv not being ready yet + if position < 1.0: + position = 0.0 + + if position > 0: + self.player.seek(position, reference='absolute') + return True except Exception as e: - print(f"Error seeking audio: {e}") - return False + print(f"Error seeking audio to {position}s: {e}") + # Don't fail completely - just start from beginning + return True def unload_audio_file(self): """Unload the current audio file""" @@ -386,17 +394,41 @@ class MpvPlayer: True if seek successful """ if not self.isInitialized or not self.audioFileLoaded: + print(f"ERROR: Cannot seek - mpv not initialized or no audio loaded") return False if not self.playlist or index < 0 or index >= len(self.playlist): + print(f"ERROR: Invalid playlist index: {index} (playlist has {len(self.playlist) if self.playlist else 0} items)") return False try: # Set playlist position # pylint: disable=no-member + currentPos = self.player.playlist_pos + + # If playlist is idle (pos = -1), we need to unpause first to activate it + if currentPos == -1: + # Unpause to activate the playlist + self.player.pause = False + import time + time.sleep(0.1) + # Now pause again so we can seek + self.player.pause = True + time.sleep(0.1) + + # Now set the playlist position self.player.playlist_pos = index + + # Wait for mpv to switch files + import time + time.sleep(0.1) + + # Update our internal tracking AFTER mpv has switched self.currentPlaylistIndex = index + return True except Exception as e: - print(f"Error seeking to playlist index: {e}") + print(f"ERROR: Exception seeking to playlist index {index}: {e}") + import traceback + traceback.print_exc() return False \ No newline at end of file diff --git a/src/speech_engine.py b/src/speech_engine.py index 62b3e70..e74ff9a 100644 --- a/src/speech_engine.py +++ b/src/speech_engine.py @@ -15,6 +15,8 @@ try: except ImportError: HAS_SPEECHD = False +from .text_validator import is_valid_text + class SpeechEngine: """Text-to-speech engine for UI accessibility using speech-dispatcher""" @@ -61,7 +63,7 @@ class SpeechEngine: text: Text to speak interrupt: If True, stop current speech first (default: True) """ - if not self.isAvailable or not text: + if not self.isAvailable or not is_valid_text(text): return # Safety: Wait for previous UI speech thread to finish if still running @@ -104,8 +106,8 @@ class SpeechEngine: print("ERROR: Speech-dispatcher not available") return - if not text: - print("ERROR: No text to speak") + if not is_valid_text(text): + print("ERROR: No valid text to speak (empty or no alphanumeric characters)") return try: @@ -293,9 +295,5 @@ class SpeechEngine: return self.isAvailable def cleanup(self): - """Close speech-dispatcher connection""" - if self.isAvailable and self.client: - try: - self.client.close() - except Exception: - pass + """Cleanup resources - alias for close()""" + self.close() diff --git a/src/text_validator.py b/src/text_validator.py new file mode 100644 index 0000000..78508a4 --- /dev/null +++ b/src/text_validator.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Text Validator + +Validates text before sending to speech engines or other processors. +Ensures text contains at least one alphanumeric character. +""" + +import re + + +def is_valid_text(text): + """ + Check if text is valid for processing + + Args: + text: Text to validate + + Returns: + bool: True if text contains at least one alphanumeric character, False otherwise + """ + if not text: + return False + + # Check if there's at least one alphanumeric character + return bool(re.search(r'[a-zA-Z0-9]', str(text))) + + +def sanitize_text(text, defaultText=""): + """ + Sanitize text for processing, returning valid text or default + + Args: + text: Text to sanitize + defaultText: Default text to return if invalid (default: "") + + Returns: + str: Original text if valid, defaultText if invalid + """ + if is_valid_text(text): + return str(text) + return defaultText diff --git a/src/tts_engine.py b/src/tts_engine.py index 66722ce..05bdbec 100644 --- a/src/tts_engine.py +++ b/src/tts_engine.py @@ -11,11 +11,24 @@ import subprocess import wave import io import struct +import shutil + +from .text_validator import is_valid_text class TtsEngine: """Text-to-speech engine using piper-tts""" + @staticmethod + def is_available(): + """ + Check if piper-tts is available on the system + + Returns: + bool: True if piper-tts is installed, False otherwise + """ + return shutil.which('piper-tts') is not None + def __init__(self, modelPath="/usr/share/piper-voices/en/en_US/hfc_male/medium/en_US-hfc_male-medium.onnx"): """ Initialize TTS engine @@ -41,7 +54,7 @@ class TtsEngine: Raises: RuntimeError: If piper-tts fails """ - if not text.strip(): + if not is_valid_text(text): return None # Safety: Limit text size to prevent excessive memory usage