Fixed bookmarking with audio books.

This commit is contained in:
Storm Dragon
2025-10-16 16:30:58 -04:00
parent 542764be2a
commit 0bdc5bdf17
9 changed files with 648 additions and 178 deletions

View File

@@ -139,10 +139,25 @@ class BookReader:
# Apply speech rate to reading engine # Apply speech rate to reading engine
self.readingEngine.set_rate(speechRate) self.readingEngine.set_rate(speechRate)
else: else:
# Use piper-tts # Use piper-tts (check if available first)
self.readingEngine = None if not TtsEngine.is_available():
voiceModel = self.config.get_voice_model() # piper-tts not found, fall back to speech-dispatcher
self.ttsEngine = TtsEngine(voiceModel) 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 # Playback state
self.isRunning = False self.isRunning = False
@@ -156,6 +171,7 @@ class BookReader:
# Audio bookmark state # Audio bookmark state
self.savedAudioPosition = 0.0 # Saved audio position for resume self.savedAudioPosition = 0.0 # Saved audio position for resume
self.bookmarkCleared = False # Track if user explicitly cleared bookmark
def load_book(self): def load_book(self):
"""Load and parse the book""" """Load and parse the book"""
@@ -276,6 +292,8 @@ class BookReader:
print(f"Resuming from chapter {self.currentChapter + 1}, paragraph {self.currentParagraph + 1}") print(f"Resuming from chapter {self.currentChapter + 1}, paragraph {self.currentParagraph + 1}")
else: else:
print("Starting from beginning") print("Starting from beginning")
self.currentChapter = 0
self.currentParagraph = 0
self.savedAudioPosition = 0.0 self.savedAudioPosition = 0.0
def read_current_paragraph(self): def read_current_paragraph(self):
@@ -361,14 +379,21 @@ class BookReader:
if not self.book: if not self.book:
return 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 # For audio books, calculate current playback position
audioPosition = 0.0 audioPosition = 0.0
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: 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 # Get current chapter start time
chapter = self.book.get_chapter(self.currentChapter) chapter = self.book.get_chapter(self.currentChapter)
if chapter and hasattr(chapter, 'startTime'): if chapter and hasattr(chapter, 'startTime'):
@@ -482,13 +507,29 @@ class BookReader:
print(message) print(message)
self.speechEngine.speak(message) self.speechEngine.speak(message)
else: else:
# Reload piper-tts with new voice # Reload piper-tts with new voice (check if available first)
self.readingEngine = None if not TtsEngine.is_available():
voiceModel = self.config.get_voice_model() # piper-tts not found, fall back to speech-dispatcher
self.ttsEngine = TtsEngine(voiceModel) message = "Warning: piper-tts not found. Falling back to speech-dispatcher."
message = "Voice reloaded successfully" print(message)
print(message) self.speechEngine.speak(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): def run_interactive(self):
"""Run in interactive mode with pygame event loop""" """Run in interactive mode with pygame event loop"""
@@ -665,13 +706,32 @@ class BookReader:
if self.isPlaying and not inAnyMenu and self.book: if self.isPlaying and not inAnyMenu and self.book:
if isAudioBook: 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 # Check if audio file playback finished
if not self.audioPlayer.is_audio_file_playing() and not self.audioPlayer.is_paused(): if not self.audioPlayer.is_audio_file_playing() and not self.audioPlayer.is_paused():
# Audio chapter finished, advance to next chapter # Audio chapter finished, advance to next chapter
if not self.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.isPlaying = False
self.currentChapter = 0
self.currentParagraph = 0
self.savedAudioPosition = 0.0
self.save_bookmark(speakFeedback=False) self.save_bookmark(speakFeedback=False)
# Stop playback completely - user must press SPACE to restart
self.audioPlayer.stop_audio_file()
else: else:
# Start next chapter # Start next chapter
self._start_paragraph_playback() self._start_paragraph_playback()
@@ -749,6 +809,17 @@ class BookReader:
else: else:
self.audioPlayer.stop() 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 # Close Audiobookshelf session if active
if self.sessionId and self.absClient: if self.sessionId and self.absClient:
try: try:
@@ -762,6 +833,13 @@ class BookReader:
if readerEngine == 'speechd' and self.readingEngine: if readerEngine == 'speechd' and self.readingEngine:
self.readingEngine.close() 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 # Clear pygame resources
self.cachedSurfaces.clear() self.cachedSurfaces.clear()
pygame.quit() pygame.quit()
@@ -802,6 +880,12 @@ class BookReader:
mods = pygame.key.get_mods() mods = pygame.key.get_mods()
shiftPressed = mods & pygame.KMOD_SHIFT 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: if event.key == pygame.K_SPACE:
# Toggle play/pause (only if book is loaded) # Toggle play/pause (only if book is loaded)
if not self.book: if not self.book:
@@ -852,11 +936,20 @@ class BookReader:
if shiftPressed or isAudioBook: if shiftPressed or isAudioBook:
# Next chapter (or for audio books, always go to next chapter) # 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(): if self.next_chapter():
chapter = self.book.get_chapter(self.currentChapter) # Just say "Next chapter" without title to avoid confusion
self.speechEngine.speak(f"Chapter {self.currentChapter + 1}: {chapter.title}") self.speechEngine.speak("Next chapter")
if self.isPlaying: if wasPlaying:
self.isPlaying = True
self._start_paragraph_playback() self._start_paragraph_playback()
else: else:
self.speechEngine.speak("No next chapter") self.speechEngine.speak("No next chapter")
@@ -881,11 +974,20 @@ class BookReader:
if shiftPressed or isAudioBook: if shiftPressed or isAudioBook:
# Previous chapter (or for audio books, always go to previous chapter) # 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(): if self.previous_chapter():
chapter = self.book.get_chapter(self.currentChapter) # Just say "Previous chapter" without title to avoid confusion
self.speechEngine.speak(f"Chapter {self.currentChapter + 1}: {chapter.title}") self.speechEngine.speak("Previous chapter")
if self.isPlaying: if wasPlaying:
self.isPlaying = True
self._start_paragraph_playback() self._start_paragraph_playback()
else: else:
self.speechEngine.speak("No previous chapter") self.speechEngine.speak("No previous chapter")
@@ -917,6 +1019,9 @@ class BookReader:
if not wasPaused and self.audioPlayer.is_playing(): if not wasPaused and self.audioPlayer.is_playing():
self.audioPlayer.pause() self.audioPlayer.pause()
# Re-enable auto-saving if it was disabled
self.bookmarkCleared = False
# Speak feedback (safe with separate sessions) # Speak feedback (safe with separate sessions)
self.save_bookmark(speakFeedback=True) self.save_bookmark(speakFeedback=True)
@@ -937,6 +1042,9 @@ class BookReader:
# Apply to reading engine as well # Apply to reading engine as well
if readerEngine == 'speechd': if readerEngine == 'speechd':
self.readingEngine.set_rate(newRate) 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}") self.speechEngine.speak(f"Speech rate: {newRate}")
elif event.key == pygame.K_PAGEDOWN: elif event.key == pygame.K_PAGEDOWN:
@@ -949,6 +1057,9 @@ class BookReader:
# Apply to reading engine as well # Apply to reading engine as well
if readerEngine == 'speechd': if readerEngine == 'speechd':
self.readingEngine.set_rate(newRate) 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}") self.speechEngine.speak(f"Speech rate: {newRate}")
elif event.key == pygame.K_b: elif event.key == pygame.K_b:
@@ -979,7 +1090,12 @@ class BookReader:
elif event.key == pygame.K_h: elif event.key == pygame.K_h:
# Help # 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: elif event.key == pygame.K_i:
if not self.book: if not self.book:
@@ -1042,6 +1158,194 @@ class BookReader:
else: else:
self.speechEngine.speak("No book loaded") 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): def _handle_recent_books_key(self, event):
"""Handle key events when in recent books menu""" """Handle key events when in recent books menu"""
if event.key == pygame.K_UP: if event.key == pygame.K_UP:
@@ -1121,9 +1425,17 @@ class BookReader:
self.currentChapter = i self.currentChapter = i
# Position within chapter # Position within chapter
positionInChapter = audioPosition - chapter.startTime positionInChapter = audioPosition - chapter.startTime
# Seek to position
if self.audioPlayer.is_audio_file_loaded(): # For multi-file audiobooks, seek to correct file in playlist
self.audioPlayer.seek_audio(audioPosition) 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 break
# Speak feedback # Speak feedback
@@ -1154,6 +1466,11 @@ class BookReader:
# Calculate audio position if audio book # Calculate audio position if audio book
audioPosition = 0.0 audioPosition = 0.0
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: 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) chapter = self.book.get_chapter(self.currentChapter)
if chapter and hasattr(chapter, 'startTime'): if chapter and hasattr(chapter, 'startTime'):
chapterStartTime = chapter.startTime chapterStartTime = chapter.startTime
@@ -1684,6 +2001,50 @@ class BookReader:
speedPercent = int(newSpeed * 100) speedPercent = int(newSpeed * 100)
self.speechEngine.speak(f"Speed {speedPercent} percent") 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): def _load_new_book(self, bookPath):
""" """
Load a new book from file path Load a new book from file path
@@ -1738,10 +2099,31 @@ class BookReader:
self._cancel_buffer() self._cancel_buffer()
# Terminate the TTS mpv subprocess if it's running # Terminate the TTS mpv subprocess if it's running
if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None: if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None:
self.ttsMpvProcess.terminate() try:
self.ttsMpvProcess.wait(timeout=1) 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() 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): def _start_paragraph_playback(self):
"""Start playing current paragraph""" """Start playing current paragraph"""
chapter = self.book.get_chapter(self.currentChapter) chapter = self.book.get_chapter(self.currentChapter)
@@ -1899,6 +2281,14 @@ class BookReader:
if isMultiFile: if isMultiFile:
# For multi-file audiobooks, seek to the correct file in playlist # For multi-file audiobooks, seek to the correct file in playlist
# Chapter index = file index (each file is a chapter) # 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): if self.audioPlayer.seek_to_playlist_index(self.currentChapter):
# Calculate position within current file # Calculate position within current file
# If resuming (saved position > 0), use position relative to chapter start # If resuming (saved position > 0), use position relative to chapter start

