Fixed bookmarking with audio books.
This commit is contained in:
452
bookstorm.py
452
bookstorm.py
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
43
src/text_validator.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user