From 4387a5cb56ef9a4b6eeaea2b3cd430950fff205e Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sun, 5 Oct 2025 20:19:16 -0400 Subject: [PATCH] Audiobookshelf support mostly working. --- .gitignore | 148 ++++++ bookstorm.py | 981 ++++++++++++++++++++++++++++++++--- src/audio_parser.py | 233 +++++++++ src/audiobookshelf_client.py | 712 +++++++++++++++++++++++++ src/audiobookshelf_menu.py | 579 +++++++++++++++++++++ src/bookmark_manager.py | 157 +++++- src/bookmarks_menu.py | 196 +++++++ src/config_manager.py | 87 ++++ src/options_menu.py | 100 ++++ src/pygame_player.py | 401 +++++++++++++- src/recent_books_menu.py | 106 ++++ src/server_link_manager.py | 131 +++++ src/speech_engine.py | 10 + src/ui.py | 205 ++++++++ 14 files changed, 3979 insertions(+), 67 deletions(-) create mode 100644 .gitignore create mode 100644 src/audio_parser.py create mode 100644 src/audiobookshelf_client.py create mode 100644 src/audiobookshelf_menu.py create mode 100644 src/bookmarks_menu.py create mode 100644 src/recent_books_menu.py create mode 100644 src/server_link_manager.py create mode 100644 src/ui.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b49a3f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,148 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# PEP 582 +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# BookStorm specific +# User data and caches (not tracked in git) +*.m4b +*.m4a +*.mp3 +*.epub +*.pdf +*.txt +*.zip +!requirements.txt + +# Config and user data directories (stored in ~/.config and ~/.bookstorm) +# These are not in the repo, but listing for clarity +# ~/.config/stormux/bookstorm/ +# ~/.bookstorm/ +# ~/.cache/bookstorm/ diff --git a/bookstorm.py b/bookstorm.py index 5b8fb6f..b60a99a 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -33,6 +33,7 @@ from src.daisy_parser import DaisyParser from src.epub_parser import EpubParser from src.pdf_parser import PdfParser from src.txt_parser import TxtParser +from src.audio_parser import AudioParser from src.bookmark_manager import BookmarkManager from src.tts_engine import TtsEngine from src.config_manager import ConfigManager @@ -42,6 +43,11 @@ from src.pygame_player import PygamePlayer from src.speech_engine import SpeechEngine from src.options_menu import OptionsMenu from src.sleep_timer_menu import SleepTimerMenu +from src.recent_books_menu import RecentBooksMenu +from src.audiobookshelf_client import AudiobookshelfClient +from src.audiobookshelf_menu import AudiobookshelfMenu +from src.server_link_manager import ServerLinkManager +from src.bookmarks_menu import BookmarksMenu class BookReader: @@ -52,10 +58,10 @@ class BookReader: Initialize book reader Args: - bookPath: Path to book file + bookPath: Path to book file (or None for server streaming) config: ConfigManager instance """ - self.bookPath = Path(bookPath) + self.bookPath = Path(bookPath) if bookPath else None self.book = None self.currentChapter = 0 self.currentParagraph = 0 @@ -90,12 +96,26 @@ class BookReader: booksDir = libraryDir else: booksDir = self.config.get_books_directory() - supportedFormats = ['.zip', '.epub', '.pdf', '.txt'] + supportedFormats = ['.zip', '.epub', '.pdf', '.txt', '.m4b', '.m4a', '.mp3'] self.bookSelector = BookSelector(booksDir, supportedFormats, self.speechEngine) # Initialize sleep timer menu self.sleepTimerMenu = SleepTimerMenu(self.speechEngine) + # Initialize recent books menu + self.recentBooksMenu = RecentBooksMenu(self.bookmarkManager, self.speechEngine) + + # Initialize bookmarks menu + self.bookmarksMenu = BookmarksMenu(self.bookmarkManager, self.speechEngine) + + # Initialize Audiobookshelf client and menu (lazy init - only create when accessed) + self.absClient = None + self.absMenu = None + self.serverLinkManager = ServerLinkManager() + self.serverBook = None # Server book metadata for streaming + self.isStreaming = False # Track if currently streaming + self.sessionId = None # Active listening session ID + # Initialize reading engine based on config readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': @@ -131,6 +151,9 @@ class BookReader: self.cancelBuffer = False self.bufferLock = threading.Lock() + # Audio bookmark state + self.savedAudioPosition = 0.0 # Saved audio position for resume + def load_book(self): """Load and parse the book""" message = f"Loading book {self.bookPath.stem}" @@ -152,20 +175,47 @@ class BookReader: elif suffix in ['.txt']: self.parser = TxtParser() self.book = self.parser.parse(self.bookPath) + elif suffix in ['.m4b', '.m4a', '.mp3']: + # Audio book file + self.parser = AudioParser() + self.book = self.parser.parse(self.bookPath) else: raise ValueError(f"Unsupported book format: {self.bookPath.suffix}") print(f"Loaded: {self.book.title}") print(f"Chapters: {self.book.get_total_chapters()}") + # If it's an audio book, load it into the player + if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: + if not self.audioPlayer.load_audio_file(self.book.audioPath): + raise Exception("Failed to load audio file") + + # Inform user about navigation capabilities + if self.book.get_total_chapters() == 1: + print("\nNote: This audio file has no chapter markers.") + print("Navigation: Only play/pause/stop supported (no chapter jumping)") + self.speechEngine.speak("Audio book loaded. No chapter markers found. Only basic playback controls available.") + else: + print(f"\nChapter navigation: Enabled ({self.book.get_total_chapters()} chapters)") + self.speechEngine.speak(f"Audio book loaded with {self.book.get_total_chapters()} chapters. Chapter navigation enabled.") + # Load bookmark if exists (but don't announce it) bookmark = self.bookmarkManager.get_bookmark(self.bookPath) if bookmark: self.currentChapter = bookmark['chapterIndex'] self.currentParagraph = bookmark['paragraphIndex'] - print(f"Resuming from chapter {self.currentChapter + 1}, paragraph {self.currentParagraph + 1}") + self.savedAudioPosition = bookmark.get('audioPosition', 0.0) + + # For audio books, show resume position + if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook and self.savedAudioPosition > 0: + minutes = int(self.savedAudioPosition // 60) + seconds = int(self.savedAudioPosition % 60) + print(f"Resuming from chapter {self.currentChapter + 1} at {minutes}m {seconds}s") + else: + print(f"Resuming from chapter {self.currentChapter + 1}, paragraph {self.currentParagraph + 1}") else: print("Starting from beginning") + self.savedAudioPosition = 0.0 def read_current_paragraph(self): """Read the current paragraph aloud""" @@ -246,15 +296,86 @@ class BookReader: Args: speakFeedback: Whether to speak "Bookmark saved" (default True) """ + # Don't save if no book is loaded + if not self.book: + return + + # For audio books, calculate current playback position + audioPosition = 0.0 + if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: + # Get current chapter start time + chapter = self.book.get_chapter(self.currentChapter) + if chapter and hasattr(chapter, 'startTime'): + chapterStartTime = chapter.startTime + else: + chapterStartTime = 0.0 + + # Get playback position within the audio file + if self.audioPlayer.is_audio_file_playing() or self.audioPlayer.is_paused(): + playbackPos = self.audioPlayer.get_audio_position() + # Total position = chapter start + position within current playback + audioPosition = chapterStartTime + playbackPos + self.bookmarkManager.save_bookmark( self.bookPath, self.book.title, self.currentChapter, - self.currentParagraph + self.currentParagraph, + audioPosition=audioPosition ) + + # Sync progress to server if streaming or server-linked + self._sync_progress_to_server(audioPosition) + if speakFeedback: self.speechEngine.speak("Bookmark saved") + def _sync_progress_to_server(self, audioPosition=0.0): + """ + Sync progress to Audiobookshelf server + + Args: + audioPosition: Current audio position in seconds + """ + # Only sync if we have an active ABS client + if not self.absClient or not self.absClient.is_authenticated(): + return + + # Check if this is a streaming book or server-linked book + serverId = None + + if self.serverBook: + # Streaming book + serverId = self.serverBook.get('id') + else: + # Check if local book is linked to server + serverLink = self.serverLinkManager.get_link(str(self.bookPath)) + if serverLink: + serverId = serverLink.get('server_id') + + if not serverId: + return + + # Calculate progress + duration = 0.0 + if hasattr(self.book, 'totalDuration'): + duration = self.book.totalDuration + + if duration <= 0: + return + + currentTime = audioPosition + progress = min(currentTime / duration, 1.0) if duration > 0 else 0.0 + + # Upload progress to server + success = self.absClient.update_progress(serverId, currentTime, duration, progress) + if success: + print(f"Progress synced to server: {int(progress * 100)}%") + + # Also sync session if active + if self.sessionId: + self.absClient.sync_session(self.sessionId, currentTime, duration, progress) + def reload_tts_engine(self): """Reload TTS engine with current config settings""" readerEngine = self.config.get_reader_engine() @@ -304,7 +425,12 @@ class BookReader: # Initialize pygame display with larger window for large print text pygame.init() self.screen = pygame.display.set_mode((1600, 900)) - pygame.display.set_caption(f"BookStorm - {self.book.title}") + + # Set caption - handle case where no book is loaded yet + if self.book: + pygame.display.set_caption(f"BookStorm - {self.book.title}") + else: + pygame.display.set_caption("BookStorm - Press A/B/R to load book") # Initialize font for large print display (72pt for severe visual impairment) self.font = pygame.font.Font(None, 96) # 96 pixels ≈ 72pt @@ -316,20 +442,24 @@ class BookReader: self.statusColor = (180, 180, 180) # Gray for status # Current display text - self.displayText = "Press SPACE to start reading" - self.statusText = f"Book: {self.book.title}" + if self.book: + self.displayText = "Press SPACE to start reading" + self.statusText = f"Book: {self.book.title}" + print(f"\n{self.book.title} - {self.book.get_total_chapters()} chapters") + print("Press SPACE to start reading") + self.speechEngine.speak("BookStorm ready. Press SPACE to start reading. Press i for info. Press h for help.") + else: + self.displayText = "No book loaded" + self.statusText = "Press A for Audiobookshelf, B for local books, R for recent books" + print("\nNo book loaded") + print("Press A for Audiobookshelf, B for local books, R for recent books") + # Speech message already given earlier # Cached rendered surfaces to prevent memory leak from re-rendering 30 FPS self.cachedDisplayText = None self.cachedStatusText = None self.cachedSurfaces = [] - print(f"\n{self.book.title} - {self.book.get_total_chapters()} chapters") - print("Press SPACE to start reading") - - # Speak controls for accessibility - self.speechEngine.speak("BookStorm ready. Press SPACE to start reading. Press i for info. Press h for help.") - self._run_pygame_loop() def _render_screen(self): @@ -421,9 +551,11 @@ class BookReader: # Don't auto-advance if in any menu inAnyMenu = (self.optionsMenu.is_in_menu() or self.bookSelector.is_in_browser() or - self.sleepTimerMenu.is_in_menu()) + self.sleepTimerMenu.is_in_menu() or + self.recentBooksMenu.is_in_menu() or + (self.absMenu and self.absMenu.is_in_menu())) - if self.isPlaying and not inAnyMenu: + if self.isPlaying and not inAnyMenu and self.book: if not self.next_paragraph(): self.displayText = "End of book reached" self.isPlaying = False @@ -441,32 +573,47 @@ class BookReader: self.isRunning = False self.isPlaying = False - # Check if we need to advance to next paragraph (piper-tts only) + # Check if we need to advance to next paragraph/chapter # Speech-dispatcher uses callbacks for auto-advance + isAudioBook = self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook readerEngine = self.config.get_reader_engine() # Don't auto-advance if in any menu inAnyMenu = (self.optionsMenu.is_in_menu() or self.bookSelector.is_in_browser() or - self.sleepTimerMenu.is_in_menu()) + self.sleepTimerMenu.is_in_menu() or + self.recentBooksMenu.is_in_menu() or + (self.absMenu and self.absMenu.is_in_menu())) - if self.isPlaying and readerEngine == 'piper' and not inAnyMenu: - # Check piper-tts / pygame player state - playbackFinished = not self.audioPlayer.is_playing() and not self.audioPlayer.is_paused() - - if playbackFinished: - # Current paragraph finished, advance - if not self.next_paragraph(): - self.displayText = "End of book reached" - self.isPlaying = False - self.save_bookmark(speakFeedback=False) - else: - # Start next paragraph with error recovery - try: - self._start_paragraph_playback() - except Exception as e: - print(f"Error starting playback: {e}") - self.speechEngine.speak("Playback error") + if self.isPlaying and not inAnyMenu and self.book: + if isAudioBook: + # Check if audio file playback finished + if not self.audioPlayer.is_audio_file_playing() and not self.audioPlayer.is_paused(): + # Audio chapter finished, advance to next chapter + if not self.next_chapter(): + self.displayText = "End of book reached" self.isPlaying = False + self.save_bookmark(speakFeedback=False) + else: + # Start next chapter + self._start_paragraph_playback() + elif readerEngine == 'piper': + # Check piper-tts / pygame player state + playbackFinished = not self.audioPlayer.is_playing() and not self.audioPlayer.is_paused() + + if playbackFinished: + # Current paragraph finished, advance + if not self.next_paragraph(): + self.displayText = "End of book reached" + self.isPlaying = False + self.save_bookmark(speakFeedback=False) + else: + # Start next paragraph with error recovery + try: + self._start_paragraph_playback() + except Exception as e: + print(f"Error starting playback: {e}") + self.speechEngine.speak("Playback error") + self.isPlaying = False # Render the screen self._render_screen() @@ -504,18 +651,50 @@ class BookReader: except KeyboardInterrupt: print("\n\nInterrupted") finally: + # Stop playback readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': self.readingEngine.cancel_reading() else: self.audioPlayer.stop() + + # Save bookmark self.save_bookmark(speakFeedback=False) - # Clear cached surfaces before quitting + + # Close Audiobookshelf session if active + if self.sessionId and self.absClient: + try: + self.absClient.close_session(self.sessionId) + except: + pass + + # Clean up speech engines + if self.speechEngine: + self.speechEngine.close() + if readerEngine == 'speechd' and self.readingEngine: + self.readingEngine.close() + + # Clear pygame resources self.cachedSurfaces.clear() pygame.quit() def _handle_pygame_key(self, event): """Handle pygame key event""" + # Check if in Audiobookshelf menu + if self.absMenu and self.absMenu.is_in_menu(): + self._handle_audiobookshelf_key(event) + return + + # Check if in recent books menu + if self.recentBooksMenu.is_in_menu(): + self._handle_recent_books_key(event) + return + + # Check if in bookmarks menu + if self.bookmarksMenu.is_in_menu(): + self._handle_bookmarks_key(event) + return + # Check if in book browser if self.bookSelector.is_in_browser(): self._handle_browser_key(event) @@ -536,7 +715,12 @@ class BookReader: shiftPressed = mods & pygame.KMOD_SHIFT if event.key == pygame.K_SPACE: - # Toggle play/pause + # Toggle play/pause (only if book is loaded) + if not self.book: + self.speechEngine.speak("No book loaded. Press A for Audiobookshelf, B for local books, or R for recent books.") + return + + isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook readerEngine = self.config.get_reader_engine() if not self.isPlaying: @@ -546,7 +730,15 @@ class BookReader: self._start_paragraph_playback() else: # Toggle pause/resume - if readerEngine == 'speechd': + if isAudioBook: + # Handle audio book pause/resume + if self.audioPlayer.is_paused(): + self.speechEngine.speak("Resuming") + self.audioPlayer.resume_audio_file() + else: + self.speechEngine.speak("Paused") + self.audioPlayer.pause_audio_file() + elif readerEngine == 'speechd': # Handle speech-dispatcher pause/resume if self.readingEngine.is_reading_paused(): self.speechEngine.speak("Resuming") @@ -564,8 +756,14 @@ class BookReader: self.audioPlayer.pause() elif event.key == pygame.K_n: - if shiftPressed: - # Next chapter + if not self.book: + self.speechEngine.speak("No book loaded") + return + + isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook + + if shiftPressed or isAudioBook: + # Next chapter (or for audio books, always go to next chapter) self._stop_playback() if self.next_chapter(): chapter = self.book.get_chapter(self.currentChapter) @@ -576,7 +774,7 @@ class BookReader: self.speechEngine.speak("No next chapter") self.isPlaying = False else: - # Next paragraph + # Next paragraph (text books only) self._stop_playback() if self.next_paragraph(): self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}") @@ -587,8 +785,14 @@ class BookReader: self.isPlaying = False elif event.key == pygame.K_p: - if shiftPressed: - # Previous chapter + if not self.book: + self.speechEngine.speak("No book loaded") + return + + isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook + + if shiftPressed or isAudioBook: + # Previous chapter (or for audio books, always go to previous chapter) self._stop_playback() if self.previous_chapter(): chapter = self.book.get_chapter(self.currentChapter) @@ -598,7 +802,7 @@ class BookReader: else: self.speechEngine.speak("No previous chapter") else: - # Previous paragraph + # Previous paragraph (text books only) self._stop_playback() if self.previous_paragraph(): self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}") @@ -608,6 +812,10 @@ class BookReader: self.speechEngine.speak("Beginning of book") elif event.key == pygame.K_s: + if not self.book: + self.speechEngine.speak("No book loaded") + return + readerEngine = self.config.get_reader_engine() # Pause playback while saving @@ -662,15 +870,34 @@ class BookReader: self.bookSelector.reset_to_directory(libraryDir) self.bookSelector.enter_browser() + elif event.key == pygame.K_r: + # Open recent books menu + self.recentBooksMenu.enter_menu() + + elif event.key == pygame.K_k: + # Open bookmarks menu + if self.book: + self.bookmarksMenu.enter_menu(str(self.bookPath)) + else: + self.speechEngine.speak("No book loaded") + + elif event.key == pygame.K_a: + # Open Audiobookshelf browser + self._open_audiobookshelf_browser() + elif event.key == pygame.K_o: # Open options menu self.optionsMenu.enter_menu() elif event.key == pygame.K_h: # Help - self.speechEngine.speak("SPACE: play pause. n: next paragraph. p: previous paragraph. Shift N: next chapter. Shift P: previous chapter. s: save bookmark. b: browse books. o: options menu. i: current info. Page Up Down: adjust speech rate. t: time remaining. h: help. q: quit or sleep timer") + 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. t: time remaining. h: help. q: quit or sleep timer") elif event.key == pygame.K_i: + if not self.book: + self.speechEngine.speak("No book loaded. Press H for help.") + return + # Speak current position info chapter = self.book.get_chapter(self.currentChapter) if chapter: @@ -694,6 +921,240 @@ class BookReader: # Open sleep timer menu self.sleepTimerMenu.enter_menu() + def _handle_recent_books_key(self, event): + """Handle key events when in recent books menu""" + if event.key == pygame.K_UP: + self.recentBooksMenu.navigate_menu('up') + elif event.key == pygame.K_DOWN: + self.recentBooksMenu.navigate_menu('down') + elif event.key == pygame.K_RETURN: + # Select book + selectedBook = self.recentBooksMenu.activate_current_item() + if selectedBook: + # Book was selected, load it + self.recentBooksMenu.exit_menu() + self._load_new_book(selectedBook) + elif event.key == pygame.K_ESCAPE: + self.recentBooksMenu.exit_menu() + + def _handle_bookmarks_key(self, event): + """Handle key events when in bookmarks menu""" + if event.key == pygame.K_UP: + self.bookmarksMenu.navigate_menu('up') + elif event.key == pygame.K_DOWN: + self.bookmarksMenu.navigate_menu('down') + elif event.key == pygame.K_RETURN: + # Activate current item + result = self.bookmarksMenu.activate_current_item() + if result: + action = result.get('action') + + if action == 'jump': + # Jump to bookmark + bookmark = result.get('bookmark') + self._jump_to_bookmark(bookmark) + self.bookmarksMenu.exit_menu() + + elif action == 'create': + # Create new bookmark + self._create_named_bookmark() + + elif event.key == pygame.K_DELETE or event.key == pygame.K_d: + # Delete current bookmark + self.bookmarksMenu.delete_current_bookmark() + + elif event.key == pygame.K_ESCAPE: + self.bookmarksMenu.exit_menu() + + def _jump_to_bookmark(self, bookmark): + """ + Jump to a named bookmark + + Args: + bookmark: Bookmark dictionary + """ + chapterIndex = bookmark['chapterIndex'] + paragraphIndex = bookmark['paragraphIndex'] + audioPosition = bookmark.get('audioPosition', 0.0) + bookmarkName = bookmark['name'] + + # Stop current playback + self.isPlaying = False + if self.ttsEngine: + self.audioPlayer.stop() + else: + self.readingEngine.stop() + self.audioPlayer.stop_audio_file() + + # Update position + self.currentChapter = chapterIndex + self.currentParagraph = paragraphIndex + + # For audio books, seek to audio position + if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: + # Find chapter that contains this audio position + for i, chapter in enumerate(self.book.chapters): + if hasattr(chapter, 'startTime'): + chapterEnd = chapter.startTime + chapter.duration + if chapter.startTime <= audioPosition < chapterEnd: + self.currentChapter = i + # Position within chapter + positionInChapter = audioPosition - chapter.startTime + # Seek to position + if self.audioPlayer.is_audio_file_loaded(): + self.audioPlayer.seek_audio_position(audioPosition) + break + + # Speak feedback + if self.speechEngine: + chapter = self.book.get_chapter(self.currentChapter) + chapterTitle = chapter.title if chapter else "Unknown" + self.speechEngine.speak(f"Jumped to bookmark: {bookmarkName}. Chapter: {chapterTitle}") + + # Update display + if self.config.get_show_text(): + self._render_screen() + + def _create_named_bookmark(self): + """Create a new named bookmark""" + import getpass + + if self.speechEngine: + self.speechEngine.speak("Enter bookmark name. Check terminal.") + + print("\n=== Create Bookmark ===") + bookmarkName = input("Bookmark name: ").strip() + + if not bookmarkName: + if self.speechEngine: + self.speechEngine.speak("Cancelled") + return + + # Calculate audio position if audio book + audioPosition = 0.0 + if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: + chapter = self.book.get_chapter(self.currentChapter) + if chapter and hasattr(chapter, 'startTime'): + chapterStartTime = chapter.startTime + else: + chapterStartTime = 0.0 + + if self.audioPlayer.is_audio_file_playing() or self.audioPlayer.is_paused(): + playbackPos = self.audioPlayer.get_audio_position() + audioPosition = chapterStartTime + playbackPos + + # Create bookmark + success = self.bookmarkManager.create_named_bookmark( + self.bookPath, + bookmarkName, + self.currentChapter, + self.currentParagraph, + audioPosition=audioPosition + ) + + if success: + if self.speechEngine: + self.speechEngine.speak(f"Bookmark created: {bookmarkName}") + print(f"Bookmark '{bookmarkName}' created!") + + # Reload bookmarks in menu + self.bookmarksMenu._load_bookmarks() + self.bookmarksMenu._speak_current_item() + else: + if self.speechEngine: + self.speechEngine.speak(f"Bookmark name already exists: {bookmarkName}") + print(f"ERROR: Bookmark with name '{bookmarkName}' already exists") + + def _open_audiobookshelf_browser(self): + """Open Audiobookshelf browser""" + # Check if server is configured + if not self.config.is_abs_configured(): + self.speechEngine.speak("Audiobookshelf not configured. Please set server URL and username in config.") + print("\nAudiobookshelf not configured.") + print("Edit ~/.config/stormux/bookstorm/settings.ini and add:") + print("[Audiobookshelf]") + print("server_url = https://your-server.com") + print("username = your-username") + print("\nThen login with password when prompted.") + return + + # Initialize client if needed + if not self.absClient: + serverUrl = self.config.get_abs_server_url() + self.absClient = AudiobookshelfClient(serverUrl, self.config) + + # Check if already authenticated + if not self.absClient.is_authenticated(): + # Need to login + self.speechEngine.speak("Audiobookshelf login required. Check terminal for password prompt.") + print("\n=== Audiobookshelf Login ===") + username = self.config.get_abs_username() + print(f"Username: {username}") + + # Get password from user + import getpass + password = getpass.getpass("Password: ") + + if not self.absClient.login(username, password): + self.speechEngine.speak("Login failed. Check username and password.") + return + + self.speechEngine.speak("Login successful") + + # Test connection + if not self.absClient.test_connection(): + self.speechEngine.speak("Connection test failed. Check server URL.") + return + + # Initialize menu if needed + if not self.absMenu: + self.absMenu = AudiobookshelfMenu(self.absClient, self.config, self.speechEngine) + + # Open browser + self.absMenu.enter_menu() + + def _handle_audiobookshelf_key(self, event): + """Handle key events when in Audiobookshelf menu""" + if event.key == pygame.K_UP: + self.absMenu.navigate_menu('up') + elif event.key == pygame.K_DOWN: + self.absMenu.navigate_menu('down') + elif event.key == pygame.K_LEFT: + self.absMenu.change_view('left') + elif event.key == pygame.K_RIGHT: + self.absMenu.change_view('right') + elif event.key == pygame.K_RETURN: + # Select item (library or book) + result = self.absMenu.activate_current_item() + if result: + action = result.get('action') + if action == 'open_local': + # Open local copy of book + localPath = result.get('path') + if localPath: + if self.absMenu: + self.absMenu.exit_menu() + self._load_new_book(localPath) + + elif action == 'stream': + # Stream from server + serverBook = result.get('serverBook') + if serverBook: + self._stream_audiobook(serverBook) + + elif action == 'download': + # Download book to library + serverBook = result.get('serverBook') + if serverBook: + self._download_audiobook(serverBook) + elif event.key == pygame.K_BACKSPACE: + # Go back + if self.absMenu: + self.absMenu.go_back() + elif event.key == pygame.K_ESCAPE: + if self.absMenu: + self.absMenu.exit_menu() + def _handle_browser_key(self, event): """Handle key events when in book browser""" if event.key == pygame.K_UP: @@ -784,6 +1245,257 @@ class BookReader: self.speechEngine.speak("Cancelled") self.sleepTimerMenu.exit_menu() + def _stream_audiobook(self, serverBook): + """ + Stream audiobook from Audiobookshelf server + + Args: + serverBook: Server book dictionary from API + """ + # Extract book metadata + # Try different ID fields (structure varies by API endpoint) + serverId = serverBook.get('id') or serverBook.get('libraryItemId') + + if not serverId: + self.speechEngine.speak("Error: Book ID not found") + print(f"\nERROR: No valid ID found in book metadata") + print(f"Available keys: {list(serverBook.keys())}") + return + + media = serverBook.get('media', {}) + metadata = media.get('metadata', {}) + title = metadata.get('title', 'Unknown') + author = metadata.get('authorName', '') + duration = media.get('duration', 0.0) + + print(f"\nDEBUG: Streaming book ID: {serverId}") + print(f"DEBUG: Title from metadata: {title}") + + # Get streaming URL (pass full book details to avoid re-fetching) + self.speechEngine.speak(f"Loading stream for {title}. Please wait.") + print(f"\nGetting stream URL for: {title}") + + streamUrl = self.absClient.get_stream_url(serverId, itemDetails=serverBook) + if not streamUrl: + self.speechEngine.speak("Failed to get stream URL. Check terminal for errors.") + print("\nERROR: Could not get stream URL") + print(f"Book structure keys: {list(serverBook.keys())}") + if 'media' in serverBook: + print(f"Media keys: {list(serverBook['media'].keys())}") + return + + # Get chapters from server (from the book details we already have) + serverChapters = media.get('chapters', []) + + # Create AudioBook object with stream URL + from src.audio_parser import AudioBook, AudioChapter + book = AudioBook(title=title, author=author, audioPath=streamUrl) + book.totalDuration = duration + + # Add chapters from server + if serverChapters: + for chapterData in serverChapters: + chapterTitle = chapterData.get('title', 'Untitled') + startTime = chapterData.get('start', 0.0) + chapterDuration = chapterData.get('end', 0.0) - startTime + + chapter = AudioChapter( + title=chapterTitle, + startTime=startTime, + duration=chapterDuration + ) + book.add_chapter(chapter) + else: + # No chapters - treat entire book as single chapter + chapter = AudioChapter( + title=title, + startTime=0.0, + duration=duration + ) + book.add_chapter(chapter) + + # Store server metadata for progress sync + self.serverBook = serverBook + self.isStreaming = True + + # Load the book + self.book = book + self.bookPath = streamUrl # Store URL as path for tracking + + # Save server book reference for resume on restart + # Use special format: abs://{server_id} so we can detect and resume + self.config.set_last_book(f"abs://{serverId}") + print(f"DEBUG: Saved last_book as: abs://{serverId}") + + # Create listening session + self.sessionId = self.absClient.create_session(serverId) + if self.sessionId: + print(f"Created listening session: {self.sessionId}") + + # Try to load progress from server + serverProgress = self.absClient.get_progress(serverId) + if serverProgress: + progressTime = serverProgress.get('currentTime', 0.0) + print(f"Resuming from server progress: {int(progressTime)}s") + + # Find chapter that contains this time + for i, chap in enumerate(book.chapters): + if hasattr(chap, 'startTime'): + chapterEnd = chap.startTime + chap.duration + if chap.startTime <= progressTime < chapterEnd: + self.currentChapter = i + break + else: + # No server progress, start from beginning + self.currentChapter = 0 + + # Initialize position + self.currentParagraph = 0 + + # Load stream URL directly - pygame_player will handle caching via ffmpeg + print(f"Loading stream: {streamUrl[:80]}...") + self.speechEngine.speak("Loading stream. This may take a moment.") + + # Load the stream URL - pygame_player will cache it using ffmpeg + # Pass auth token so ffmpeg can authenticate + if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken): + self.speechEngine.speak("Failed to load stream. Check terminal for errors.") + print("\nERROR: Failed to load stream from server") + print("Make sure ffmpeg is installed: sudo pacman -S ffmpeg") + return + + # Success! Start playing + self.speechEngine.speak(f"Now streaming {title}. Press space to pause.") + print(f"\nNow streaming: {title} by {author}") + print(f"Chapters: {len(book.chapters)}") + + # Exit menu (if we came from menu - not needed when resuming from startup) + if self.absMenu: + self.absMenu.exit_menu() + + # Update UI if enabled + if self.config.get_show_text(): + self._render_screen() + + # Start playback + self.audioPlayer.play_audio_file() + self.isPlaying = True + self.isAudioBook = True + + def _download_audiobook(self, serverBook): + """ + Download audiobook from Audiobookshelf server + + Args: + serverBook: Server book dictionary from API + """ + # Check library directory is set + libraryDir = self.config.get_library_directory() + if not libraryDir: + self.speechEngine.speak("Library directory not set. Please set library directory first. Press B then L.") + print("\nERROR: Library directory not set.") + print("Press 'b' to browse files, then 'L' to set library directory.") + return + + libraryPath = Path(libraryDir) + if not libraryPath.exists(): + self.speechEngine.speak("Library directory does not exist.") + print(f"\nERROR: Library directory does not exist: {libraryDir}") + return + + # Extract book metadata + # Try different ID fields (structure varies by API endpoint) + serverId = serverBook.get('id') or serverBook.get('libraryItemId') + + if not serverId: + self.speechEngine.speak("Error: Book ID not found") + print(f"\nERROR: No valid ID found in book metadata") + print(f"Available keys: {list(serverBook.keys())}") + return + + media = serverBook.get('media', {}) + metadata = media.get('metadata', {}) + title = metadata.get('title', 'Unknown') + author = metadata.get('authorName', '') + duration = media.get('duration', 0.0) + numChapters = media.get('numChapters', 0) + + print(f"\nDEBUG: Downloading book ID: {serverId}") + print(f"DEBUG: Title from metadata: {title}") + + # Create sanitized filename + safeTitle = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip() + safeTitle = safeTitle.replace(' ', '_') + + # Determine file extension from server + audioFiles = media.get('audioFiles', []) + if audioFiles: + firstFile = audioFiles[0] + fileMetadata = firstFile.get('metadata', {}) + fileName = fileMetadata.get('filename', '') + fileExt = Path(fileName).suffix if fileName else '.m4b' + else: + fileExt = '.m4b' + + outputPath = libraryPath / f"{safeTitle}{fileExt}" + + # Check if file already exists + if outputPath.exists(): + self.speechEngine.speak("Book already downloaded.") + print(f"\nBook already exists: {outputPath}") + # Open it anyway + if self.absMenu: + self.absMenu.exit_menu() + self._load_new_book(str(outputPath)) + return + + # Notify user + self.speechEngine.speak(f"Downloading {title}. This may take several minutes.") + print(f"\nDownloading: {title}") + print(f"Output: {outputPath}") + print("Please wait...") + + # Progress callback + lastPercent = 0 + def progress_callback(percent): + nonlocal lastPercent + # Only update every 10% + if percent >= lastPercent + 10: + print(f"Progress: {percent}%") + lastPercent = percent + + # Download file + success = self.absClient.download_audio_file(serverId, str(outputPath), progress_callback) + + if not success: + self.speechEngine.speak("Download failed. Check terminal for errors.") + print("\nDownload failed!") + return + + # Create server link + serverUrl = self.config.get_abs_server_url() + libraryId = serverBook.get('libraryId', '') + + self.serverLinkManager.create_link( + bookPath=str(outputPath), + serverUrl=serverUrl, + serverId=serverId, + libraryId=libraryId, + title=title, + author=author, + duration=duration, + chapters=numChapters + ) + + # Success! + self.speechEngine.speak(f"Download complete. Opening {title}.") + print(f"\nDownload complete! Opening book...") + + # Exit menu and open the book + if self.absMenu: + self.absMenu.exit_menu() + self._load_new_book(str(outputPath)) + def _load_new_book(self, bookPath): """ Load a new book from file path @@ -819,10 +1531,18 @@ class BookReader: self.speechEngine.speak(message) def _stop_playback(self): - """Stop current playback (both piper-tts and speech-dispatcher)""" + """Stop current playback (audio books, piper-tts, and speech-dispatcher)""" + # Handle case where no book is loaded + if not self.book: + return + + isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook readerEngine = self.config.get_reader_engine() - if readerEngine == 'speechd': + if isAudioBook: + # Stop audio file playback + self.audioPlayer.stop_audio_file() + elif readerEngine == 'speechd': # Cancel speech-dispatcher reading self.readingEngine.cancel_reading() else: @@ -837,6 +1557,12 @@ class BookReader: print("ERROR: No chapter found!") return + # Check if this is an audio book + if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: + # Audio book playback + self._start_audio_chapter_playback(chapter) + return + paragraph = chapter.get_paragraph(self.currentParagraph) if not paragraph: print("ERROR: No paragraph found!") @@ -896,6 +1622,35 @@ class BookReader: if wavData is not None: del wavData + def _start_audio_chapter_playback(self, chapter): + """Start playing audio book chapter""" + # Update display text and status + self.displayText = f"Playing: {chapter.title}" + self.statusText = f"Ch {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title}" + + # Determine start position + # If we have a saved audio position and we're on the saved chapter, use it + if self.savedAudioPosition > 0.0: + startTime = self.savedAudioPosition + # Clear saved position so we don't use it again (only for initial resume) + self.savedAudioPosition = 0.0 + minutes = int(startTime // 60) + seconds = int(startTime % 60) + print(f"Resuming playback at {minutes}m {seconds}s") + else: + # Get start time from audio chapter + if hasattr(chapter, 'startTime'): + startTime = chapter.startTime + else: + startTime = 0.0 + + # Seek to position and play + if self.audioPlayer.audioFileLoaded: + self.audioPlayer.play_audio_file(startPosition=startTime) + else: + print("ERROR: Audio file not loaded!") + self.isPlaying = False + def _buffer_next_paragraph(self): """Start buffering next paragraph in background thread""" # Only for piper-tts (speech-dispatcher handles buffering internally) @@ -996,6 +1751,12 @@ class BookReader: def cleanup(self): """Cleanup resources""" + # Close active listening session if any + if self.sessionId and self.absClient: + print(f"Closing listening session: {self.sessionId}") + self.absClient.close_session(self.sessionId) + self.sessionId = None + self._cancel_buffer() self.audioPlayer.cleanup() self.speechEngine.cleanup() @@ -1046,25 +1807,125 @@ def main(): else: # Try to use last book lastBook = config.get_last_book() - if lastBook and Path(lastBook).exists(): + + # Check if last book was an Audiobookshelf stream + if lastBook and lastBook.startswith('abs://'): + # Extract server book ID + serverId = lastBook[6:] # Remove 'abs://' prefix + print(f"Resuming Audiobookshelf book: {serverId}") + + # Try to reconnect and stream + print(f"Last book was an Audiobookshelf stream") + + # Start BookStorm even if stream fails - user can browse/select + bookPathFallback = None # Will trigger interactive mode if stream fails + + if config.is_abs_configured(): + try: + # Initialize Audiobookshelf client + from src.audiobookshelf_client import AudiobookshelfClient + serverUrl = config.get_abs_server_url() + absClient = AudiobookshelfClient(serverUrl, config) + + if absClient.is_authenticated() and absClient.test_connection(): + # Get book details + print(f"Fetching book details from server...") + bookDetails = absClient.get_library_item_details(serverId) + + if bookDetails: + # Successfully got book details - try to stream + print(f"Found book on server, preparing to stream...") + try: + from src.speech_engine import SpeechEngine + speechEngine = SpeechEngine() + + reader = BookReader(None, config) + reader.absClient = absClient + reader._stream_audiobook(bookDetails) + reader.run_interactive() + return 0 + + except Exception as e: + print(f"Error resuming stream: {e}") + print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books") + else: + print(f"Book not found on server (may have been deleted)") + print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books") + else: + print("Cannot connect to Audiobookshelf server or session expired") + print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") + + except Exception as e: + print(f"Error connecting to Audiobookshelf: {e}") + print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") + else: + print("Audiobookshelf not configured") + print("Opening BookStorm anyway - use 'o' to configure server or 'b' for local books") + + # Fall through to open BookStorm in interactive mode + # Clear last_book so we don't loop on this error + config.set_last_book('') + + # Open BookStorm without a book - user can browse + print("\nStarting BookStorm in interactive mode...") + print("Press 'a' for Audiobookshelf, 'b' for local books, 'r' for recent books") + + try: + from src.speech_engine import SpeechEngine + speechEngine = SpeechEngine() + speechEngine.speak("Could not resume stream. Press A for Audiobookshelf, B for local books, or R for recent books.") + + # Create a minimal book to satisfy BookReader initialization + # We'll use a dummy book that tells user what to do + reader = BookReader(None, config) + reader.book = None # No book loaded yet + reader.run_interactive() # User can use menus to load a book + return 0 + + except Exception as e: + print(f"Error starting BookStorm: {e}") + import traceback + traceback.print_exc() + return 1 + + elif lastBook and Path(lastBook).exists(): bookPath = lastBook else: - # No book available + # No book available - open in interactive mode print("BookStorm - Accessible Book Reader") - print("\nUsage:") - print(" python bookstorm.py # Read EPUB book") - print(" python bookstorm.py # Read PDF book") - print(" python bookstorm.py # Read TXT book") - print(" python bookstorm.py # Read DAISY book") - print(" python bookstorm.py # Resume last book") - print(" python bookstorm.py book.epub --wav # Export to WAV files") - print("\nPress 'o' in the reader to access options menu") + if lastBook: print(f"\nNote: Last book no longer exists: {lastBook}") - return 1 + else: + print("\nNo previous book found") - # Check if book exists - if not Path(bookPath).exists(): + print("Starting in interactive mode...") + print("Press 'a' for Audiobookshelf, 'b' for local books, 'r' for recent books\n") + + try: + from src.speech_engine import SpeechEngine + speechEngine = SpeechEngine() + speechEngine.speak("BookStorm ready. Press A for Audiobookshelf, B for local books, or R for recent books.") + + reader = BookReader(None, config) + reader.book = None + reader.run_interactive() + return 0 + + except Exception as e: + print(f"Error starting BookStorm: {e}") + import traceback + traceback.print_exc() + print("\nUsage:") + print(" python bookstorm.py # Read EPUB book") + print(" python bookstorm.py # Read PDF book") + print(" python bookstorm.py # Read TXT book") + print(" python bookstorm.py # Read DAISY book") + print(" python bookstorm.py book.epub --wav # Export to WAV files") + return 1 + + # Check if book exists (only for local books) + if bookPath and not Path(bookPath).exists(): print(f"Error: Book file not found: {bookPath}") return 1 diff --git a/src/audio_parser.py b/src/audio_parser.py new file mode 100644 index 0000000..8d37d14 --- /dev/null +++ b/src/audio_parser.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Audio Book Parser + +Parses audio book files (M4B, M4A, MP3) and extracts chapter information. +Uses mutagen for metadata extraction. +""" + +from pathlib import Path +from src.book import Book, Chapter + + +class AudioChapter(Chapter): + """Chapter with audio-specific metadata""" + + def __init__(self, title="Untitled", startTime=0.0, duration=0.0): + """ + Initialize audio chapter + + Args: + title: Chapter title + startTime: Start time in seconds + duration: Duration in seconds + """ + super().__init__(title) + self.startTime = startTime + self.duration = duration + # Add placeholder paragraph for compatibility + self.paragraphs = [f"Audio chapter: {title}"] + + +class AudioBook(Book): + """Book with audio-specific metadata""" + + def __init__(self, title="Untitled", author="Unknown", audioPath=None): + """ + Initialize audio book + + Args: + title: Book title + author: Book author + audioPath: Path to audio file + """ + super().__init__(title, author) + self.audioPath = audioPath + self.isAudioBook = True + self.totalDuration = 0.0 + + +class AudioParser: + """Parser for audio book files (M4B, M4A, MP3)""" + + def __init__(self): + """Initialize audio parser""" + self.mutagen = None + try: + import mutagen + import mutagen.mp4 + import mutagen.mp3 + self.mutagen = mutagen + except ImportError: + print("Warning: mutagen not installed. Install with: pip install mutagen") + + def parse(self, audioPath): + """ + Parse audio file and extract chapters + + Args: + audioPath: Path to audio file + + Returns: + AudioBook object + """ + if not self.mutagen: + raise ImportError("mutagen library required for audio books") + + audioPath = Path(audioPath) + + # Try to load the audio file + try: + audioFile = self.mutagen.File(audioPath) + if audioFile is None: + raise ValueError(f"Could not read audio file: {audioPath}") + except Exception as e: + raise Exception(f"Error loading audio file: {e}") + + # Create audio book + book = AudioBook(audioPath=str(audioPath)) + + # Extract metadata + title = self._extract_title(audioFile, audioPath) + author = self._extract_author(audioFile) + book.title = title + book.author = author + + # Get duration + if hasattr(audioFile.info, 'length'): + book.totalDuration = audioFile.info.length + + # Extract chapters + chapters = self._extract_chapters(audioFile, audioPath) + + if chapters: + # Add parsed chapters + for chapter in chapters: + book.add_chapter(chapter) + else: + # No chapter markers - treat entire file as one chapter + singleChapter = AudioChapter( + title=title, + startTime=0.0, + duration=book.totalDuration + ) + book.add_chapter(singleChapter) + + return book + + def _extract_title(self, audioFile, audioPath): + """Extract title from metadata""" + # Try various title tags + titleTags = ['\xa9nam', 'TIT2', 'title', 'TITLE'] + + for tag in titleTags: + if tag in audioFile: + value = audioFile[tag] + if isinstance(value, list): + return str(value[0]) + return str(value) + + # Fallback to filename + return audioPath.stem + + def _extract_author(self, audioFile): + """Extract author from metadata""" + # Try various author tags + authorTags = ['\xa9ART', 'TPE1', 'artist', 'ARTIST', 'author', 'AUTHOR'] + + for tag in authorTags: + if tag in audioFile: + value = audioFile[tag] + if isinstance(value, list): + return str(value[0]) + return str(value) + + return "Unknown" + + def _extract_chapters(self, audioFile, audioPath): + """Extract chapter information from audio file""" + chapters = [] + + # Try MP4 chapter format (M4B, M4A) + if hasattr(audioFile, 'tags') and audioFile.tags: + # MP4 files store chapters in a special way + if hasattr(audioFile.tags, '_DictProxy__dict'): + tagsDict = audioFile.tags._DictProxy__dict + if 'chapters' in tagsDict: + mp4Chapters = tagsDict['chapters'] + chapters = self._parse_mp4_chapters(mp4Chapters) + + # Try MP3 chapter format (ID3 CHAP frames) + if not chapters and hasattr(audioFile, 'tags'): + # Look for CHAP frames in ID3 tags + if hasattr(audioFile.tags, 'getall'): + chapFrames = audioFile.tags.getall('CHAP') + if chapFrames: + chapters = self._parse_id3_chapters(chapFrames) + + return chapters + + def _parse_mp4_chapters(self, mp4Chapters): + """Parse MP4 chapter list""" + chapters = [] + + for i, chapterData in enumerate(mp4Chapters): + if isinstance(chapterData, tuple) and len(chapterData) >= 2: + startTime = chapterData[0] / 1000.0 # Convert ms to seconds + chapterTitle = chapterData[1] if chapterData[1] else f"Chapter {i + 1}" + + # Calculate duration (will be updated when we know the next chapter's start) + duration = 0.0 + + chapter = AudioChapter( + title=chapterTitle, + startTime=startTime, + duration=duration + ) + chapters.append(chapter) + + # Calculate durations based on next chapter's start time + for i in range(len(chapters) - 1): + chapters[i].duration = chapters[i + 1].startTime - chapters[i].startTime + + # Last chapter duration will be set by total book duration later + + return chapters + + def _parse_id3_chapters(self, chapFrames): + """Parse ID3 CHAP frames (MP3 chapters)""" + chapters = [] + + for i, chapFrame in enumerate(chapFrames): + startTime = 0.0 + chapterTitle = f"Chapter {i + 1}" + + # Extract start time and title from CHAP frame + # Note: start_time and sub_frames are external library attributes + if hasattr(chapFrame, 'start_time'): + # pylint: disable=no-member + startTime = chapFrame.start_time / 1000.0 # Convert ms to seconds + + if hasattr(chapFrame, 'sub_frames'): + # pylint: disable=no-member + for subFrame in chapFrame.sub_frames: + if hasattr(subFrame, 'text'): + chapterTitle = str(subFrame.text[0]) if subFrame.text else chapterTitle + + chapter = AudioChapter( + title=chapterTitle, + startTime=startTime, + duration=0.0 + ) + chapters.append(chapter) + + # Calculate durations + for i in range(len(chapters) - 1): + chapters[i].duration = chapters[i + 1].startTime - chapters[i].startTime + + return chapters + + def cleanup(self): + """Clean up resources (no temp files for audio)""" + pass diff --git a/src/audiobookshelf_client.py b/src/audiobookshelf_client.py new file mode 100644 index 0000000..1e35eb2 --- /dev/null +++ b/src/audiobookshelf_client.py @@ -0,0 +1,712 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Audiobookshelf API Client + +Handles communication with Audiobookshelf server for browsing, +streaming, and syncing audiobooks. +""" + +import requests +import json +from typing import Optional, Dict, List + + +class AudiobookshelfClient: + """Client for Audiobookshelf API""" + + def __init__(self, serverUrl: str, configManager=None): + """ + Initialize Audiobookshelf client + + Args: + serverUrl: Base URL of Audiobookshelf server (e.g., https://abs.example.com) + configManager: ConfigManager instance for token storage + """ + # Remove trailing slash from server URL + self.serverUrl = serverUrl.rstrip('/') + self.configManager = configManager + self.authToken = None + + # Load saved token if available + if configManager: + savedToken = configManager.get_abs_auth_token() + if savedToken: + self.authToken = savedToken + + def login(self, username: str, password: str) -> bool: + """ + Login to Audiobookshelf server + + Args: + username: Username + password: Password + + Returns: + True if login successful, False otherwise + """ + try: + url = f"{self.serverUrl}/login" + payload = { + 'username': username, + 'password': password + } + + response = requests.post(url, json=payload, timeout=10) + + if response.status_code == 200: + data = response.json() + # Token is in response.user.token + if 'user' in data and 'token' in data['user']: + self.authToken = data['user']['token'] + + # Save token to config + if self.configManager: + self.configManager.set_abs_auth_token(self.authToken) + self.configManager.set_abs_username(username) + + return True + else: + print("ERROR: Token not found in login response") + return False + else: + print(f"Login failed: {response.status_code} - {response.text}") + return False + + except requests.exceptions.RequestException as e: + print(f"Login error: {e}") + return False + + def test_connection(self) -> bool: + """ + Test connection to server with current token + + Returns: + True if connection successful, False otherwise + """ + if not self.authToken: + print("ERROR: No auth token available") + return False + + try: + # Try to get user info to verify token + url = f"{self.serverUrl}/api/me" + headers = {'Authorization': f'Bearer {self.authToken}'} + + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + return True + elif response.status_code == 401: + print("ERROR: Auth token invalid or expired") + # Clear invalid token + self.authToken = None + if self.configManager: + self.configManager.set_abs_auth_token('') + return False + else: + print(f"Connection test failed: {response.status_code}") + return False + + except requests.exceptions.RequestException as e: + print(f"Connection test error: {e}") + return False + + def get_libraries(self) -> Optional[List[Dict]]: + """ + Get list of libraries from server + + Returns: + List of library dictionaries, or None if error + """ + if not self.authToken: + print("ERROR: Not authenticated") + return None + + try: + url = f"{self.serverUrl}/api/libraries" + headers = {'Authorization': f'Bearer {self.authToken}'} + + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + # Response should be a dict with 'libraries' key + if isinstance(data, dict) and 'libraries' in data: + return data['libraries'] + elif isinstance(data, list): + return data + else: + print(f"Unexpected response format: {data}") + return None + else: + print(f"Get libraries failed: {response.status_code}") + return None + + except requests.exceptions.RequestException as e: + print(f"Get libraries error: {e}") + return None + + def get_library_items(self, libraryId: str, limit: int = 100, page: int = 0) -> Optional[List[Dict]]: + """ + Get audiobooks in a library + + Args: + libraryId: Library ID + limit: Max items to return (default 100) + page: Page number for pagination (default 0) + + Returns: + List of library item dictionaries, or None if error + """ + if not self.authToken: + print("ERROR: Not authenticated") + return None + + try: + url = f"{self.serverUrl}/api/libraries/{libraryId}/items" + headers = {'Authorization': f'Bearer {self.authToken}'} + params = {'limit': limit, 'page': page} + + response = requests.get(url, headers=headers, params=params, timeout=10) + + if response.status_code == 200: + data = response.json() + # Response format varies - check for 'results' key + if isinstance(data, dict) and 'results' in data: + return data['results'] + elif isinstance(data, list): + return data + else: + print(f"Unexpected response format: {data}") + return None + else: + print(f"Get library items failed: {response.status_code}") + return None + + except requests.exceptions.RequestException as e: + print(f"Get library items error: {e}") + return None + + def _make_request(self, method: str, endpoint: str, **kwargs) -> Optional[requests.Response]: + """ + Make authenticated API request + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint (e.g., '/api/libraries') + **kwargs: Additional arguments for requests + + Returns: + Response object or None if error + """ + if not self.authToken: + print("ERROR: Not authenticated") + return None + + try: + url = f"{self.serverUrl}{endpoint}" + headers = kwargs.pop('headers', {}) + headers['Authorization'] = f'Bearer {self.authToken}' + + response = requests.request( + method=method, + url=url, + headers=headers, + timeout=kwargs.pop('timeout', 30), + **kwargs + ) + + return response + + except requests.exceptions.RequestException as e: + print(f"Request error: {e}") + return None + + def is_authenticated(self) -> bool: + """Check if client has valid authentication token""" + return bool(self.authToken) + + def get_library_item_details(self, itemId: str) -> Optional[Dict]: + """ + Get detailed information about a library item + + Args: + itemId: Library item ID + + Returns: + Item details dictionary, or None if error + """ + if not self.authToken: + print("ERROR: Not authenticated") + return None + + try: + # Try both endpoint patterns (API changed between versions) + endpoints = [ + f"/api/items/{itemId}", # v2.3+ (current) + f"/api/library-items/{itemId}", # v2.0-2.2 (legacy) + ] + + headers = {'Authorization': f'Bearer {self.authToken}'} + + for endpoint in endpoints: + url = f"{self.serverUrl}{endpoint}" + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + return response.json() + elif response.status_code == 404: + # Try next endpoint + continue + else: + # Other error, don't try more endpoints + print(f"Get item details failed: {response.status_code}") + return None + + # All endpoints failed + print(f"Get item details failed: Item not found (tried both /api/items and /api/library-items)") + return None + + except requests.exceptions.RequestException as e: + print(f"Get item details error: {e}") + return None + + def download_audio_file(self, itemId: str, outputPath: str, progressCallback=None) -> bool: + """ + Download audiobook file to local path + + Args: + itemId: Library item ID + outputPath: Path to save the downloaded file + progressCallback: Optional callback for progress updates (percent) + + Returns: + True if download successful, False otherwise + """ + if not self.authToken: + print("ERROR: Not authenticated") + return False + + try: + # Use the download endpoint + # Format: /api/items/{itemId}/download?token={token} + downloadUrl = f"{self.serverUrl}/api/items/{itemId}/file" + headers = {'Authorization': f'Bearer {self.authToken}'} + + print(f"DEBUG: Downloading from: {downloadUrl}") + + # Download with streaming to handle large files + response = requests.get(downloadUrl, headers=headers, stream=True, timeout=30) + + if response.status_code != 200: + print(f"Download failed: {response.status_code}") + return False + + # Get total file size + totalSize = int(response.headers.get('content-length', 0)) + + # Download to file + downloaded = 0 + with open(outputPath, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded += len(chunk) + + # Progress callback + if progressCallback and totalSize > 0: + percent = int((downloaded / totalSize) * 100) + progressCallback(percent) + + print(f"Download complete: {outputPath}") + return True + + except requests.exceptions.RequestException as e: + print(f"Download error: {e}") + return False + except IOError as e: + print(f"File write error: {e}") + return False + + def get_stream_url(self, itemId: str, itemDetails: Optional[Dict] = None) -> Optional[str]: + """ + Get streaming URL for an audiobook using /play endpoint + + Args: + itemId: Library item ID + itemDetails: Optional pre-fetched item details (not required) + + Returns: + Streaming URL with auth token, or None if error + """ + if not self.authToken: + print("ERROR: Not authenticated") + return None + + try: + # Validate item exists and has audio content (optional check) + if itemDetails: + media = itemDetails.get('media', {}) + numAudioFiles = media.get('numAudioFiles', 0) + duration = media.get('duration', 0.0) + print(f"DEBUG: Item has {numAudioFiles} audio files, duration {duration}s") + + # Use the /play endpoint which creates a playback session and returns stream info + # This is what the web player uses + playUrl = f"{self.serverUrl}/api/items/{itemId}/play" + print(f"DEBUG: Requesting play session from: {playUrl}") + + response = requests.post( + playUrl, + headers={'Authorization': f'Bearer {self.authToken}'}, + json={ + 'deviceInfo': { + 'deviceId': 'bookstorm', + 'clientName': 'BookStorm' + }, + 'forceDirectPlay': False, + 'forceTranscode': False, + 'supportedMimeTypes': ['audio/mpeg', 'audio/mp4', 'audio/flac', 'audio/ogg'] + }, + timeout=10 + ) + + if response.status_code != 200: + print(f"ERROR: /play endpoint failed with status {response.status_code}") + print(f"Response: {response.text}") + return None + + playData = response.json() + print(f"DEBUG: Play response keys: {list(playData.keys())}") + + # Extract the actual stream URL from the play response + # The response contains either 'audioTracks' or direct 'url' + streamUrl = None + + # Try different response formats + if 'audioTracks' in playData and playData['audioTracks']: + # Multi-file audiobook - use first track or concatenated stream + audioTrack = playData['audioTracks'][0] + streamUrl = audioTrack.get('contentUrl') + print(f"DEBUG: Using audioTrack URL") + elif 'url' in playData: + # Direct URL + streamUrl = playData.get('url') + print(f"DEBUG: Using direct URL") + elif 'contentUrl' in playData: + # Alternative format + streamUrl = playData.get('contentUrl') + print(f"DEBUG: Using contentUrl") + + if not streamUrl: + print(f"ERROR: No stream URL found in play response") + print(f"Play data: {playData}") + return None + + # Make URL absolute if it's relative + if streamUrl.startswith('/'): + streamUrl = f"{self.serverUrl}{streamUrl}" + + print(f"DEBUG: Stream URL: {streamUrl[:100]}...") + return streamUrl + + except Exception as e: + print(f"Get stream URL error: {e}") + import traceback + traceback.print_exc() + return None + + def get_item_chapters(self, itemId: str) -> Optional[List[Dict]]: + """ + Get chapter information for an audiobook + + Args: + itemId: Library item ID + + Returns: + List of chapter dictionaries, or None if error + """ + if not self.authToken: + print("ERROR: Not authenticated") + return None + + try: + # Get item details which includes chapter info + itemDetails = self.get_library_item_details(itemId) + if not itemDetails: + return None + + # Extract chapters from media + media = itemDetails.get('media', {}) + chapters = media.get('chapters', []) + + return chapters if chapters else None + + except Exception as e: + print(f"Get chapters error: {e}") + return None + + def update_progress(self, itemId: str, currentTime: float, duration: float, progress: float) -> bool: + """ + Update playback progress for an item + + Args: + itemId: Library item ID + currentTime: Current playback time in seconds + duration: Total duration in seconds + progress: Progress as decimal (0.0 to 1.0) + + Returns: + True if update successful, False otherwise + """ + if not self.authToken: + print("ERROR: Not authenticated") + return False + + try: + url = f"{self.serverUrl}/api/me/progress/{itemId}" + headers = {'Authorization': f'Bearer {self.authToken}'} + payload = { + 'currentTime': currentTime, + 'duration': duration, + 'progress': progress, + 'isFinished': progress >= 0.99 + } + + response = requests.patch(url, json=payload, headers=headers, timeout=10) + + if response.status_code == 200: + return True + else: + print(f"Update progress failed: {response.status_code}") + return False + + except requests.exceptions.RequestException as e: + print(f"Update progress error: {e}") + return False + + def get_progress(self, itemId: str) -> Optional[Dict]: + """ + Get playback progress for an item + + Args: + itemId: Library item ID + + Returns: + Progress dictionary, or None if error or no progress + """ + if not self.authToken: + print("ERROR: Not authenticated") + return None + + try: + url = f"{self.serverUrl}/api/me/progress/{itemId}" + headers = {'Authorization': f'Bearer {self.authToken}'} + + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + return response.json() + elif response.status_code == 404: + # No progress found + return None + else: + print(f"Get progress failed: {response.status_code}") + return None + + except requests.exceptions.RequestException as e: + print(f"Get progress error: {e}") + return None + + def create_session(self, itemId: str) -> Optional[str]: + """ + Create a new listening session + + Args: + itemId: Library item ID + + Returns: + Session ID, or None if error + """ + if not self.authToken: + print("ERROR: Not authenticated") + return None + + try: + # Try the correct endpoint for starting a playback session + url = f"{self.serverUrl}/api/session/local" + headers = {'Authorization': f'Bearer {self.authToken}'} + payload = { + 'libraryItemId': itemId, + 'mediaPlayer': 'BookStorm', + 'deviceInfo': { + 'deviceId': 'bookstorm', + 'clientName': 'BookStorm' + } + } + + response = requests.post(url, json=payload, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + sessionId = data.get('id') + print(f"DEBUG: Session created successfully: {sessionId}") + return sessionId + else: + # Session creation not critical, just log and continue + print(f"Note: Could not create session (status {response.status_code}), continuing without session tracking") + return None + + except requests.exceptions.RequestException as e: + # Session creation not critical, just log and continue + print(f"Note: Could not create session ({e}), continuing without session tracking") + return None + + def sync_session(self, sessionId: str, currentTime: float, duration: float, progress: float) -> bool: + """ + Sync session progress + + Args: + sessionId: Session ID + currentTime: Current playback time in seconds + duration: Total duration in seconds + progress: Progress as decimal (0.0 to 1.0) + + Returns: + True if sync successful, False otherwise + """ + if not self.authToken: + print("ERROR: Not authenticated") + return False + + try: + url = f"{self.serverUrl}/api/session/{sessionId}/sync" + headers = {'Authorization': f'Bearer {self.authToken}'} + payload = { + 'currentTime': currentTime, + 'duration': duration, + 'progress': progress + } + + response = requests.post(url, json=payload, headers=headers, timeout=10) + + if response.status_code == 200: + return True + else: + print(f"Sync session failed: {response.status_code}") + return False + + except requests.exceptions.RequestException as e: + print(f"Sync session error: {e}") + return False + + def close_session(self, sessionId: str) -> bool: + """ + Close a listening session + + Args: + sessionId: Session ID + + Returns: + True if close successful, False otherwise + """ + if not self.authToken: + print("ERROR: Not authenticated") + return False + + try: + url = f"{self.serverUrl}/api/session/{sessionId}/close" + headers = {'Authorization': f'Bearer {self.authToken}'} + + response = requests.post(url, headers=headers, timeout=10) + + if response.status_code == 200: + return True + else: + print(f"Close session failed: {response.status_code}") + return False + + except requests.exceptions.RequestException as e: + print(f"Close session error: {e}") + return False + + def get_library_series(self, libraryId: str) -> Optional[List[Dict]]: + """ + Get series in a library + + Args: + libraryId: Library ID + + Returns: + List of series dictionaries, or None if error + """ + if not self.authToken: + print("ERROR: Not authenticated") + return None + + try: + url = f"{self.serverUrl}/api/libraries/{libraryId}/series" + headers = {'Authorization': f'Bearer {self.authToken}'} + + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + # Response format may vary + if isinstance(data, dict) and 'series' in data: + return data['series'] + elif isinstance(data, dict) and 'results' in data: + return data['results'] + elif isinstance(data, list): + return data + else: + return None + else: + print(f"Get series failed: {response.status_code}") + return None + + except requests.exceptions.RequestException as e: + print(f"Get series error: {e}") + return None + + def get_library_collections(self, libraryId: str) -> Optional[List[Dict]]: + """ + Get collections in a library + + Args: + libraryId: Library ID + + Returns: + List of collection dictionaries, or None if error + """ + if not self.authToken: + print("ERROR: Not authenticated") + return None + + try: + url = f"{self.serverUrl}/api/libraries/{libraryId}/collections" + headers = {'Authorization': f'Bearer {self.authToken}'} + + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + data = response.json() + # Response format may vary + if isinstance(data, dict) and 'collections' in data: + return data['collections'] + elif isinstance(data, dict) and 'results' in data: + return data['results'] + elif isinstance(data, list): + return data + else: + return None + else: + print(f"Get collections failed: {response.status_code}") + return None + + except requests.exceptions.RequestException as e: + print(f"Get collections error: {e}") + return None + + diff --git a/src/audiobookshelf_menu.py b/src/audiobookshelf_menu.py new file mode 100644 index 0000000..c85a8ba --- /dev/null +++ b/src/audiobookshelf_menu.py @@ -0,0 +1,579 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Audiobookshelf Menu + +Interactive browser for Audiobookshelf server content. +Browse libraries, audiobooks, series, and collections. +""" + +from pathlib import Path + + +class AudiobookshelfMenu: + """Audiobookshelf browser interface""" + + def __init__(self, absClient, configManager, speechEngine=None): + """ + Initialize Audiobookshelf menu + + Args: + absClient: AudiobookshelfClient instance + configManager: ConfigManager instance + speechEngine: SpeechEngine instance for accessibility + """ + self.absClient = absClient + self.configManager = configManager + self.speechEngine = speechEngine + + # Menu state + self.inMenu = False + self.currentView = 'libraries' # 'libraries', 'books', 'stream_download' + self.currentSelection = 0 + self.items = [] + + # Context tracking + self.selectedLibrary = None + self.selectedBook = None + + # View mode for books list + self.booksViewMode = 'all' # 'all', 'series', 'collections' + + # Stream/download submenu options + self.streamDownloadOptions = [ + {'action': 'stream', 'label': 'Stream from server'}, + {'action': 'download', 'label': 'Download to library'}, + {'action': 'cancel', 'label': 'Cancel'} + ] + + def enter_menu(self): + """Enter the Audiobookshelf browser""" + self.inMenu = True + self.currentSelection = 0 + + # Reset state from previous sessions + self.selectedLibrary = None + self.selectedBook = None + self.booksViewMode = 'all' + + if self.speechEngine: + self.speechEngine.speak("Audiobookshelf browser. Loading libraries...") + + # Get libraries from server + libraries = self.absClient.get_libraries() + + if not libraries: + if self.speechEngine: + self.speechEngine.speak("Error loading libraries. Check server connection.") + self.inMenu = False + return + + # If only one library, skip library selection and go straight to books + if len(libraries) == 1: + self.selectedLibrary = libraries[0] + self._load_books() + else: + # Multiple libraries - show library selection + self.currentView = 'libraries' + self.items = libraries + if self.speechEngine: + self.speechEngine.speak(f"Found {len(libraries)} libraries. Use arrow keys to navigate.") + self._speak_current_item() + + def _load_books(self): + """Load books from selected library""" + if not self.selectedLibrary: + return + + libraryId = self.selectedLibrary.get('id') + if not libraryId: + if self.speechEngine: + self.speechEngine.speak("Error: Invalid library") + return + + if self.speechEngine: + libraryName = self.selectedLibrary.get('name', 'Library') + self.speechEngine.speak(f"Loading books from {libraryName}...") + + # Get books from library + books = self.absClient.get_library_items(libraryId) + + if not books: + if self.speechEngine: + self.speechEngine.speak("No books found in library") + self.items = [] + self.currentView = 'books' + return + + # Store books without checking local status (too slow) + # Local check will happen on selection only + self.items = books + self.currentView = 'books' + self.currentSelection = 0 + + if self.speechEngine: + self.speechEngine.speak(f"Loaded {len(books)} books. Use arrow keys to navigate, left-right to change view.") + self._speak_current_item() + + def _check_if_local(self, book): + """ + Check if book exists locally + + Args: + book: Book dictionary from server + + Returns: + True if book exists locally, False otherwise + """ + # Get library directory + libraryDir = self.configManager.get_library_directory() + if not libraryDir: + return False + + libraryPath = Path(libraryDir) + if not libraryPath.exists(): + return False + + # Extract book metadata + media = book.get('media', {}) + metadata = media.get('metadata', {}) + title = metadata.get('title', '') + author = metadata.get('authorName', '') + + if not title: + return False + + # Search for matching files in library + # Look for common audio formats + audioExtensions = ['.m4b', '.m4a', '.mp3', '.ogg'] + + for audioExt in audioExtensions: + # Try exact title match + for filePath in libraryPath.rglob(f"*{audioExt}"): + fileName = filePath.stem.lower() + if title.lower() in fileName: + # Found potential match - could enhance with duration check later + return True + + return False + + def navigate_menu(self, direction): + """Navigate menu up or down""" + # Handle stream/download submenu separately + if self.currentView == 'stream_download': + if direction == 'up': + self.currentSelection = (self.currentSelection - 1) % len(self.streamDownloadOptions) + elif direction == 'down': + self.currentSelection = (self.currentSelection + 1) % len(self.streamDownloadOptions) + self._speak_current_item() + return + + if not self.items: + return + + oldSelection = self.currentSelection + if direction == 'up': + self.currentSelection = (self.currentSelection - 1) % len(self.items) + elif direction == 'down': + self.currentSelection = (self.currentSelection + 1) % len(self.items) + + # Debug output + print(f"DEBUG NAV: {direction} - moved from {oldSelection} to {self.currentSelection} (total items: {len(self.items)})") + if self.currentSelection < len(self.items): + item = self.items[self.currentSelection] + media = item.get('media', {}) + metadata = media.get('metadata', {}) + title = metadata.get('title', 'Unknown') + print(f"DEBUG NAV: Current item title: {title}") + + self._speak_current_item() + + def change_view(self, direction): + """Change view mode (only in books view)""" + if self.currentView != 'books': + return + + views = ['all', 'series', 'collections'] + currentIndex = views.index(self.booksViewMode) + + if direction == 'left': + newIndex = (currentIndex - 1) % len(views) + elif direction == 'right': + newIndex = (currentIndex + 1) % len(views) + else: + return + + oldViewMode = self.booksViewMode + self.booksViewMode = views[newIndex] + + # Announce new view + if self.speechEngine: + viewName = { + 'all': 'All books', + 'series': 'Series view', + 'collections': 'Collections view' + }[self.booksViewMode] + self.speechEngine.speak(viewName) + + # Reload content for new view + if self.booksViewMode == 'all': + self._load_books() + elif self.booksViewMode == 'series': + self._load_series() + elif self.booksViewMode == 'collections': + self._load_collections() + + def _speak_current_item(self): + """Speak current item""" + if not self.speechEngine: + return + + if self.currentView == 'stream_download': + # Speak submenu option + if self.currentSelection < len(self.streamDownloadOptions): + option = self.streamDownloadOptions[self.currentSelection] + text = f"{option['label']}, {self.currentSelection + 1} of {len(self.streamDownloadOptions)}" + self.speechEngine.speak(text) + return + + if not self.items: + return + + item = self.items[self.currentSelection] + + if self.currentView == 'libraries': + # Speak library name + libraryName = item.get('name', 'Unknown library') + text = f"{libraryName}, {self.currentSelection + 1} of {len(self.items)}" + self.speechEngine.speak(text) + + elif self.currentView == 'books': + # Different handling based on view mode + if self.booksViewMode == 'series': + # Speaking a series + name = item.get('name', 'Unknown series') + numBooks = item.get('books', []) + bookCount = len(numBooks) if isinstance(numBooks, list) else item.get('numBooks', 0) + text = f"{name}, {bookCount} books, {self.currentSelection + 1} of {len(self.items)}" + self.speechEngine.speak(text) + + elif self.booksViewMode == 'collections': + # Speaking a collection + name = item.get('name', 'Unknown collection') + numBooks = item.get('books', []) + bookCount = len(numBooks) if isinstance(numBooks, list) else item.get('numBooks', 0) + text = f"{name}, {bookCount} books, {self.currentSelection + 1} of {len(self.items)}" + self.speechEngine.speak(text) + + else: + # Speaking a book (all books view) + media = item.get('media', {}) + metadata = media.get('metadata', {}) + title = metadata.get('title', 'Unknown title') + author = metadata.get('authorName', '') + + # Build description + parts = [title] + if author: + parts.append(f"by {author}") + parts.append(f"{self.currentSelection + 1} of {len(self.items)}") + + text = ", ".join(parts) + self.speechEngine.speak(text) + + def activate_current_item(self): + """ + Activate current item (select library or book) + + Returns: + None if navigating, dictionary if book selected + """ + # Handle stream/download submenu + if self.currentView == 'stream_download': + if self.currentSelection < len(self.streamDownloadOptions): + option = self.streamDownloadOptions[self.currentSelection] + action = option['action'] + + if action == 'cancel': + # Go back to books list + self.currentView = 'books' + self.currentSelection = 0 + if self.speechEngine: + self.speechEngine.speak("Cancelled") + self._speak_current_item() + return None + + elif action == 'stream': + # Return stream action + return { + 'action': 'stream', + 'serverBook': self.selectedBook + } + + elif action == 'download': + # Return download action + return { + 'action': 'download', + 'serverBook': self.selectedBook + } + + return None + + if not self.items: + if self.speechEngine: + self.speechEngine.speak("No items") + return None + + item = self.items[self.currentSelection] + + if self.currentView == 'libraries': + # Library selected - load books from this library + self.selectedLibrary = item + self._load_books() + return None + + elif self.currentView == 'books': + # Check if we're selecting a series or collection (not a book) + if self.booksViewMode == 'series': + # Series selected - show books in this series + seriesBooks = item.get('books', []) + if not seriesBooks: + if self.speechEngine: + self.speechEngine.speak("No books in this series") + return None + + # Store books directly (local check too slow) + self.items = seriesBooks + self.booksViewMode = 'all' # Switch to "all books" view + self.currentSelection = 0 + + if self.speechEngine: + seriesName = item.get('name', 'Series') + self.speechEngine.speak(f"{seriesName}. {len(seriesBooks)} books") + self._speak_current_item() + return None + + elif self.booksViewMode == 'collections': + # Collection selected - show books in this collection + collectionBooks = item.get('books', []) + if not collectionBooks: + if self.speechEngine: + self.speechEngine.speak("No books in this collection") + return None + + # Store books directly (local check too slow) + self.items = collectionBooks + self.booksViewMode = 'all' # Switch to "all books" view + self.currentSelection = 0 + + if self.speechEngine: + collectionName = item.get('name', 'Collection') + self.speechEngine.speak(f"{collectionName}. {len(collectionBooks)} books") + self._speak_current_item() + return None + + # Book selected + self.selectedBook = item + + # Debug: what book did user select? + media = item.get('media', {}) + metadata = media.get('metadata', {}) + title = metadata.get('title', 'Unknown') + print(f"\nDEBUG SELECT: User pressed ENTER on item {self.currentSelection}") + print(f"DEBUG SELECT: Book title: {title}") + print(f"DEBUG SELECT: Book ID: {item.get('id', 'NO ID')}") + + # For books from series/collections, fetch full details if needed + # (they might have incomplete metadata) + if not item.get('media'): + # Book doesn't have full media details, fetch them + bookId = item.get('id') or item.get('libraryItemId') + if bookId: + print(f"\nFetching full details for book ID: {bookId}") + fullDetails = self.absClient.get_library_item_details(bookId) + if fullDetails: + self.selectedBook = fullDetails + item = fullDetails + print("Full book details loaded") + # Re-extract metadata from full details + media = item.get('media', {}) + metadata = media.get('metadata', {}) + title = metadata.get('title', 'Unknown') + print(f"DEBUG SELECT: After fetch, title is: {title}") + + # Check if local copy exists + isLocal = self._check_if_local(item) + + if isLocal: + # Book exists locally - return it for direct opening + if self.speechEngine: + media = item.get('media', {}) + metadata = media.get('metadata', {}) + title = metadata.get('title', 'Book') + self.speechEngine.speak(f"Opening local copy of {title}") + + # Find local file path + localPath = self._find_local_path(item) + if localPath: + return { + 'action': 'open_local', + 'path': localPath, + 'serverBook': item + } + else: + if self.speechEngine: + self.speechEngine.speak("Error: Could not find local file") + return None + else: + # Book not local - enter stream/download submenu + self.currentView = 'stream_download' + self.currentSelection = 0 + if self.speechEngine: + self.speechEngine.speak("Choose playback option. Use arrow keys to navigate.") + self._speak_current_item() + return None + + return None + + def _find_local_path(self, book): + """ + Find local file path for a book + + Args: + book: Book dictionary from server + + Returns: + Path to local file, or None if not found + """ + libraryDir = self.configManager.get_library_directory() + if not libraryDir: + return None + + libraryPath = Path(libraryDir) + if not libraryPath.exists(): + return None + + # Extract book metadata + media = book.get('media', {}) + metadata = media.get('metadata', {}) + title = metadata.get('title', '') + + if not title: + return None + + # Search for matching files + audioExtensions = ['.m4b', '.m4a', '.mp3', '.ogg'] + + for audioExt in audioExtensions: + for filePath in libraryPath.rglob(f"*{audioExt}"): + fileName = filePath.stem.lower() + if title.lower() in fileName: + return str(filePath) + + return None + + def go_back(self): + """Go back to previous view""" + if self.currentView == 'stream_download': + # Go back to books list + self.currentView = 'books' + self.currentSelection = 0 + if self.speechEngine: + self.speechEngine.speak("Back to books") + self._speak_current_item() + elif self.currentView == 'books': + # Go back to libraries (if multiple) + libraries = self.absClient.get_libraries() + if libraries and len(libraries) > 1: + self.currentView = 'libraries' + self.items = libraries + self.currentSelection = 0 + self.selectedLibrary = None + if self.speechEngine: + self.speechEngine.speak("Back to libraries") + self._speak_current_item() + else: + # Only one library - exit menu + self.exit_menu() + else: + # Already at top level - exit + self.exit_menu() + + def is_in_menu(self): + """Check if currently in menu""" + return self.inMenu + + def _load_series(self): + """Load series from selected library""" + if not self.selectedLibrary: + return + + libraryId = self.selectedLibrary.get('id') + if not libraryId: + if self.speechEngine: + self.speechEngine.speak("Error: Invalid library") + return + + if self.speechEngine: + self.speechEngine.speak("Loading series...") + + # Get series from library + seriesList = self.absClient.get_library_series(libraryId) + + if not seriesList: + if self.speechEngine: + self.speechEngine.speak("No series found") + self.items = [] + self.currentSelection = 0 + return + + self.items = seriesList + self.currentView = 'books' # Keep in books view but showing series + self.currentSelection = 0 + + if self.speechEngine: + self.speechEngine.speak(f"Loaded {len(seriesList)} series") + self._speak_current_item() + + def _load_collections(self): + """Load collections from selected library""" + if not self.selectedLibrary: + return + + libraryId = self.selectedLibrary.get('id') + if not libraryId: + if self.speechEngine: + self.speechEngine.speak("Error: Invalid library") + return + + if self.speechEngine: + self.speechEngine.speak("Loading collections...") + + # Get collections from library + collectionsList = self.absClient.get_library_collections(libraryId) + + if not collectionsList: + if self.speechEngine: + self.speechEngine.speak("No collections found") + self.items = [] + self.currentSelection = 0 + return + + self.items = collectionsList + self.currentView = 'books' # Keep in books view but showing collections + self.currentSelection = 0 + + if self.speechEngine: + self.speechEngine.speak(f"Loaded {len(collectionsList)} collections") + self._speak_current_item() + + def exit_menu(self): + """Exit the menu""" + self.inMenu = False + self.currentView = 'libraries' + self.items = [] + self.selectedLibrary = None + self.selectedBook = None + if self.speechEngine: + self.speechEngine.speak("Closed Audiobookshelf browser") + diff --git a/src/bookmark_manager.py b/src/bookmark_manager.py index 19961aa..3a42dd6 100644 --- a/src/bookmark_manager.py +++ b/src/bookmark_manager.py @@ -50,6 +50,27 @@ class BookmarkManager: ) ''') + # Add audio_position column if it doesn't exist (migration for existing databases) + try: + cursor.execute('ALTER TABLE bookmarks ADD COLUMN audio_position REAL DEFAULT 0.0') + except sqlite3.OperationalError: + # Column already exists + pass + + # Create named_bookmarks table for multiple bookmarks per book + cursor.execute(''' + CREATE TABLE IF NOT EXISTS named_bookmarks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book_id TEXT NOT NULL, + name TEXT NOT NULL, + chapter_index INTEGER NOT NULL DEFAULT 0, + paragraph_index INTEGER NOT NULL DEFAULT 0, + audio_position REAL DEFAULT 0.0, + created_at TEXT NOT NULL, + UNIQUE(book_id, name) + ) + ''') + conn.commit() conn.close() @@ -58,7 +79,7 @@ class BookmarkManager: bookPath = str(Path(bookPath).resolve()) return hashlib.sha256(bookPath.encode()).hexdigest()[:16] - def save_bookmark(self, bookPath, bookTitle, chapterIndex, paragraphIndex, sentenceIndex=0): + def save_bookmark(self, bookPath, bookTitle, chapterIndex, paragraphIndex, sentenceIndex=0, audioPosition=0.0): """ Save bookmark for a book @@ -68,6 +89,7 @@ class BookmarkManager: chapterIndex: Current chapter index paragraphIndex: Current paragraph index sentenceIndex: Current sentence index (default: 0) + audioPosition: Audio playback position in seconds (default: 0.0) """ bookId = self._get_book_id(bookPath) timestamp = datetime.now().isoformat() @@ -78,11 +100,11 @@ class BookmarkManager: cursor.execute(''' INSERT OR REPLACE INTO bookmarks (book_id, book_path, book_title, chapter_index, paragraph_index, - sentence_index, last_accessed, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, + sentence_index, audio_position, last_accessed, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM bookmarks WHERE book_id = ?), ?)) ''', (bookId, str(bookPath), bookTitle, chapterIndex, paragraphIndex, - sentenceIndex, timestamp, bookId, timestamp)) + sentenceIndex, audioPosition, timestamp, bookId, timestamp)) conn.commit() conn.close() @@ -104,7 +126,7 @@ class BookmarkManager: cursor.execute(''' SELECT chapter_index, paragraph_index, sentence_index, - book_title, last_accessed + book_title, last_accessed, audio_position FROM bookmarks WHERE book_id = ? ''', (bookId,)) @@ -118,7 +140,8 @@ class BookmarkManager: 'paragraphIndex': row[1], 'sentenceIndex': row[2], 'bookTitle': row[3], - 'lastAccessed': row[4] + 'lastAccessed': row[4], + 'audioPosition': row[5] if row[5] is not None else 0.0 } return None @@ -172,3 +195,125 @@ class BookmarkManager: }) return bookmarks + + def create_named_bookmark(self, bookPath, name, chapterIndex, paragraphIndex, audioPosition=0.0): + """ + Create a named bookmark for a book + + Args: + bookPath: Path to book file + name: Bookmark name + chapterIndex: Chapter index + paragraphIndex: Paragraph index + audioPosition: Audio position in seconds (default: 0.0) + + Returns: + True if created successfully, False if name already exists + """ + bookId = self._get_book_id(bookPath) + timestamp = datetime.now().isoformat() + + conn = sqlite3.connect(self.dbPath) + cursor = conn.cursor() + + try: + cursor.execute(''' + INSERT INTO named_bookmarks + (book_id, name, chapter_index, paragraph_index, audio_position, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp)) + + conn.commit() + conn.close() + return True + + except sqlite3.IntegrityError: + # Bookmark with this name already exists + conn.close() + return False + + def get_named_bookmarks(self, bookPath): + """ + Get all named bookmarks for a book + + Args: + bookPath: Path to book file + + Returns: + List of named bookmark dictionaries + """ + bookId = self._get_book_id(bookPath) + + conn = sqlite3.connect(self.dbPath) + cursor = conn.cursor() + + cursor.execute(''' + SELECT id, name, chapter_index, paragraph_index, audio_position, created_at + FROM named_bookmarks + WHERE book_id = ? + ORDER BY created_at DESC + ''', (bookId,)) + + rows = cursor.fetchall() + conn.close() + + bookmarks = [] + for row in rows: + bookmarks.append({ + 'id': row[0], + 'name': row[1], + 'chapterIndex': row[2], + 'paragraphIndex': row[3], + 'audioPosition': row[4], + 'createdAt': row[5] + }) + + return bookmarks + + def delete_named_bookmark(self, bookmarkId): + """ + Delete a named bookmark by ID + + Args: + bookmarkId: Bookmark ID + """ + conn = sqlite3.connect(self.dbPath) + cursor = conn.cursor() + + cursor.execute('DELETE FROM named_bookmarks WHERE id = ?', (bookmarkId,)) + + conn.commit() + conn.close() + + def get_named_bookmark_by_id(self, bookmarkId): + """ + Get a named bookmark by ID + + Args: + bookmarkId: Bookmark ID + + Returns: + Bookmark dictionary or None if not found + """ + conn = sqlite3.connect(self.dbPath) + cursor = conn.cursor() + + cursor.execute(''' + SELECT name, chapter_index, paragraph_index, audio_position + FROM named_bookmarks + WHERE id = ? + ''', (bookmarkId,)) + + row = cursor.fetchone() + conn.close() + + if row: + return { + 'name': row[0], + 'chapterIndex': row[1], + 'paragraphIndex': row[2], + 'audioPosition': row[3] + } + + return None + diff --git a/src/bookmarks_menu.py b/src/bookmarks_menu.py new file mode 100644 index 0000000..e2da772 --- /dev/null +++ b/src/bookmarks_menu.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Bookmarks Menu + +Interactive menu for managing named bookmarks. +Allows creating, browsing, and jumping to bookmarks. +""" + + +class BookmarksMenu: + """Menu for named bookmarks""" + + def __init__(self, bookmarkManager, speechEngine=None): + """ + Initialize bookmarks menu + + Args: + bookmarkManager: BookmarkManager instance + speechEngine: SpeechEngine instance for accessibility + """ + self.bookmarkManager = bookmarkManager + self.speechEngine = speechEngine + + # Menu state + self.inMenu = False + self.currentView = 'list' # 'list' or 'create' + self.currentSelection = 0 + self.bookmarks = [] + + # Current book context + self.currentBookPath = None + + # Menu options for list view + self.listOptions = [] # Will be populated with bookmarks + "Create new" + + def enter_menu(self, bookPath): + """ + Enter the bookmarks menu for a specific book + + Args: + bookPath: Path to current book + """ + self.inMenu = True + self.currentBookPath = bookPath + self.currentView = 'list' + self.currentSelection = 0 + + # Load bookmarks for this book + self._load_bookmarks() + + if self.speechEngine: + if len(self.bookmarks) > 0: + self.speechEngine.speak(f"Bookmarks. {len(self.bookmarks)} bookmarks found. Use arrow keys to navigate.") + else: + self.speechEngine.speak("Bookmarks. No bookmarks yet. Press Enter to create one.") + + self._speak_current_item() + + def _load_bookmarks(self): + """Load bookmarks for current book""" + if not self.currentBookPath: + self.bookmarks = [] + self.listOptions = [] + return + + self.bookmarks = self.bookmarkManager.get_named_bookmarks(self.currentBookPath) + + # Build list options: bookmarks + "Create new bookmark" + self.listOptions = [] + for bookmark in self.bookmarks: + self.listOptions.append({ + 'type': 'bookmark', + 'data': bookmark + }) + + # Add "Create new" option + self.listOptions.append({ + 'type': 'create', + 'data': None + }) + + def navigate_menu(self, direction): + """Navigate menu up or down""" + if not self.listOptions: + return + + if direction == 'up': + self.currentSelection = (self.currentSelection - 1) % len(self.listOptions) + elif direction == 'down': + self.currentSelection = (self.currentSelection + 1) % len(self.listOptions) + + self._speak_current_item() + + def _speak_current_item(self): + """Speak current item""" + if not self.speechEngine or not self.listOptions: + return + + if self.currentSelection >= len(self.listOptions): + return + + option = self.listOptions[self.currentSelection] + + if option['type'] == 'bookmark': + bookmark = option['data'] + name = bookmark['name'] + text = f"{name}, bookmark {self.currentSelection + 1} of {len(self.listOptions)}" + self.speechEngine.speak(text) + + elif option['type'] == 'create': + text = f"Create new bookmark, {self.currentSelection + 1} of {len(self.listOptions)}" + self.speechEngine.speak(text) + + def activate_current_item(self): + """ + Activate current item + + Returns: + Dictionary with action and bookmark data, or None + """ + if not self.listOptions: + return None + + if self.currentSelection >= len(self.listOptions): + return None + + option = self.listOptions[self.currentSelection] + + if option['type'] == 'bookmark': + # Jump to bookmark + bookmark = option['data'] + return { + 'action': 'jump', + 'bookmark': bookmark + } + + elif option['type'] == 'create': + # Create new bookmark + return { + 'action': 'create' + } + + return None + + def delete_current_bookmark(self): + """ + Delete currently selected bookmark + + Returns: + True if deleted, False otherwise + """ + if not self.listOptions: + return False + + if self.currentSelection >= len(self.listOptions): + return False + + option = self.listOptions[self.currentSelection] + + if option['type'] == 'bookmark': + bookmark = option['data'] + bookmarkId = bookmark['id'] + bookmarkName = bookmark['name'] + + # Delete from database + self.bookmarkManager.delete_named_bookmark(bookmarkId) + + if self.speechEngine: + self.speechEngine.speak(f"Deleted bookmark: {bookmarkName}") + + # Reload bookmarks + self._load_bookmarks() + + # Adjust selection if needed + if self.currentSelection >= len(self.listOptions): + self.currentSelection = max(0, len(self.listOptions) - 1) + + self._speak_current_item() + return True + + return False + + def is_in_menu(self): + """Check if currently in menu""" + return self.inMenu + + def exit_menu(self): + """Exit the menu""" + self.inMenu = False + self.currentView = 'list' + self.currentSelection = 0 + self.bookmarks = [] + self.listOptions = [] + if self.speechEngine: + self.speechEngine.speak("Closed bookmarks") diff --git a/src/config_manager.py b/src/config_manager.py index 772227f..b13d693 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -62,6 +62,16 @@ class ConfigManager: 'library_directory': '' } + self.config['Audiobookshelf'] = { + 'server_url': '', + 'username': '', + 'auth_token': '', + 'auto_sync': 'true', + 'sync_interval': '30', + 'prefer_local': 'true', + 'stream_cache_limit': '500' + } + self.save() def get(self, section, key, fallback=None): @@ -219,3 +229,80 @@ class ConfigManager: """Set library directory""" self.set('Paths', 'library_directory', str(libraryDir)) self.save() + + # Audiobookshelf settings + + def get_abs_server_url(self): + """Get Audiobookshelf server URL""" + return self.get('Audiobookshelf', 'server_url', '') + + def set_abs_server_url(self, serverUrl): + """Set Audiobookshelf server URL""" + self.set('Audiobookshelf', 'server_url', str(serverUrl)) + self.save() + + def get_abs_username(self): + """Get Audiobookshelf username""" + return self.get('Audiobookshelf', 'username', '') + + def set_abs_username(self, username): + """Set Audiobookshelf username""" + self.set('Audiobookshelf', 'username', str(username)) + self.save() + + def get_abs_auth_token(self): + """Get Audiobookshelf authentication token""" + return self.get('Audiobookshelf', 'auth_token', '') + + def set_abs_auth_token(self, token): + """Set Audiobookshelf authentication token""" + self.set('Audiobookshelf', 'auth_token', str(token)) + self.save() + + def get_abs_auto_sync(self): + """Get Audiobookshelf auto-sync setting""" + return self.get_bool('Audiobookshelf', 'auto_sync', True) + + def set_abs_auto_sync(self, enabled): + """Set Audiobookshelf auto-sync setting""" + self.set('Audiobookshelf', 'auto_sync', str(enabled).lower()) + self.save() + + def get_abs_sync_interval(self): + """Get Audiobookshelf sync interval in seconds""" + try: + return int(self.get('Audiobookshelf', 'sync_interval', '30')) + except ValueError: + return 30 + + def set_abs_sync_interval(self, seconds): + """Set Audiobookshelf sync interval in seconds""" + self.set('Audiobookshelf', 'sync_interval', str(seconds)) + self.save() + + def get_abs_prefer_local(self): + """Get Audiobookshelf prefer local books setting""" + return self.get_bool('Audiobookshelf', 'prefer_local', True) + + def set_abs_prefer_local(self, enabled): + """Set Audiobookshelf prefer local books setting""" + self.set('Audiobookshelf', 'prefer_local', str(enabled).lower()) + self.save() + + def get_abs_stream_cache_limit(self): + """Get Audiobookshelf stream cache limit in MB""" + try: + return int(self.get('Audiobookshelf', 'stream_cache_limit', '500')) + except ValueError: + return 500 + + def set_abs_stream_cache_limit(self, limitMb): + """Set Audiobookshelf stream cache limit in MB""" + self.set('Audiobookshelf', 'stream_cache_limit', str(limitMb)) + self.save() + + def is_abs_configured(self): + """Check if Audiobookshelf server is configured""" + serverUrl = self.get_abs_server_url() + username = self.get_abs_username() + return bool(serverUrl and username) diff --git a/src/options_menu.py b/src/options_menu.py index 20f7cea..b07390a 100644 --- a/src/options_menu.py +++ b/src/options_menu.py @@ -65,6 +65,10 @@ class OptionsMenu: showText = self.config.get_show_text() showTextLabel = "Show Text Display: On" if showText else "Show Text Display: Off" + # Add Audiobookshelf setup status + absConfigured = self.config.is_abs_configured() + absLabel = "Audiobookshelf: Configured" if absConfigured else "Audiobookshelf: Not Configured" + menuItems.extend([ { 'label': showTextLabel, @@ -74,6 +78,10 @@ class OptionsMenu: 'label': "Speech Rate Settings", 'action': 'speech_rate' }, + { + 'label': absLabel, + 'action': 'audiobookshelf_setup' + }, { 'label': "Back", 'action': 'back' @@ -124,6 +132,8 @@ class OptionsMenu: return self._toggle_show_text() elif action == 'speech_rate': return self._speech_rate_info() + elif action == 'audiobookshelf_setup': + return self._audiobookshelf_setup() elif action == 'back': self.speechEngine.speak("Closing options menu") return False @@ -483,6 +493,96 @@ class OptionsMenu: if menuItems and self.currentSelection < len(menuItems): self.speechEngine.speak(menuItems[self.currentSelection]['label']) + def _audiobookshelf_setup(self): + """Setup Audiobookshelf server connection""" + from src.ui import get_input + + self.speechEngine.speak("Audiobookshelf setup starting.") + + # Show current settings if configured + currentUrl = "" + currentUser = "" + if self.config.is_abs_configured(): + currentUrl = self.config.get_abs_server_url() + currentUser = self.config.get_abs_username() + self.speechEngine.speak(f"Current server: {currentUrl}. Current username: {currentUser}. Leave fields blank to keep current values.") + + # Get server URL + serverUrlPrompt = "Enter Audiobookshelf server URL. Example: https colon slash slash abs dot example dot com" + serverUrl = get_input(self.speechEngine, serverUrlPrompt, currentUrl) + + if serverUrl is None: + self.speechEngine.speak("Setup cancelled.") + return True + + serverUrl = serverUrl.strip() + + if not serverUrl and self.config.is_abs_configured(): + serverUrl = currentUrl + self.speechEngine.speak(f"Using current URL: {serverUrl}") + elif not serverUrl: + self.speechEngine.speak("Server URL required. Setup cancelled.") + return True + + # Validate URL format + if not serverUrl.startswith(('http://', 'https://')): + self.speechEngine.speak("Invalid URL. Must start with http or https. Setup cancelled.") + return True + + # Get username + usernamePrompt = "Enter Audiobookshelf username" + username = get_input(self.speechEngine, usernamePrompt, currentUser) + + if username is None: + self.speechEngine.speak("Setup cancelled.") + return True + + username = username.strip() + + if not username and self.config.is_abs_configured(): + username = currentUser + self.speechEngine.speak(f"Using current username: {username}") + elif not username: + self.speechEngine.speak("Username required. Setup cancelled.") + return True + + # Get password + passwordPrompt = "Enter password for testing connection. Note: Password is NOT saved, only the authentication token." + password = get_input(self.speechEngine, passwordPrompt, "") + + if password is None: + self.speechEngine.speak("Setup cancelled.") + return True + + if not password: + self.speechEngine.speak("Password required. Setup cancelled.") + return True + + # Test connection + self.speechEngine.speak("Testing connection. Please wait.") + + # Import here to avoid circular dependency + from src.audiobookshelf_client import AudiobookshelfClient + + # Create temporary client to test + testClient = AudiobookshelfClient(serverUrl, None) + + if not testClient.login(username, password): + self.speechEngine.speak("Login failed. Check server URL, username, and password. Make sure the server is reachable and credentials are correct.") + return True + + # Login successful - get token + authToken = testClient.authToken + + # Save settings + self.config.set_abs_server_url(serverUrl) + self.config.set_abs_username(username) + self.config.set_abs_auth_token(authToken) + + self.speechEngine.speak("Setup successful. Audiobookshelf configured. You can now press a to browse your Audiobookshelf library.") + + return True + def exit_menu(self): """Exit the menu""" self.inMenu = False diff --git a/src/pygame_player.py b/src/pygame_player.py index 1c6c5e7..53d48bb 100644 --- a/src/pygame_player.py +++ b/src/pygame_player.py @@ -19,6 +19,9 @@ class PygamePlayer: self.isInitialized = False self.isPaused = False self.currentSound = None # Track current sound for cleanup + self.audioFileLoaded = False # Track if audio file is loaded + self.audioFilePath = None # Current audio file path + self.tempAudioFile = None # Temporary transcoded audio file try: # Initialize pygame mixer only (not full pygame) @@ -106,10 +109,20 @@ class PygamePlayer: def cleanup(self): """Cleanup resources""" + # Clean up audio file playback state + if self.audioFileLoaded: + # Note: We don't delete cached files - they're kept for future use + self.tempAudioFile = None + self.audioFileLoaded = False + self.audioFilePath = None + if self.isInitialized: # Stop and cleanup current sound if self.currentSound: - self.currentSound.stop() + try: + self.currentSound.stop() + except Exception: + pass # Mixer may be shutting down del self.currentSound self.currentSound = None pygame.mixer.quit() @@ -118,3 +131,389 @@ class PygamePlayer: def is_available(self): """Check if pygame mixer is available""" return self.isInitialized + + # Audio file playback methods (for audiobooks) + + def load_audio_file(self, audioPath, authToken=None): + """ + Load an audio file for streaming playback + + Args: + audioPath: Path to audio file or URL + authToken: Optional Bearer token for authenticated URLs + + Returns: + True if loaded successfully + """ + if not self.isInitialized: + return False + + from pathlib import Path + audioPath = str(audioPath) # Ensure it's a string + + # Check if this is a URL (for streaming from Audiobookshelf) + isUrl = audioPath.startswith('http://') or audioPath.startswith('https://') + + if isUrl: + # Use ffmpeg for streaming from URLs + print(f"DEBUG: Loading URL for streaming") + return self._load_url_with_ffmpeg(audioPath, authToken=authToken) + + # Local file - use existing logic + fileSuffix = Path(audioPath).suffix.lower() + + try: + # Stop any current playback and clean up temp files + self.stop_audio_file() + + # Try to load audio file directly using pygame.mixer.music + pygame.mixer.music.load(audioPath) + self.audioFileLoaded = True + self.audioFilePath = audioPath + return True + + except Exception as e: + print(f"Direct load failed: {e}") + + # Try transcoding with ffmpeg if direct load failed + if "ModPlug_Load failed" in str(e) or "Unrecognized" in str(e): + print(f"Attempting to transcode {fileSuffix} with ffmpeg...") + return self._load_with_ffmpeg_transcode(audioPath) + + # Unknown error + print(f"Error loading audio file: {e}") + self.audioFileLoaded = False + return False + + def _load_url_with_ffmpeg(self, streamUrl, authToken=None): + """ + Stream from URL using ffmpeg to transcode to cache + + Args: + streamUrl: URL to stream from (e.g., Audiobookshelf URL) + authToken: Optional Bearer token for authentication + + Returns: + True if successful + """ + import subprocess + import shutil + import hashlib + from pathlib import Path + + # Check if ffmpeg is available + if not shutil.which('ffmpeg'): + print("\nffmpeg not found. Falling back to direct download...") + print("Install ffmpeg for better streaming: sudo pacman -S ffmpeg") + return False + + # Set up cache directory + cacheDir = Path.home() / '.cache' / 'bookstorm' / 'audiobookshelf' + cacheDir.mkdir(parents=True, exist_ok=True) + + # Generate cache filename from hash of URL (without token for consistency) + # Extract base URL without token parameter + baseUrl = streamUrl.split('?')[0] if '?' in streamUrl else streamUrl + urlHash = hashlib.sha256(baseUrl.encode()).hexdigest()[:16] + cachedPath = cacheDir / f"{urlHash}.ogg" + + # Check if cached version exists + if cachedPath.exists(): + print(f"\nUsing cached stream") + try: + pygame.mixer.music.load(str(cachedPath)) + self.audioFileLoaded = True + self.audioFilePath = streamUrl + self.tempAudioFile = str(cachedPath) + print("Cached file loaded! Starting playback...") + return True + except Exception as e: + print(f"Cached file corrupted, re-downloading: {e}") + cachedPath.unlink(missing_ok=True) + + # No cache available, stream and transcode + try: + print(f"\nStreaming from server...") + print("Transcoding to cache. This will take a moment.") + print(f"(Cached for future use in {cacheDir})\n") + + # Build ffmpeg command with authentication headers if token provided + ffmpegCmd = ['ffmpeg'] + + # Add authentication header for Audiobookshelf + if authToken: + # ffmpeg needs headers in the format "Name: Value\r\n" + authHeader = f"Authorization: Bearer {authToken}" + ffmpegCmd.extend(['-headers', authHeader]) + print(f"DEBUG: Using Bearer token authentication") + + ffmpegCmd.extend([ + '-i', streamUrl, + '-vn', # No video + '-c:a', 'libvorbis', + '-q:a', '4', # Medium quality + '-threads', '0', # Use all CPU cores + '-y', + str(cachedPath) + ]) + + print(f"DEBUG: ffmpeg command: {' '.join(ffmpegCmd[:6])}...") + + # Run ffmpeg with progress output + result = subprocess.run( + ffmpegCmd, + capture_output=False, # Show progress to user + text=True, + timeout=1800 # 30 minute timeout for large audiobooks + ) + + if result.returncode != 0: + print(f"\nStreaming/transcoding failed (exit code {result.returncode})") + cachedPath.unlink(missing_ok=True) + return False + + # Try to load the transcoded file + try: + pygame.mixer.music.load(str(cachedPath)) + self.audioFileLoaded = True + self.audioFilePath = streamUrl # Keep URL for reference + self.tempAudioFile = str(cachedPath) + print("\nStream cached successfully!") + print("Starting playback...") + return True + + except Exception as e: + print(f"Error loading transcoded stream: {e}") + cachedPath.unlink(missing_ok=True) + return False + + except subprocess.TimeoutExpired: + print("\nStreaming timed out (file too large or connection too slow)") + cachedPath.unlink(missing_ok=True) + return False + except KeyboardInterrupt: + print("\nStreaming cancelled by user") + cachedPath.unlink(missing_ok=True) + return False + except Exception as e: + print(f"Error during streaming: {e}") + cachedPath.unlink(missing_ok=True) + return False + + def _load_with_ffmpeg_transcode(self, audioPath, fastMode=False): + """ + Transcode audio file using ffmpeg and load the result + + Args: + audioPath: Path to original audio file + fastMode: If True, use faster/lower quality settings + + Returns: + True if successful + """ + import subprocess + import shutil + import hashlib + from pathlib import Path + + # Check if ffmpeg is available + if not shutil.which('ffmpeg'): + print("\nffmpeg not found. Please install ffmpeg or convert the file manually:") + print(f" ffmpeg -i '{audioPath}' -c:a libmp3lame -q:a 2 output.mp3") + return False + + # Set up persistent cache directory + cacheDir = Path.home() / '.cache' / 'bookstorm' / 'audio' + cacheDir.mkdir(parents=True, exist_ok=True) + + # Generate cache filename from hash of original file path + pathHash = hashlib.sha256(str(Path(audioPath).resolve()).encode()).hexdigest()[:16] + cachedPath = cacheDir / f"{pathHash}.ogg" + + # Check if cached version exists + if cachedPath.exists(): + print(f"\nUsing cached transcoded file for {Path(audioPath).name}") + try: + pygame.mixer.music.load(str(cachedPath)) + self.audioFileLoaded = True + self.audioFilePath = audioPath + self.tempAudioFile = str(cachedPath) + print("Cached file loaded! Starting playback...") + return True + except Exception as e: + print(f"Cached file corrupted, re-transcoding: {e}") + cachedPath.unlink(missing_ok=True) + + # No cache available, transcode the file + try: + print(f"\nTranscoding {Path(audioPath).name}...") + print("This will take a moment. Press Ctrl+C to cancel.") + print(f"(Cached for future use in {cacheDir})\n") + + # Build ffmpeg command + if fastMode: + # Fast mode: lower quality, faster encoding + ffmpegCmd = [ + 'ffmpeg', + '-i', audioPath, + '-vn', # No video + '-c:a', 'libvorbis', + '-q:a', '1', # Lower quality (0-10, lower is better) + '-threads', '0', # Use all CPU cores + '-y', + str(cachedPath) + ] + else: + # Normal mode: balanced quality + ffmpegCmd = [ + 'ffmpeg', + '-i', audioPath, + '-vn', + '-c:a', 'libvorbis', + '-q:a', '4', # Medium quality + '-threads', '0', + '-y', + str(cachedPath) + ] + + # Run ffmpeg with progress output + result = subprocess.run( + ffmpegCmd, + capture_output=False, # Show progress to user + text=True, + timeout=600 # 10 minute timeout + ) + + if result.returncode != 0: + print(f"\nTranscoding failed (exit code {result.returncode})") + cachedPath.unlink(missing_ok=True) + return False + + # Try to load the transcoded file + try: + pygame.mixer.music.load(str(cachedPath)) + self.audioFileLoaded = True + self.audioFilePath = audioPath # Keep original path for reference + self.tempAudioFile = str(cachedPath) + print("\nTranscoding complete! Cached for future use.") + print("Starting playback...") + return True + + except Exception as e: + print(f"Error loading transcoded file: {e}") + cachedPath.unlink(missing_ok=True) + return False + + except subprocess.TimeoutExpired: + print("\nTranscoding timed out (file too large or system too slow)") + cachedPath.unlink(missing_ok=True) + return False + except KeyboardInterrupt: + print("\nTranscoding cancelled by user") + cachedPath.unlink(missing_ok=True) + return False + except Exception as e: + print(f"Error during transcoding: {e}") + cachedPath.unlink(missing_ok=True) + return False + + def play_audio_file(self, startPosition=0.0): + """ + Play loaded audio file from a specific position + + Args: + startPosition: Start time in seconds + + Returns: + True if playback started successfully + """ + if not self.isInitialized or not self.audioFileLoaded: + return False + + try: + # Start playback + pygame.mixer.music.play(start=startPosition) + self.isPaused = False + return True + + except Exception as e: + print(f"Error playing audio file: {e}") + return False + + def pause_audio_file(self): + """Pause audio file playback""" + if self.isInitialized and self.audioFileLoaded: + pygame.mixer.music.pause() + self.isPaused = True + + def resume_audio_file(self): + """Resume audio file playback""" + if self.isInitialized and self.audioFileLoaded: + pygame.mixer.music.unpause() + self.isPaused = False + + def stop_audio_file(self): + """Stop audio file playback""" + # Only stop if mixer is initialized + if self.isInitialized and self.audioFileLoaded: + try: + pygame.mixer.music.stop() + except Exception: + pass # Mixer may already be shut down + self.isPaused = False + + # Note: We don't delete tempAudioFile anymore since it's a persistent cache + # The cache files are kept in ~/.cache/bookstorm/audio/ for future use + + def is_audio_file_playing(self): + """Check if audio file is currently playing""" + if not self.isInitialized or not self.audioFileLoaded: + return False + return pygame.mixer.music.get_busy() + + def get_audio_position(self): + """ + Get current playback position in milliseconds + + Returns: + Position in milliseconds, or 0.0 if not playing + """ + if not self.isInitialized or not self.audioFileLoaded: + return 0.0 + + # pygame.mixer.music.get_pos() returns time in milliseconds + return pygame.mixer.music.get_pos() / 1000.0 + + def seek_audio(self, position): + """ + Seek to a specific position in the audio file + + Args: + position: Position in seconds + + Note: + pygame.mixer.music doesn't support direct seeking. + We need to stop and restart from the position. + """ + if not self.isInitialized or not self.audioFileLoaded: + return False + + try: + # Stop current playback + pygame.mixer.music.stop() + + # Restart from new position + pygame.mixer.music.play(start=position) + self.isPaused = False + return True + + except Exception as e: + print(f"Error seeking audio: {e}") + return False + + def unload_audio_file(self): + """Unload the current audio file""" + if self.audioFileLoaded: + self.stop_audio_file() # This also cleans up temp files + self.audioFileLoaded = False + self.audioFilePath = None diff --git a/src/recent_books_menu.py b/src/recent_books_menu.py new file mode 100644 index 0000000..3fa36b7 --- /dev/null +++ b/src/recent_books_menu.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Recent Books Menu + +Displays the 10 most recently accessed books for quick access. +""" + +from pathlib import Path + + +class RecentBooksMenu: + """Recent books selection interface""" + + def __init__(self, bookmarkManager, speechEngine=None): + """ + Initialize recent books menu + + Args: + bookmarkManager: BookmarkManager instance for fetching recent books + speechEngine: SpeechEngine instance for accessibility + """ + self.bookmarkManager = bookmarkManager + self.speechEngine = speechEngine + self.currentSelection = 0 + self.inMenu = False + self.items = [] + + def enter_menu(self): + """Enter the recent books menu""" + self.inMenu = True + self.currentSelection = 0 + + # Get recent books from bookmarks + allBookmarks = self.bookmarkManager.list_bookmarks() + + # Limit to 10 most recent, filter out non-existent files + self.items = [] + for bookmark in allBookmarks[:10]: + bookPath = Path(bookmark['bookPath']).resolve() + if bookPath.exists(): + self.items.append({ + 'path': bookPath, + 'title': bookmark['bookTitle'] or bookPath.name, + 'lastAccessed': bookmark['lastAccessed'] + }) + + if self.speechEngine: + self.speechEngine.speak("Recent books. Use arrow keys to navigate, Enter to select, Escape to cancel.") + + if self.items: + self._speak_current_item() + elif self.speechEngine: + self.speechEngine.speak("No recent books found") + + def navigate_menu(self, direction): + """Navigate menu up or down""" + if not self.items: + return + + if direction == 'up': + self.currentSelection = (self.currentSelection - 1) % len(self.items) + elif direction == 'down': + self.currentSelection = (self.currentSelection + 1) % len(self.items) + + self._speak_current_item() + + def _speak_current_item(self): + """Speak current item""" + if not self.items or not self.speechEngine: + return + + item = self.items[self.currentSelection] + # Speak title and position + text = f"{item['title']}, {self.currentSelection + 1} of {len(self.items)}" + # speak() has interrupt=True by default, which stops any current speech + self.speechEngine.speak(text) + + def activate_current_item(self): + """ + Activate current item (return book path) + + Returns: + Book path if selected, None if no items + """ + if not self.items: + if self.speechEngine: + self.speechEngine.speak("No items") + return None + + item = self.items[self.currentSelection] + + if self.speechEngine: + self.speechEngine.speak(f"Loading: {item['title']}") + + return str(item['path']) + + def is_in_menu(self): + """Check if currently in menu""" + return self.inMenu + + def exit_menu(self): + """Exit the menu""" + self.inMenu = False + if self.speechEngine: + self.speechEngine.speak("Cancelled") diff --git a/src/server_link_manager.py b/src/server_link_manager.py new file mode 100644 index 0000000..793c782 --- /dev/null +++ b/src/server_link_manager.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Server Link Manager + +Manages sidecar files that link local books to Audiobookshelf server books. +Enables progress sync and prevents duplicate downloads. +""" + +import json +import hashlib +from pathlib import Path +from datetime import datetime +from typing import Optional, Dict + + +class ServerLinkManager: + """Manages server link metadata for local books""" + + def __init__(self): + """Initialize server link manager""" + # Create sidecar directory + homePath = Path.home() + self.sidecarDir = homePath / ".bookstorm" / "server_links" + self.sidecarDir.mkdir(parents=True, exist_ok=True) + + def _get_book_hash(self, bookPath: str) -> str: + """Generate hash from book path for sidecar filename""" + pathStr = str(Path(bookPath).resolve()) + return hashlib.sha256(pathStr.encode()).hexdigest()[:16] + + def create_link(self, bookPath: str, serverUrl: str, serverId: str, libraryId: str, + title: str = "", author: str = "", duration: float = 0.0, + chapters: int = 0, manualOverride: bool = False): + """ + Create server link for a local book + + Args: + bookPath: Path to local book file + serverUrl: Audiobookshelf server URL + serverId: Server's library item ID + libraryId: Server's library ID + title: Book title + author: Author name + duration: Audio duration in seconds + chapters: Number of chapters + manualOverride: True if user manually linked despite mismatch + """ + bookHash = self._get_book_hash(bookPath) + sidecarPath = self.sidecarDir / f"{bookHash}.json" + + linkData = { + 'server_url': serverUrl, + 'server_id': serverId, + 'library_id': libraryId, + 'local_path': str(Path(bookPath).resolve()), + 'linked_at': datetime.now().isoformat(), + 'validation': { + 'duration': duration, + 'chapters': chapters, + 'title': title, + 'author': author + }, + 'manual_override': manualOverride + } + + with open(sidecarPath, 'w') as f: + json.dump(linkData, f, indent=2) + + print(f"Created server link: {sidecarPath}") + + def get_link(self, bookPath: str) -> Optional[Dict]: + """ + Get server link for a local book + + Args: + bookPath: Path to local book file + + Returns: + Link data dictionary, or None if no link exists + """ + bookHash = self._get_book_hash(bookPath) + sidecarPath = self.sidecarDir / f"{bookHash}.json" + + if not sidecarPath.exists(): + return None + + try: + with open(sidecarPath, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError) as e: + print(f"Error reading server link: {e}") + return None + + def has_link(self, bookPath: str) -> bool: + """Check if book has server link""" + return self.get_link(bookPath) is not None + + def find_by_server_id(self, serverId: str) -> Optional[str]: + """ + Find local book path by server ID + + Args: + serverId: Server's library item ID + + Returns: + Local book path if found, None otherwise + """ + # Search all sidecar files + for sidecarPath in self.sidecarDir.glob("*.json"): + try: + with open(sidecarPath, 'r') as f: + linkData = json.load(f) + if linkData.get('server_id') == serverId: + localPath = linkData.get('local_path') + # Verify file still exists + if localPath and Path(localPath).exists(): + return localPath + except (json.JSONDecodeError, IOError): + continue + + return None + + def delete_link(self, bookPath: str): + """Delete server link for a book""" + bookHash = self._get_book_hash(bookPath) + sidecarPath = self.sidecarDir / f"{bookHash}.json" + + if sidecarPath.exists(): + sidecarPath.unlink() + print(f"Deleted server link: {sidecarPath}") diff --git a/src/speech_engine.py b/src/speech_engine.py index c07c255..4f89866 100644 --- a/src/speech_engine.py +++ b/src/speech_engine.py @@ -43,6 +43,16 @@ class SpeechEngine: else: print("Warning: python3-speechd not installed. UI will not be accessible.") + def close(self): + """Close speech-dispatcher connection""" + if self.client: + try: + self.client.close() + except: + pass + self.client = None + self.isAvailable = False + def speak(self, text, interrupt=True): """ Speak text using speech-dispatcher diff --git a/src/ui.py b/src/ui.py new file mode 100644 index 0000000..c0e886a --- /dev/null +++ b/src/ui.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +UI Components for BookStorm + +Accessible UI input and display components adapted from libstormgames. +""" + +import pygame +import time + + +def get_input(speechEngine, prompt="Enter text:", text=""): + """Display an accessible text input dialog using pygame. + + Features: + - Speaks each character as typed + - Left/Right arrows navigate and speak characters + - Up/Down arrows read full text content + - Backspace announces deletions + - Enter submits, Escape cancels + - Control key repeats the original prompt message + - Fully accessible without screen reader dependency + + Args: + speechEngine: SpeechEngine instance for speaking + prompt (str): Prompt text to display (default: "Enter text:") + text (str): Initial text in input box (default: "") + + Returns: + str: User input text, or None if cancelled + """ + + # Initialize text buffer and cursor + textBuffer = list(text) # Use list for easier character manipulation + cursorPos = len(textBuffer) # Start at end of initial text + + # Announce the prompt and initial text as a single message + if text: + initialMessage = f"{prompt} Default text: {text}" + else: + initialMessage = f"{prompt} Empty text field" + speechEngine.speak(initialMessage) + + # Clear any pending events + pygame.event.clear() + + # Main input loop + while True: + event = pygame.event.wait() + + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_RETURN: + # Submit the input + result = ''.join(textBuffer) + speechEngine.speak(f"Submitted: {result if result else 'empty'}") + return result + + elif event.key == pygame.K_ESCAPE: + # Cancel input + speechEngine.speak("Cancelled") + return None + + elif event.key == pygame.K_BACKSPACE: + # Delete character before cursor + if cursorPos > 0: + deletedChar = textBuffer.pop(cursorPos - 1) + cursorPos -= 1 + speechEngine.speak(f"{deletedChar} deleted") + else: + speechEngine.speak("Nothing to delete") + + elif event.key == pygame.K_DELETE: + # Delete character at cursor + if cursorPos < len(textBuffer): + deletedChar = textBuffer.pop(cursorPos) + speechEngine.speak(f"{deletedChar} deleted") + else: + speechEngine.speak("Nothing to delete") + + elif event.key == pygame.K_LEFT: + # Move cursor left and speak character + if cursorPos > 0: + cursorPos -= 1 + if cursorPos == 0: + speechEngine.speak("Beginning of text") + else: + speechEngine.speak(textBuffer[cursorPos]) + else: + speechEngine.speak("Beginning of text") + + elif event.key == pygame.K_RIGHT: + # Move cursor right and speak character + if cursorPos < len(textBuffer): + speechEngine.speak(textBuffer[cursorPos]) + cursorPos += 1 + if cursorPos == len(textBuffer): + speechEngine.speak("End of text") + else: + speechEngine.speak("End of text") + + elif event.key == pygame.K_UP or event.key == pygame.K_DOWN: + # Read entire text content + if textBuffer: + speechEngine.speak(''.join(textBuffer)) + else: + speechEngine.speak("Empty text field") + + elif event.key == pygame.K_HOME: + # Move to beginning + cursorPos = 0 + speechEngine.speak("Beginning of text") + + elif event.key == pygame.K_END: + # Move to end + cursorPos = len(textBuffer) + speechEngine.speak("End of text") + + elif event.key == pygame.K_LCTRL or event.key == pygame.K_RCTRL: + # Repeat the original prompt message + speechEngine.speak(initialMessage) + + else: + # Handle regular character input + if event.unicode and event.unicode.isprintable(): + char = event.unicode + # Insert character at cursor position + textBuffer.insert(cursorPos, char) + cursorPos += 1 + + # Speak the character name + if char == ' ': + speechEngine.speak("space") + elif char == '\\': + speechEngine.speak("backslash") + elif char == '/': + speechEngine.speak("slash") + elif char == '!': + speechEngine.speak("exclamation mark") + elif char == '"': + speechEngine.speak("quotation mark") + elif char == '#': + speechEngine.speak("hash") + elif char == '$': + speechEngine.speak("dollar sign") + elif char == '%': + speechEngine.speak("percent") + elif char == '&': + speechEngine.speak("ampersand") + elif char == "'": + speechEngine.speak("apostrophe") + elif char == '(': + speechEngine.speak("left parenthesis") + elif char == ')': + speechEngine.speak("right parenthesis") + elif char == '*': + speechEngine.speak("asterisk") + elif char == '+': + speechEngine.speak("plus") + elif char == ',': + speechEngine.speak("comma") + elif char == '-': + speechEngine.speak("minus") + elif char == '.': + speechEngine.speak("period") + elif char == ':': + speechEngine.speak("colon") + elif char == ';': + speechEngine.speak("semicolon") + elif char == '<': + speechEngine.speak("less than") + elif char == '=': + speechEngine.speak("equals") + elif char == '>': + speechEngine.speak("greater than") + elif char == '?': + speechEngine.speak("question mark") + elif char == '@': + speechEngine.speak("at sign") + elif char == '[': + speechEngine.speak("left bracket") + elif char == ']': + speechEngine.speak("right bracket") + elif char == '^': + speechEngine.speak("caret") + elif char == '_': + speechEngine.speak("underscore") + elif char == '`': + speechEngine.speak("grave accent") + elif char == '{': + speechEngine.speak("left brace") + elif char == '|': + speechEngine.speak("pipe") + elif char == '}': + speechEngine.speak("right brace") + elif char == '~': + speechEngine.speak("tilde") + else: + # For regular letters, numbers, and other characters + speechEngine.speak(char) + + # Allow other events to be processed + pygame.event.pump() + pygame.event.clear() + time.sleep(0.001)