View File

@@ -297,27 +297,27 @@ class AudiobookshelfClient:
print(f"DEBUG: Downloading from: {downloadUrl}") print(f"DEBUG: Downloading from: {downloadUrl}")
# Download with streaming to handle large files # 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: # Get total file size
print(f"Download failed: {response.status_code}") totalSize = int(response.headers.get('content-length', 0))
return False
# Get total file size # Download to file
totalSize = int(response.headers.get('content-length', 0)) 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 # Progress callback
downloaded = 0 if progressCallback and totalSize > 0:
with open(outputPath, 'wb') as f: percent = int((downloaded / totalSize) * 100)
for chunk in response.iter_content(chunk_size=8192): progressCallback(percent)
if chunk:
f.write(chunk)
downloaded += len(chunk)
# Progress callback
if progressCallback and totalSize > 0:
percent = int((downloaded / totalSize) * 100)
progressCallback(percent)
print(f"Download complete: {outputPath}") print(f"Download complete: {outputPath}")
return True return True

View File

@@ -34,45 +34,44 @@ class BookmarkManager:
def _init_db(self): def _init_db(self):
"""Initialize database schema""" """Initialize database schema"""
conn = sqlite3.connect(self.dbPath) with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS bookmarks ( CREATE TABLE IF NOT EXISTS bookmarks (
book_id TEXT PRIMARY KEY, book_id TEXT PRIMARY KEY,
book_path TEXT NOT NULL, book_path TEXT NOT NULL,
book_title TEXT, book_title TEXT,
chapter_index INTEGER NOT NULL DEFAULT 0, chapter_index INTEGER NOT NULL DEFAULT 0,
paragraph_index INTEGER NOT NULL DEFAULT 0, paragraph_index INTEGER NOT NULL DEFAULT 0,
sentence_index INTEGER NOT NULL DEFAULT 0, sentence_index INTEGER NOT NULL DEFAULT 0,
last_accessed TEXT, last_accessed TEXT,
created_at TEXT created_at TEXT
) )
''') ''')
# Add audio_position column if it doesn't exist (migration for existing databases) # Add audio_position column if it doesn't exist (migration for existing databases)
try: try:
cursor.execute('ALTER TABLE bookmarks ADD COLUMN audio_position REAL DEFAULT 0.0') cursor.execute('ALTER TABLE bookmarks ADD COLUMN audio_position REAL DEFAULT 0.0')
except sqlite3.OperationalError: except sqlite3.OperationalError:
# Column already exists # Column already exists
pass pass
# Create named_bookmarks table for multiple bookmarks per book # Create named_bookmarks table for multiple bookmarks per book
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS named_bookmarks ( CREATE TABLE IF NOT EXISTS named_bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT NOT NULL, book_id TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
chapter_index INTEGER NOT NULL DEFAULT 0, chapter_index INTEGER NOT NULL DEFAULT 0,
paragraph_index INTEGER NOT NULL DEFAULT 0, paragraph_index INTEGER NOT NULL DEFAULT 0,
audio_position REAL DEFAULT 0.0, audio_position REAL DEFAULT 0.0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
UNIQUE(book_id, name) UNIQUE(book_id, name)
) )
''') ''')
conn.commit() conn.commit()
conn.close()
def _get_book_id(self, bookPath): def _get_book_id(self, bookPath):
"""Generate unique book ID from file path""" """Generate unique book ID from file path"""
@@ -94,20 +93,19 @@ class BookmarkManager:
bookId = self._get_book_id(bookPath) bookId = self._get_book_id(bookPath)
timestamp = datetime.now().isoformat() timestamp = datetime.now().isoformat()
conn = sqlite3.connect(self.dbPath) with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
INSERT OR REPLACE INTO bookmarks INSERT OR REPLACE INTO bookmarks
(book_id, book_path, book_title, chapter_index, paragraph_index, (book_id, book_path, book_title, chapter_index, paragraph_index,
sentence_index, audio_position, last_accessed, created_at) sentence_index, audio_position, last_accessed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, VALUES (?, ?, ?, ?, ?, ?, ?, ?,
COALESCE((SELECT created_at FROM bookmarks WHERE book_id = ?), ?)) COALESCE((SELECT created_at FROM bookmarks WHERE book_id = ?), ?))
''', (bookId, str(bookPath), bookTitle, chapterIndex, paragraphIndex, ''', (bookId, str(bookPath), bookTitle, chapterIndex, paragraphIndex,
sentenceIndex, audioPosition, timestamp, bookId, timestamp)) sentenceIndex, audioPosition, timestamp, bookId, timestamp))
conn.commit() conn.commit()
conn.close()
def get_bookmark(self, bookPath): def get_bookmark(self, bookPath):
""" """
@@ -121,18 +119,17 @@ class BookmarkManager:
""" """
bookId = self._get_book_id(bookPath) bookId = self._get_book_id(bookPath)
conn = sqlite3.connect(self.dbPath) with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT chapter_index, paragraph_index, sentence_index, SELECT chapter_index, paragraph_index, sentence_index,
book_title, last_accessed, audio_position book_title, last_accessed, audio_position
FROM bookmarks FROM bookmarks
WHERE book_id = ? WHERE book_id = ?
''', (bookId,)) ''', (bookId,))
row = cursor.fetchone() row = cursor.fetchone()
conn.close()
if row: if row:
return { return {
@@ -155,13 +152,10 @@ class BookmarkManager:
""" """
bookId = self._get_book_id(bookPath) bookId = self._get_book_id(bookPath)
conn = sqlite3.connect(self.dbPath) with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('DELETE FROM bookmarks WHERE book_id = ?', (bookId,))
cursor.execute('DELETE FROM bookmarks WHERE book_id = ?', (bookId,)) conn.commit()
conn.commit()
conn.close()
def list_bookmarks(self): def list_bookmarks(self):
""" """
@@ -170,18 +164,17 @@ class BookmarkManager:
Returns: Returns:
List of dictionaries with bookmark data List of dictionaries with bookmark data
""" """
conn = sqlite3.connect(self.dbPath) with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT book_path, book_title, chapter_index, paragraph_index, SELECT book_path, book_title, chapter_index, paragraph_index,
sentence_index, last_accessed sentence_index, last_accessed
FROM bookmarks FROM bookmarks
ORDER BY last_accessed DESC ORDER BY last_accessed DESC
''') ''')
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close()
bookmarks = [] bookmarks = []
for row in rows: for row in rows:
@@ -213,23 +206,21 @@ class BookmarkManager:
bookId = self._get_book_id(bookPath) bookId = self._get_book_id(bookPath)
timestamp = datetime.now().isoformat() timestamp = datetime.now().isoformat()
conn = sqlite3.connect(self.dbPath)
cursor = conn.cursor()
try: try:
cursor.execute(''' with sqlite3.connect(self.dbPath) as conn:
INSERT INTO named_bookmarks cursor = conn.cursor()
(book_id, name, chapter_index, paragraph_index, audio_position, created_at)
VALUES (?, ?, ?, ?, ?, ?)
''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp))
conn.commit() cursor.execute('''
conn.close() 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 return True
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
# Bookmark with this name already exists # Bookmark with this name already exists
conn.close()
return False return False
def get_named_bookmarks(self, bookPath): def get_named_bookmarks(self, bookPath):
@@ -244,18 +235,17 @@ class BookmarkManager:
""" """
bookId = self._get_book_id(bookPath) bookId = self._get_book_id(bookPath)
conn = sqlite3.connect(self.dbPath) with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT id, name, chapter_index, paragraph_index, audio_position, created_at SELECT id, name, chapter_index, paragraph_index, audio_position, created_at
FROM named_bookmarks FROM named_bookmarks
WHERE book_id = ? WHERE book_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC
''', (bookId,)) ''', (bookId,))
rows = cursor.fetchall() rows = cursor.fetchall()
conn.close()
bookmarks = [] bookmarks = []
for row in rows: for row in rows:
@@ -277,13 +267,10 @@ class BookmarkManager:
Args: Args:
bookmarkId: Bookmark ID bookmarkId: Bookmark ID
""" """
conn = sqlite3.connect(self.dbPath) with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute('DELETE FROM named_bookmarks WHERE id = ?', (bookmarkId,))
cursor.execute('DELETE FROM named_bookmarks WHERE id = ?', (bookmarkId,)) conn.commit()
conn.commit()
conn.close()
def get_named_bookmark_by_id(self, bookmarkId): def get_named_bookmark_by_id(self, bookmarkId):
""" """
@@ -295,17 +282,16 @@ class BookmarkManager:
Returns: Returns:
Bookmark dictionary or None if not found Bookmark dictionary or None if not found
""" """
conn = sqlite3.connect(self.dbPath) with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT name, chapter_index, paragraph_index, audio_position SELECT name, chapter_index, paragraph_index, audio_position
FROM named_bookmarks FROM named_bookmarks
WHERE id = ? WHERE id = ?
''', (bookmarkId,)) ''', (bookmarkId,))
row = cursor.fetchone() row = cursor.fetchone()
conn.close()
if row: if row:
return { return {

View File

@@ -47,13 +47,18 @@ class DaisyParser:
# Detect DAISY version and parse accordingly # Detect DAISY version and parse accordingly
if (tempPath / "ncc.html").exists(): 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")): elif (tempPath / "navigation.ncx").exists() or list(tempPath.glob("*.ncx")):
return self._parse_daisy3(tempPath) book = self._parse_daisy3(tempPath)
else: else:
raise ValueError("Unknown DAISY format: no ncc.html or navigation.ncx found") raise ValueError("Unknown DAISY format: no ncc.html or navigation.ncx found")
# Cleanup before returning
self.cleanup()
return book
except Exception as e: except Exception as e:
# Ensure cleanup on error
self.cleanup() self.cleanup()
raise e raise e

View File

@@ -152,6 +152,7 @@ class FolderAudiobookParser:
try: try:
audioFile = self.mutagen.File(audioPath) audioFile = self.mutagen.File(audioPath)
if not audioFile: if not audioFile:
# File format not recognized by mutagen - use filename fallback
return metadata return metadata
# Get duration # Get duration
@@ -235,8 +236,10 @@ class FolderAudiobookParser:
except: except:
pass pass
except Exception as e: except Exception:
print(f"Warning: Could not read metadata from {audioPath.name}: {e}") # Silently fall back to filename-based metadata
# This is normal for files without tags or unsupported formats
pass
return metadata return metadata

View File

@@ -325,11 +325,19 @@ class MpvPlayer:
return False return False
try: 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 return True
except Exception as e: except Exception as e:
print(f"Error seeking audio: {e}") print(f"Error seeking audio to {position}s: {e}")
return False # Don't fail completely - just start from beginning
return True
def unload_audio_file(self): def unload_audio_file(self):
"""Unload the current audio file""" """Unload the current audio file"""
@@ -386,17 +394,41 @@ class MpvPlayer:
True if seek successful True if seek successful
""" """
if not self.isInitialized or not self.audioFileLoaded: if not self.isInitialized or not self.audioFileLoaded:
print(f"ERROR: Cannot seek - mpv not initialized or no audio loaded")
return False return False
if not self.playlist or index < 0 or index >= len(self.playlist): 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 return False
try: try:
# Set playlist position # Set playlist position
# pylint: disable=no-member # 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 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 self.currentPlaylistIndex = index
return True return True
except Exception as e: 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 return False

View File

@@ -15,6 +15,8 @@ try:
except ImportError: except ImportError:
HAS_SPEECHD = False HAS_SPEECHD = False
from .text_validator import is_valid_text
class SpeechEngine: class SpeechEngine:
"""Text-to-speech engine for UI accessibility using speech-dispatcher""" """Text-to-speech engine for UI accessibility using speech-dispatcher"""
@@ -61,7 +63,7 @@ class SpeechEngine:
text: Text to speak text: Text to speak
interrupt: If True, stop current speech first (default: True) 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 return
# Safety: Wait for previous UI speech thread to finish if still running # Safety: Wait for previous UI speech thread to finish if still running
@@ -104,8 +106,8 @@ class SpeechEngine:
print("ERROR: Speech-dispatcher not available") print("ERROR: Speech-dispatcher not available")
return return
if not text: if not is_valid_text(text):
print("ERROR: No text to speak") print("ERROR: No valid text to speak (empty or no alphanumeric characters)")
return return
try: try:
@@ -293,9 +295,5 @@ class SpeechEngine:
return self.isAvailable return self.isAvailable
def cleanup(self): def cleanup(self):
"""Close speech-dispatcher connection""" """Cleanup resources - alias for close()"""
if self.isAvailable and self.client: self.close()
try:
self.client.close()
except Exception:
pass

43
src/text_validator.py Normal file
View File

@@ -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

View File

@@ -11,11 +11,24 @@ import subprocess
import wave import wave
import io import io
import struct import struct
import shutil
from .text_validator import is_valid_text
class TtsEngine: class TtsEngine:
"""Text-to-speech engine using piper-tts""" """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"): def __init__(self, modelPath="/usr/share/piper-voices/en/en_US/hfc_male/medium/en_US-hfc_male-medium.onnx"):
""" """
Initialize TTS engine Initialize TTS engine
@@ -41,7 +54,7 @@ class TtsEngine:
Raises: Raises:
RuntimeError: If piper-tts fails RuntimeError: If piper-tts fails
""" """
if not text.strip(): if not is_valid_text(text):
return None return None
# Safety: Limit text size to prevent excessive memory usage # Safety: Limit text size to prevent excessive memory usage