From badab833df2bcf91d4274f7caf55404892e184f4 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 26 Feb 2026 20:29:13 -0500 Subject: [PATCH] type to get to book added, chaning library binding to shift+l. Improved audiobookshelf support. --- README.md | 12 +- bookstorm.py | 537 +++++++++++----- requirements.txt | 1 + src/audiobookshelf_client.py | 1120 ++++++++++++++++------------------ src/audiobookshelf_menu.py | 74 ++- src/audiobookshelf_sync.py | 193 +++--- src/book_selector.py | 44 +- src/bookmark_manager.py | 128 +++- src/bookmarks_menu.py | 55 +- 9 files changed, 1309 insertions(+), 855 deletions(-) diff --git a/README.md b/README.md index e63cf35..eb0d475 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,10 @@ python bookstorm.py mybook.epub --wav --output-dir ./audiobooks ### Book Browser Controls - **UP/DOWN** - Navigate items +- **Type letters** - Jump to the first item whose name starts with what you typed - **ENTER** - Select book or enter directory - **BACKSPACE/LEFT** - Go to parent directory -- **L** - Set current directory as default library location +- **SHIFT+L** - Set current directory as default library location - **ESC** - Cancel and return ## Audiobookshelf Integration @@ -120,9 +121,10 @@ BookStorm can connect to an [Audiobookshelf](https://www.audiobookshelf.org/) se - **Browse Libraries**: Navigate multiple libraries, series, and collections - **Stream or Download**: Choose to stream directly or download for offline use -- **Progress Sync**: Your reading progress syncs between BookStorm and Audiobookshelf -- **Bookmark Sync**: Named bookmarks sync to the server (when enabled) +- **Progress Sync**: Streaming and linked local books sync playback progress to Audiobookshelf, including on quit +- **Bookmark Sync**: Named audiobook bookmarks sync to the server for linked Audiobookshelf items - **Local-First**: If you already have a book locally, BookStorm uses it automatically +- **Type-to-Search**: In the Audiobookshelf browser, type letters to jump to matching libraries or books ### Stream vs Download @@ -189,7 +191,7 @@ Bookmarks are stored in `~/.bookstorm/bookmarks.db` (SQLite database). ### Auto-Save Bookmarks -BookStorm automatically saves your position when you press **s** or quit the application. When you reopen a book, it resumes from where you left off. +BookStorm automatically saves your position when you press **s** or quit the application. When the current audiobook is linked to Audiobookshelf, that playback position is also synced to the server before shutdown. When you reopen a book, it resumes from where you left off. ### Named Bookmarks @@ -199,7 +201,7 @@ Press **k** to open the bookmarks menu where you can: - Delete bookmarks you no longer need - View all bookmarks for the current book -Named bookmarks sync to Audiobookshelf if the book is linked to a server (downloaded or manually linked). +For Audiobookshelf-linked audiobooks, BookStorm imports server bookmarks when the bookmarks menu opens and syncs newly created or deleted named bookmarks back to the server. ## Supported Formats diff --git a/bookstorm.py b/bookstorm.py index ec86222..f0369a3 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -144,6 +144,7 @@ class BookReader: self.serverBook = None # Server book metadata for streaming self.isStreaming = False # Track if currently streaming self.sessionId = None # Active listening session ID (legacy, now managed by absSync) + self.lastAbsSyncAt = 0.0 # Last periodic Audiobookshelf sync timestamp # Initialize reading engine based on config readerEngine = self.config.get_reader_engine() @@ -286,14 +287,7 @@ class BookReader: # For audio books, save exact position if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: self.savedAudioPosition = progressTime - - # Find chapter that contains this time - for i, chap in enumerate(self.book.chapters): - if hasattr(chap, 'startTime'): - chapterEnd = chap.startTime + chap.duration - if chap.startTime <= progressTime < chapterEnd: - self.currentChapter = i - break + self._set_position_from_audio_time(progressTime) else: # Text book - use chapter/paragraph from server if available # (Audiobookshelf doesn't track paragraph, so we'd need to enhance this) @@ -391,6 +385,266 @@ class BookReader: return True return False + def _get_audio_duration(self): + """Get total duration for the current audio book.""" + if not self.book or not hasattr(self.book, 'isAudioBook') or not self.book.isAudioBook: + return 0.0 + return getattr(self.book, 'totalDuration', 0.0) or 0.0 + + def _get_current_audio_position(self): + """Get the best-known absolute playback position in seconds.""" + if not self.book or not hasattr(self.book, 'isAudioBook') or not self.book.isAudioBook: + return 0.0 + + chapter = self.book.get_chapter(self.currentChapter) + chapterStartTime = chapter.startTime if chapter and hasattr(chapter, 'startTime') else 0.0 + + if self.audioPlayer.is_audio_file_loaded(): + if self.audioPlayer.is_audio_file_playing() or self.audioPlayer.is_paused(): + playbackPos = self.audioPlayer.get_audio_position() + return max(0.0, chapterStartTime + playbackPos) + + if self.savedAudioPosition > 0.0: + return self.savedAudioPosition + + return max(0.0, chapterStartTime) + + def _set_position_from_audio_time(self, audioPosition): + """Update chapter tracking from an absolute audio time.""" + if not self.book or not hasattr(self.book, 'isAudioBook') or not self.book.isAudioBook: + return + + targetPosition = max(0.0, float(audioPosition)) + lastChapterIndex = max(0, self.book.get_total_chapters() - 1) + + for chapterIndex, chapter in enumerate(self.book.chapters): + if not hasattr(chapter, 'startTime'): + continue + + chapterEnd = chapter.startTime + getattr(chapter, 'duration', 0.0) + if chapterIndex == lastChapterIndex: + if targetPosition >= chapter.startTime: + self.currentChapter = chapterIndex + return + elif chapter.startTime <= targetPosition < chapterEnd: + self.currentChapter = chapterIndex + return + + self.currentChapter = lastChapterIndex + + def _has_server_sync_target(self): + """Return True if the current book is linked to Audiobookshelf.""" + if self.isStreaming and self.absSync and self.absSync.is_active(): + return True + if not self.bookPath: + return False + serverLink = self.serverLinkManager.get_link(str(self.bookPath)) + return bool(serverLink and serverLink.get('server_id')) + + def _sync_progress_to_server(self, audioPosition=0.0, updateSession=False, isFinished=None): + """ + Sync progress to Audiobookshelf server. + + Args: + audioPosition: Current audio position in seconds + updateSession: Whether to send live session heartbeat data + isFinished: Optional finished flag override + """ + if not self.absClient or not self.absClient.is_authenticated(): + return + + currentTime = max(0.0, float(audioPosition)) + + if self.isStreaming and self.absSync and self.absSync.is_active(): + self.absSync.sync_progress(currentTime, updateSession=updateSession, isFinished=isFinished) + return + + if not self.bookPath: + return + + serverLink = self.serverLinkManager.get_link(str(self.bookPath)) + if not serverLink: + return + + serverId = serverLink.get('server_id') + if not serverId: + return + + duration = self._get_audio_duration() + if duration <= 0: + return + + progress = min(currentTime / duration, 1.0) if duration > 0 else 0.0 + if isFinished is None: + isFinished = progress >= 0.999 + + success = self.absClient.update_progress(serverId, currentTime, duration, progress=progress, isFinished=isFinished) + if success: + print(f"Progress synced to server: {progress * 100:.1f}%") + + def _maybe_sync_abs_progress(self, force=False): + """Sync progress periodically while audio is playing.""" + if not self._has_server_sync_target(): + return + + if not self.book or not hasattr(self.book, 'isAudioBook') or not self.book.isAudioBook: + return + + syncInterval = max(5, self.config.get_abs_sync_interval()) + now = time.monotonic() + if not force and self.lastAbsSyncAt and (now - self.lastAbsSyncAt) < syncInterval: + return + + audioPosition = self._get_current_audio_position() + self._sync_progress_to_server(audioPosition, updateSession=self.isStreaming) + self.lastAbsSyncAt = now + + def _close_abs_session(self, finalPosition=None, isFinished=None): + """Close the active Audiobookshelf session with final progress data.""" + currentTime = self._get_current_audio_position() if finalPosition is None else max(0.0, float(finalPosition)) + + if self.absSync and self.absSync.is_active(): + self.absSync.close_session(currentTime=currentTime, isFinished=isFinished) + self.sessionId = None + self.lastAbsSyncAt = 0.0 + return + + if self.sessionId and self.absClient: + duration = self._get_audio_duration() + progress = min(currentTime / duration, 1.0) if duration > 0 else 0.0 + self.absClient.close_session( + self.sessionId, + currentTime=currentTime, + duration=duration, + progress=progress, + timeListened=0.0 + ) + self.sessionId = None + self.lastAbsSyncAt = 0.0 + + def _save_audio_bookmark(self, audioPosition, speakFeedback=True, isFinished=None): + """Persist the current audio bookmark locally and remotely.""" + if not self.book or not self.bookPath: + return + + targetPosition = max(0.0, float(audioPosition)) + self._set_position_from_audio_time(targetPosition) + self.savedAudioPosition = targetPosition + + self.bookmarkManager.save_bookmark( + self.bookPath, + self.book.title, + self.currentChapter, + self.currentParagraph, + audioPosition=targetPosition + ) + + self._sync_progress_to_server(targetPosition, updateSession=False, isFinished=isFinished) + if self.isStreaming and self.absSync: + self.absSync.mark_progress_checkpoint(targetPosition) + + if speakFeedback: + self.speechEngine.speak("Bookmark saved") + + def _handle_finished_audiobook(self): + """Persist completion state when an audiobook reaches the end.""" + duration = self._get_audio_duration() + finalPosition = duration if duration > 0 else self._get_current_audio_position() + + self.displayText = "End of book reached" + self.speechEngine.speak("Book finished. To clear bookmarks and restart from beginning, press shift home.") + self.isPlaying = False + self._save_audio_bookmark(finalPosition, speakFeedback=False, isFinished=True) + self._close_abs_session(finalPosition=finalPosition, isFinished=True) + + if self.audioPlayer.is_audio_file_loaded(): + self.audioPlayer.stop_audio_file() + + def _get_current_server_item_id(self): + """Get the Audiobookshelf item ID for the current book, if any.""" + if self.isStreaming and self.serverBook: + return self.serverBook.get('id') or self.serverBook.get('libraryItemId') + + if not self.bookPath: + return None + + serverLink = self.serverLinkManager.get_link(str(self.bookPath)) + if not serverLink: + return None + + return serverLink.get('server_id') + + def _can_sync_named_bookmarks(self): + """Return True if named bookmarks can sync for the current book.""" + isAudioBook = self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook + return bool( + isAudioBook and + self.absClient and + self.absClient.is_authenticated() and + self._get_current_server_item_id() + ) + + def _sync_named_bookmarks_from_server(self): + """Fetch Audiobookshelf named bookmarks for the current item and cache them locally.""" + if not self._can_sync_named_bookmarks(): + return False + + itemId = self._get_current_server_item_id() + serverBookmarks = self.absClient.get_bookmarks(itemId) + if serverBookmarks is None: + return False + + originalChapter = self.currentChapter + originalParagraph = self.currentParagraph + + for serverBookmark in serverBookmarks: + if not isinstance(serverBookmark, dict): + continue + + bookmarkTime = int(serverBookmark.get('time', 0) or 0) + bookmarkTitle = serverBookmark.get('title') or f"Bookmark {bookmarkTime}" + chapterIndex = self.currentChapter + paragraphIndex = 0 + if self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: + self._set_position_from_audio_time(float(bookmarkTime)) + chapterIndex = self.currentChapter + + self.bookmarkManager.upsert_named_bookmark_from_server( + self.bookPath, + bookmarkTitle, + chapterIndex, + paragraphIndex, + audioPosition=float(bookmarkTime), + serverLibraryItemId=itemId, + serverTime=bookmarkTime, + serverCreatedAt=serverBookmark.get('createdAt') + ) + + self.currentChapter = originalChapter + self.currentParagraph = originalParagraph + return True + + def _save_named_bookmark_to_server(self, bookmarkName, audioPosition): + """Create or update a named bookmark on Audiobookshelf for the current item.""" + if not self._can_sync_named_bookmarks(): + return None + + itemId = self._get_current_server_item_id() + bookmarkTime = int(round(max(0.0, float(audioPosition)))) + return self.absClient.save_bookmark(itemId, bookmarkTime, bookmarkName) + + def _delete_named_bookmark_from_server(self, bookmark): + """Delete a synced named bookmark from Audiobookshelf.""" + itemId = bookmark.get('serverLibraryItemId') or self._get_current_server_item_id() + bookmarkTime = bookmark.get('serverTime') + if not itemId or bookmarkTime is None: + return True + + if not self.absClient or not self.absClient.is_authenticated(): + return False + + return self.absClient.delete_bookmark(itemId, int(bookmarkTime)) + def save_bookmark(self, speakFeedback=True): """Save current position as bookmark @@ -413,77 +667,22 @@ class BookReader: if playlistIndex != self.currentChapter: self.currentChapter = playlistIndex - # For audio books, calculate current playback position - audioPosition = 0.0 if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: - # Get current chapter start time - chapter = self.book.get_chapter(self.currentChapter) - if chapter and hasattr(chapter, 'startTime'): - chapterStartTime = chapter.startTime - else: - chapterStartTime = 0.0 - - # Get playback position within the audio file - if self.audioPlayer.is_audio_file_playing() or self.audioPlayer.is_paused(): - playbackPos = self.audioPlayer.get_audio_position() - # Total position = chapter start + position within current playback - audioPosition = chapterStartTime + playbackPos + audioPosition = self._get_current_audio_position() + self._save_audio_bookmark(audioPosition, speakFeedback=speakFeedback) + return self.bookmarkManager.save_bookmark( self.bookPath, self.book.title, self.currentChapter, self.currentParagraph, - audioPosition=audioPosition + audioPosition=0.0 ) - # 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 - - # For streaming books, use the sync manager - if self.isStreaming and self.absSync and self.absSync.is_active(): - self.absSync.sync_progress(audioPosition) - return - - # For downloaded/linked local books, use direct API calls - serverLink = self.serverLinkManager.get_link(str(self.bookPath)) - if not serverLink: - return - - serverId = serverLink.get('server_id') - if not serverId: - return - - # Calculate progress - duration = 0.0 - if hasattr(self.book, 'totalDuration'): - duration = self.book.totalDuration - - if duration <= 0: - return - - currentTime = audioPosition - progress = min(currentTime / duration, 1.0) if duration > 0 else 0.0 - - # Upload progress to server - success = self.absClient.update_progress(serverId, currentTime, duration, progress) - if success: - print(f"Progress synced to server: {progress * 100:.1f}%") - def reload_tts_engine(self): """Reload TTS engine with current config settings""" readerEngine = self.config.get_reader_engine() @@ -738,6 +937,8 @@ class BookReader: if self.isPlaying and not inAnyMenu and self.book: if isAudioBook: + self._maybe_sync_abs_progress() + # For multi-file audiobooks, sync playlist position periodically # This keeps currentChapter in sync with mpv's actual position if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile: @@ -754,16 +955,7 @@ class BookReader: if not self.audioPlayer.is_audio_file_playing() and not self.audioPlayer.is_paused(): # Audio chapter finished, advance to next chapter if not self.next_chapter(): - # Book finished - restart from beginning - self.displayText = "End of book reached - restarting from chapter 1" - self.speechEngine.speak("Book finished. To clear bookmarks and restart from beginning, press shift home.") - self.isPlaying = False - self.currentChapter = 0 - self.currentParagraph = 0 - self.savedAudioPosition = 0.0 - self.save_bookmark(speakFeedback=False) - # Stop playback completely - user must press SPACE to restart - self.audioPlayer.stop_audio_file() + self._handle_finished_audiobook() else: # Start next chapter self._start_paragraph_playback() @@ -874,6 +1066,7 @@ class BookReader: finally: # Save bookmark BEFORE stopping (so we can get current position) self.save_bookmark(speakFeedback=False) + self._close_abs_session() # Stop playback readerEngine = self.config.get_reader_engine() @@ -885,18 +1078,6 @@ class BookReader: if self.audioPlayer.is_audio_file_loaded(): self.audioPlayer.stop_audio_file() - # Close Audiobookshelf session if active - if self.absSync and self.absSync.is_active(): - try: - self.absSync.close_session() - except: - pass - elif self.sessionId and self.absClient: - try: - self.absClient.close_session(self.sessionId) - except: - pass - # Clean up speech engines if self.speechEngine: self.speechEngine.close() @@ -1013,8 +1194,13 @@ class BookReader: # Handle audio book pause/resume if self.audioPlayer.is_paused(): self.audioPlayer.resume_audio_file() + if self.isStreaming and self.absSync: + self.absSync.mark_progress_checkpoint(self._get_current_audio_position()) else: + self._sync_progress_to_server(self._get_current_audio_position(), updateSession=False) self.audioPlayer.pause_audio_file() + if self.isStreaming and self.absSync: + self.absSync.mark_progress_checkpoint(self._get_current_audio_position()) elif readerEngine == 'speechd': # Handle speech-dispatcher pause/resume if self.readingEngine.is_reading_paused(): @@ -1173,6 +1359,7 @@ class BookReader: elif event.key == pygame.K_k: # Open bookmarks menu if self.book: + self._sync_named_bookmarks_from_server() self.bookmarksMenu.enter_menu(str(self.bookPath)) else: self.speechEngine.speak("No book loaded") @@ -1293,11 +1480,25 @@ class BookReader: self.isPlaying = False self._stop_playback() + namedBookmarks = self.bookmarkManager.get_named_bookmarks(self.bookPath) + serverDeleteFailed = False + for bookmark in namedBookmarks: + if bookmark.get('serverTime') is None: + continue + if not self._delete_named_bookmark_from_server(bookmark): + serverDeleteFailed = True + break + # Delete ALL bookmarks for current book (auto-save + named) self.bookmarkManager.delete_bookmark(self.bookPath) # Auto-save bookmark - self.bookmarkManager.delete_all_named_bookmarks(self.bookPath) # Named bookmarks + if not serverDeleteFailed: + self.bookmarkManager.delete_all_named_bookmarks(self.bookPath) # Named bookmarks self.bookmarkCleared = True # Mark that bookmark was explicitly cleared + if serverDeleteFailed: + self.speechEngine.speak("Could not clear server bookmarks") + return + # Jump to beginning self.currentChapter = 0 self.currentParagraph = 0 @@ -1522,7 +1723,17 @@ class BookReader: elif event.key == pygame.K_DELETE or event.key == pygame.K_d: # Delete current bookmark - self.bookmarksMenu.delete_current_bookmark() + bookmark = self.bookmarksMenu.get_current_bookmark() + if not bookmark: + self.bookmarksMenu.delete_current_bookmark() + return + + if bookmark.get('serverTime') is not None: + if not self._delete_named_bookmark_from_server(bookmark): + self.speechEngine.speak("Could not delete bookmark from Audiobookshelf") + return + + self.bookmarksMenu.remove_bookmark(bookmark['id'], bookmark.get('name')) elif event.key == pygame.K_ESCAPE: self.bookmarksMenu.exit_menu() @@ -1644,8 +1855,7 @@ class BookReader: playbackPos = self.audioPlayer.get_audio_position() audioPosition = chapterStartTime + playbackPos - # Create bookmark - success = self.bookmarkManager.create_named_bookmark( + bookmarkId = self.bookmarkManager.create_named_bookmark( self.bookPath, bookmarkName, self.currentChapter, @@ -1653,18 +1863,33 @@ class BookReader: 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 bookmarkId is None: if self.speechEngine: self.speechEngine.speak(f"Bookmark name already exists: {bookmarkName}") print(f"ERROR: Bookmark with name '{bookmarkName}' already exists") + return + + serverBookmark = self._save_named_bookmark_to_server(bookmarkName, audioPosition) + if serverBookmark: + itemId = serverBookmark.get('libraryItemId') or self._get_current_server_item_id() + serverTime = int(serverBookmark.get('time', round(audioPosition))) + self.bookmarkManager.update_named_bookmark_server_data( + bookmarkId, + serverLibraryItemId=itemId, + serverTime=serverTime, + serverCreatedAt=serverBookmark.get('createdAt') + ) + message = f"Bookmark created: {bookmarkName}" + else: + message = f"Bookmark created locally: {bookmarkName}" + + if self.speechEngine: + self.speechEngine.speak(message) + print(message) + + # Reload bookmarks in menu + self.bookmarksMenu._load_bookmarks() + self.bookmarksMenu._speak_current_item() def _open_audiobookshelf_browser(self): """Open Audiobookshelf browser""" @@ -1763,6 +1988,9 @@ class BookReader: elif event.key == pygame.K_ESCAPE: if self.absMenu: self.absMenu.exit_menu() + elif event.unicode and event.unicode.isprintable() and not event.unicode.isspace(): + if self.absMenu: + self.absMenu.search_items(event.unicode) def _handle_browser_key(self, event): """Handle key events when in book browser""" @@ -1780,12 +2008,14 @@ class BookReader: elif event.key == pygame.K_BACKSPACE or event.key == pygame.K_LEFT: # Go to parent directory self.bookSelector.go_parent_directory() - elif event.key == pygame.K_l: + elif event.key == pygame.K_l and (event.mod & pygame.KMOD_SHIFT): # Set current directory as library directory currentDir = self.bookSelector.get_current_directory() self.config.set_library_directory(str(currentDir)) dirName = currentDir.name if currentDir.name else str(currentDir) self.speechEngine.speak(f"Library set to {dirName}") + elif event.unicode and event.unicode.isprintable() and not event.unicode.isspace(): + self.bookSelector.search_items(event.unicode) elif event.key == pygame.K_ESCAPE: self.bookSelector.exit_browser() @@ -1867,6 +2097,14 @@ class BookReader: Args: serverBook: Server book dictionary from API """ + if self.book: + self.save_bookmark(speakFeedback=False) + self._close_abs_session() + self.audioPlayer.stop() + if self.audioPlayer.is_audio_file_loaded(): + self.audioPlayer.stop_audio_file() + self.isPlaying = False + # Extract book metadata # Try different ID fields (structure varies by API endpoint) serverId = serverBook.get('id') or serverBook.get('libraryItemId') @@ -1898,18 +2136,23 @@ class BookReader: return streamUrl = sessionData.get('streamUrl') + streamUrls = sessionData.get('streamUrls') or ([streamUrl] if streamUrl else []) startPosition = sessionData.get('startPosition', 0.0) + playData = sessionData.get('playData', {}) # Store session ID for backward compatibility self.sessionId = sessionData.get('sessionId') # Get chapters from server (from the book details we already have) serverChapters = media.get('chapters', []) + audioTracks = playData.get('audioTracks', []) if isinstance(playData, dict) else [] # Create AudioBook object with stream URL from src.audio_parser import AudioBook, AudioChapter book = AudioBook(title=title, author=author, audioPath=streamUrl) book.totalDuration = duration + book.audioFiles = streamUrls + book.isMultiFile = len(streamUrls) > 1 # Add chapters from server if serverChapters: @@ -1924,6 +2167,32 @@ class BookReader: duration=chapterDuration ) book.add_chapter(chapter) + elif len(streamUrls) > 1: + cumulativeStart = 0.0 + for trackIndex, trackUrl in enumerate(streamUrls): + trackData = audioTracks[trackIndex] if trackIndex < len(audioTracks) and isinstance(audioTracks[trackIndex], dict) else {} + trackStart = trackData.get('startOffset') + if trackStart is None: + trackStart = trackData.get('offset') + if trackStart is None: + trackStart = trackData.get('start') + if trackStart is None: + trackStart = cumulativeStart + + trackDuration = trackData.get('duration') + if trackDuration is None: + trackDuration = trackData.get('length') + if trackDuration is None: + trackDuration = 0.0 + + chapter = AudioChapter( + title=trackData.get('title', f"Part {trackIndex + 1}"), + startTime=float(trackStart), + duration=float(trackDuration) + ) + chapter.audioPath = trackUrl + book.add_chapter(chapter) + cumulativeStart = float(trackStart) + float(trackDuration) else: # No chapters - treat entire book as single chapter chapter = AudioChapter( @@ -1933,9 +2202,13 @@ class BookReader: ) book.add_chapter(chapter) + if len(streamUrls) > 1 and duration <= 0: + book.totalDuration = sum(getattr(chapter, 'duration', 0.0) for chapter in book.chapters) + # Store server metadata for progress sync self.serverBook = serverBook self.isStreaming = True + self.lastAbsSyncAt = 0.0 # Load the book self.book = book @@ -1966,14 +2239,7 @@ class BookReader: # Use position from server (already retrieved during session start) if startPosition > 0: self.savedAudioPosition = startPosition - - # 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 <= startPosition < chapterEnd: - self.currentChapter = i - break + self._set_position_from_audio_time(startPosition) else: # No server progress, start from beginning self.currentChapter = 0 @@ -1993,14 +2259,20 @@ class BookReader: print(f"Loading stream: {streamUrl[:80]}...") self.speechEngine.speak("Loading stream. This may take a moment.") - # Load the stream URL - mpv handles streaming with auth headers - # Pass auth token for authentication and start position for resuming playbackSpeed = self.config.get_playback_speed() - if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken, - playbackSpeed=playbackSpeed, startPosition=startPos): + if len(streamUrls) > 1: + loadSuccess = self.audioPlayer.load_audio_playlist(streamUrls, authToken=self.absClient.authToken, + playbackSpeed=playbackSpeed) + else: + loadSuccess = self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken, + playbackSpeed=playbackSpeed, startPosition=startPos) + + if not loadSuccess: self.speechEngine.speak("Failed to load stream. Check terminal for errors.") print("\nERROR: Failed to load stream from server") print("Make sure mpv is installed: sudo pacman -S mpv") + self._close_abs_session(finalPosition=startPos) + self.isStreaming = False return # Restore saved volume setting @@ -2020,13 +2292,9 @@ class BookReader: if self.config.get_show_text() and hasattr(self, 'screen') and self.screen: self._render_screen() - # Start playback (position already set during load) - self.audioPlayer.play_audio_file() self.isPlaying = True self.isAudioBook = True - - # Clear saved position after using it - self.savedAudioPosition = 0.0 + self._start_audio_chapter_playback(self.book.get_chapter(self.currentChapter)) def _download_audiobook(self, serverBook): """ @@ -2038,9 +2306,9 @@ class BookReader: # 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.") + self.speechEngine.speak("Library directory not set. Please set library directory first. Press B then Shift L.") print("\nERROR: Library directory not set.") - print("Press 'b' to browse files, then 'L' to set library directory.") + print("Press 'b' to browse files, then 'Shift+L' to set library directory.") return libraryPath = Path(libraryDir) @@ -2212,6 +2480,7 @@ class BookReader: # (so we can capture the current audio position) if self.book: self.save_bookmark(speakFeedback=False) + self._close_abs_session() # Stop current playback self.audioPlayer.stop() @@ -2226,6 +2495,11 @@ class BookReader: # Reset audio position state self.savedAudioPosition = 0.0 self.bookmarkCleared = False + self.isStreaming = False + self.serverBook = None + self.lastAbsSyncAt = 0.0 + if self.absSync: + self.absSync.reset() # Load new book (which will restore bookmark if it exists) try: @@ -2560,17 +2834,7 @@ class BookReader: def cleanup(self): """Cleanup resources""" - # Close active listening session via sync manager - if self.absSync and self.absSync.is_active(): - self.absSync.close_session() - self.sessionId = None - elif self.sessionId and self.absClient: - # Fallback for legacy code path - print(f"Closing listening session: {self.sessionId}") - self.absClient.close_session(self.sessionId) - if self.bookPath: - self.serverLinkManager.clear_session(str(self.bookPath)) - self.sessionId = None + self._close_abs_session() # Save current volume setting if audio book or Piper-TTS was used if self.book: @@ -2685,13 +2949,6 @@ def main(): reader = BookReader(None, config) reader.absClient = absClient reader.absSync = AudiobookshelfSync(absClient, reader.serverLinkManager) - - # Restore session ID if it was saved - savedSessionId = cachedLink.get('session_id') - if savedSessionId: - print(f"Restoring listening session: {savedSessionId}") - reader.sessionId = savedSessionId - reader._stream_audiobook(serverBook) reader.run_interactive() return 0 diff --git a/requirements.txt b/requirements.txt index 4a8673d..e51fb62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ mutagen>=1.45.0 pypdf mpv requests>=2.25.0 +setproctitle>=1.3.0 # Braille display support (optional) # Note: These are system packages, not pip packages diff --git a/src/audiobookshelf_client.py b/src/audiobookshelf_client.py index 15ec9b0..2f42809 100644 --- a/src/audiobookshelf_client.py +++ b/src/audiobookshelf_client.py @@ -3,714 +3,614 @@ """ Audiobookshelf API Client -Handles communication with Audiobookshelf server for browsing, -streaming, and syncing audiobooks. +Handles communication with Audiobookshelf for browsing, playback, +downloads, and progress synchronization. """ -import requests -import json +from urllib.parse import urljoin from typing import Optional, Dict, List +import requests + class AudiobookshelfClient: - """Client for Audiobookshelf API""" + """Client for the Audiobookshelf API""" def __init__(self, serverUrl: str, configManager=None): """ - Initialize Audiobookshelf client + Initialize Audiobookshelf client. Args: - serverUrl: Base URL of Audiobookshelf server (e.g., https://abs.example.com) - configManager: ConfigManager instance for token storage + serverUrl: Base server URL + configManager: Optional ConfigManager for token persistence """ - # Remove trailing slash from server URL self.serverUrl = serverUrl.rstrip('/') self.configManager = configManager self.authToken = None + self.httpSession = requests.Session() + self.defaultTimeout = 30 + self.deviceInfo = { + 'deviceId': 'bookstorm', + 'clientName': 'BookStorm', + 'platform': 'linux' + } - # 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: + def _clear_auth_token(self): + """Clear the saved authentication token.""" + self.authToken = None + if self.configManager: + self.configManager.set_abs_auth_token('') + + def _build_headers(self, headers=None) -> Dict: + """Build request headers.""" + requestHeaders = {} + if headers: + requestHeaders.update(headers) + if self.authToken: + requestHeaders['Authorization'] = f'Bearer {self.authToken}' + return requestHeaders + + def _short_response_text(self, response: requests.Response) -> str: + """Return a short, printable response summary.""" + try: + text = response.text.strip() + except Exception: + text = '' + if len(text) > 300: + text = f"{text[:297]}..." + return text + + def _make_request(self, method: str, endpoint: str, expectedStatus=(200,), **kwargs) -> Optional[requests.Response]: """ - Login to Audiobookshelf server + Make an authenticated request. Args: - username: Username - password: Password + method: HTTP method + endpoint: Relative or absolute URL + expectedStatus: Acceptable HTTP status codes + **kwargs: Passed to requests Returns: - True if login successful, False otherwise + Response on success, else None """ + if endpoint.startswith('http://') or endpoint.startswith('https://'): + requestUrl = endpoint + else: + requestUrl = f"{self.serverUrl}{endpoint}" + + timeout = kwargs.pop('timeout', self.defaultTimeout) + headers = self._build_headers(kwargs.pop('headers', None)) + try: - url = f"{self.serverUrl}/login" - payload = { + response = self.httpSession.request( + method=method, + url=requestUrl, + headers=headers, + timeout=timeout, + **kwargs + ) + except requests.exceptions.RequestException as error: + print(f"Audiobookshelf request error for {method} {requestUrl}: {error}") + return None + + statusCode = getattr(response, 'status_code', None) + + if statusCode == 401: + print("Audiobookshelf authentication failed. Token may be expired.") + self._clear_auth_token() + return None + + if statusCode not in expectedStatus: + responseText = self._short_response_text(response) + print(f"Audiobookshelf API error for {method} {requestUrl}: {statusCode}") + if responseText: + print(f"Response: {responseText}") + return None + + return response + + def _request_json(self, method: str, endpoint: str, expectedStatus=(200,), **kwargs): + """Make a request and decode JSON.""" + response = self._make_request(method, endpoint, expectedStatus=expectedStatus, **kwargs) + if not response: + return None + + statusCode = getattr(response, 'status_code', None) + if statusCode == 204: + return {} + + try: + return response.json() + except ValueError as error: + print(f"Invalid JSON from Audiobookshelf endpoint {endpoint}: {error}") + return None + + def _coerce_float(self, value, default: float = 0.0) -> float: + """Safely convert a value to float.""" + try: + return float(value) + except (TypeError, ValueError): + return default + + def _extract_list(self, data, preferredKeys: List[str]) -> List[Dict]: + """Extract a list from variable API response shapes.""" + if isinstance(data, list): + return data + if not isinstance(data, dict): + return [] + + for key in preferredKeys: + value = data.get(key) + if isinstance(value, list): + return value + + return [] + + def _has_more_pages(self, data: Dict, page: int, pageSize: int, collectedCount: int) -> bool: + """Infer whether a paginated API response has more pages.""" + totalPages = data.get('totalPages') or data.get('numPages') or data.get('pages') + if isinstance(totalPages, int): + return page + 1 < totalPages + + totalItems = data.get('total') + if isinstance(totalItems, int) and pageSize > 0: + return collectedCount < totalItems + + return False + + def _normalize_url(self, url: str) -> str: + """Make a possibly relative URL absolute.""" + if not url: + return '' + return urljoin(f"{self.serverUrl}/", url) + + def _extract_stream_urls(self, playData: Dict) -> List[str]: + """Extract one or more playback URLs from a /play response.""" + streamUrls = [] + + for key in ['streamUrl', 'contentUrl', 'url']: + candidateUrl = playData.get(key) + if candidateUrl: + normalizedUrl = self._normalize_url(candidateUrl) + if normalizedUrl not in streamUrls: + streamUrls.append(normalizedUrl) + + audioTracks = playData.get('audioTracks') + if isinstance(audioTracks, list): + for audioTrack in audioTracks: + if not isinstance(audioTrack, dict): + continue + contentUrl = audioTrack.get('contentUrl') or audioTrack.get('url') + if contentUrl: + normalizedUrl = self._normalize_url(contentUrl) + if normalizedUrl not in streamUrls: + streamUrls.append(normalizedUrl) + + return streamUrls + + def is_authenticated(self) -> bool: + """Check whether a token is available.""" + return bool(self.authToken) + + def login(self, username: str, password: str) -> bool: + """Authenticate against Audiobookshelf.""" + responseData = self._request_json( + 'POST', + '/login', + expectedStatus=(200,), + headers={'Content-Type': 'application/json'}, + json={ 'username': username, 'password': password - } + }, + timeout=10 + ) - 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}") + if not isinstance(responseData, dict): return False + userData = responseData.get('user', {}) + token = userData.get('token') + if not token: + print("Audiobookshelf login succeeded but no token was returned.") + return False + + self.authToken = token + if self.configManager: + self.configManager.set_abs_auth_token(token) + self.configManager.set_abs_username(username) + return True + def test_connection(self) -> bool: - """ - Test connection to server with current token + """Verify that the current token still works.""" + response = self._make_request('GET', '/api/me', expectedStatus=(200,), timeout=10) + return response is not None - 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_current_user(self) -> Optional[Dict]: + """Get the current user profile including bookmarks.""" + responseData = self._request_json('GET', '/api/me', expectedStatus=(200,), timeout=10) + if not isinstance(responseData, dict): + return None + return responseData 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") + """Get available libraries.""" + responseData = self._request_json('GET', '/api/libraries', expectedStatus=(200,), timeout=10) + if responseData is None: 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 + libraries = self._extract_list(responseData, ['libraries', 'results']) + return libraries if libraries else None def get_library_items(self, libraryId: str, limit: int = 0, page: int = 0) -> Optional[List[Dict]]: """ - Get audiobooks in a library + Get items in a library. - Args: - libraryId: Library ID - limit: Max items to return (0 = no limit) - page: Page number for pagination (default 0) - - Returns: - List of library item dictionaries, or None if error + Fetches all pages when limit is 0 so large libraries are fully browsable. """ - if not self.authToken: - print("ERROR: Not authenticated") + if not libraryId: return None - try: - url = f"{self.serverUrl}/api/libraries/{libraryId}/items" - headers = {'Authorization': f'Bearer {self.authToken}'} - # Only include limit param if explicitly set (0 means no limit) - params = {'page': page} - if limit > 0: - params['limit'] = limit + items = [] + currentPage = max(0, int(page)) + remainingLimit = max(0, int(limit)) - response = requests.get(url, headers=headers, params=params, timeout=30) + while True: + params = {'page': currentPage} + if remainingLimit > 0: + params['limit'] = remainingLimit - 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 + responseData = self._request_json( + 'GET', + f"/api/libraries/{libraryId}/items", + expectedStatus=(200,), + params=params, + timeout=30 ) + if responseData is None: + return None if not items else items - return response + pageItems = self._extract_list(responseData, ['results', 'libraryItems', 'items']) + if not pageItems and isinstance(responseData, list): + pageItems = responseData - except requests.exceptions.RequestException as e: - print(f"Request error: {e}") - return None + items.extend(pageItems) - def is_authenticated(self) -> bool: - """Check if client has valid authentication token""" - return bool(self.authToken) + if remainingLimit > 0: + return items[:remainingLimit] + + pageSize = len(pageItems) + if pageSize == 0: + break + + if not isinstance(responseData, dict): + break + + if not self._has_more_pages(responseData, currentPage, pageSize, len(items)): + break + + currentPage += 1 + + return items 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 + """Get detailed item metadata.""" + for endpoint in [f"/api/items/{itemId}", f"/api/library-items/{itemId}"]: + response = self._make_request('GET', endpoint, expectedStatus=(200, 404), timeout=15) + statusCode = getattr(response, 'status_code', None) if response else None + if not response or statusCode == 404: + continue + try: + responseData = response.json() + except ValueError as error: + print(f"Invalid item details response from Audiobookshelf: {error}") + return None + if isinstance(responseData, dict): + return responseData.get('libraryItem') if isinstance(responseData.get('libraryItem'), dict) else responseData + 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") + """Download an audiobook file.""" + response = self._make_request( + 'GET', + f"/api/items/{itemId}/file", + expectedStatus=(200,), + stream=True, + timeout=30 + ) + if not response: 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}'} - - # Download with streaming to handle large files - # Use context manager to ensure response cleanup - with requests.get(downloadUrl, headers=headers, stream=True, timeout=30) as response: - if response.status_code != 200: - print(f"Download failed: {response.status_code}") - return False - - # 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}") + totalSize = int(response.headers.get('content-length', 0)) + downloaded = 0 + with open(outputPath, 'wb') as outputFile: + for chunk in response.iter_content(chunk_size=8192): + if not chunk: + continue + outputFile.write(chunk) + downloaded += len(chunk) + if progressCallback and totalSize > 0: + percent = int((downloaded / totalSize) * 100) + progressCallback(percent) + except IOError as error: + print(f"File write error while downloading Audiobookshelf item: {error}") return False + finally: + response.close() + + print(f"Download complete: {outputPath}") + return True def get_stream_url(self, itemId: str, itemDetails: Optional[Dict] = None) -> Optional[Dict]: """ - Get streaming URL for an audiobook using /play endpoint. - - This endpoint creates a playback session on the server and returns - the session ID along with the stream URL and current playback position. - - Args: - itemId: Library item ID - itemDetails: Optional pre-fetched item details (not required) - - Returns: - Dict with {streamUrl, sessionId, currentTime} or None if error + Start playback via /play and return stream/session details. """ - if not self.authToken: - print("ERROR: Not authenticated") + payload = { + 'deviceInfo': self.deviceInfo, + 'mediaPlayer': 'BookStorm', + 'forceDirectPlay': False, + 'forceTranscode': False, + 'supportedMimeTypes': [ + 'audio/aac', + 'audio/flac', + 'audio/mp4', + 'audio/mpeg', + 'audio/ogg', + 'audio/opus', + 'audio/webm' + ] + } + + playData = self._request_json( + 'POST', + f"/api/items/{itemId}/play", + expectedStatus=(200,), + headers={'Content-Type': 'application/json'}, + json=payload, + timeout=15 + ) + if not isinstance(playData, dict): return None - try: - # Validate item exists and has audio content (optional check) - if itemDetails: - media = itemDetails.get('media', {}) - # 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" - - 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() - - # Extract session ID from the play response - # The /play endpoint creates a session and returns it - sessionId = playData.get('id') - - # Extract current playback time (user's saved progress) - currentTime = playData.get('currentTime', 0.0) - - # 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') - elif 'url' in playData: - # Direct URL - streamUrl = playData.get('url') - elif 'contentUrl' in playData: - # Alternative format - streamUrl = playData.get('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}" - - return { - 'streamUrl': streamUrl, - 'sessionId': sessionId, - 'currentTime': currentTime - } - - except Exception as e: - print(f"Get stream URL error: {e}") - import traceback - traceback.print_exc() + streamUrls = self._extract_stream_urls(playData) + if not streamUrls: + print("Audiobookshelf play response did not include a playable stream URL.") return None + duration = self._coerce_float(playData.get('duration')) + if duration <= 0 and isinstance(itemDetails, dict): + duration = self._coerce_float(itemDetails.get('media', {}).get('duration')) + + return { + 'streamUrl': streamUrls[0], + 'streamUrls': streamUrls, + 'sessionId': playData.get('id'), + 'currentTime': self._coerce_float(playData.get('currentTime')), + 'duration': duration, + 'playData': playData + } + 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") + """Get chapters for an item.""" + itemDetails = self.get_library_item_details(itemId) + if not itemDetails: return None + chapters = itemDetails.get('media', {}).get('chapters', []) + return chapters if chapters else None - try: - # Get item details which includes chapter info - itemDetails = self.get_library_item_details(itemId) - if not itemDetails: - return None + def update_progress(self, itemId: str, currentTime: float, duration: float, progress: float = None, + isFinished: bool = None) -> bool: + """Update persisted media progress.""" + currentTime = max(0.0, self._coerce_float(currentTime)) + duration = max(0.0, self._coerce_float(duration)) + if progress is None: + progress = min(currentTime / duration, 1.0) if duration > 0 else 0.0 + if isFinished is None: + isFinished = progress >= 0.99 and duration > 0 - # 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 = { + response = self._make_request( + 'PATCH', + f"/api/me/progress/{itemId}", + expectedStatus=(200, 204), + headers={'Content-Type': 'application/json'}, + json={ '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 + 'isFinished': bool(isFinished) + }, + timeout=10 + ) + return response is not None 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") + """Get saved media progress for an item.""" + response = self._make_request('GET', f"/api/me/progress/{itemId}", expectedStatus=(200, 404), timeout=10) + statusCode = getattr(response, 'status_code', None) if response else None + if not response or statusCode == 404: 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 response.json() + except ValueError as error: + print(f"Invalid progress response from Audiobookshelf: {error}") return None + def get_bookmarks(self, itemId: str = None) -> Optional[List[Dict]]: + """ + Get bookmarks for the current user, optionally filtered by item ID. + """ + userData = self.get_current_user() + if not userData: + return None + + bookmarks = userData.get('bookmarks', []) + if not isinstance(bookmarks, list): + return None + + if not itemId: + return bookmarks + + return [ + bookmark for bookmark in bookmarks + if isinstance(bookmark, dict) and bookmark.get('libraryItemId') == itemId + ] + + def create_bookmark(self, itemId: str, bookmarkTime: int, title: str) -> Optional[Dict]: + """Create a named bookmark for an item.""" + responseData = self._request_json( + 'POST', + f"/api/me/item/{itemId}/bookmark", + expectedStatus=(200,), + headers={'Content-Type': 'application/json'}, + json={ + 'time': int(bookmarkTime), + 'title': title + }, + timeout=10 + ) + return responseData if isinstance(responseData, dict) else None + + def update_bookmark(self, itemId: str, bookmarkTime: int, title: str) -> Optional[Dict]: + """Update an existing bookmark title/time for an item.""" + responseData = self._request_json( + 'PATCH', + f"/api/me/item/{itemId}/bookmark", + expectedStatus=(200,), + headers={'Content-Type': 'application/json'}, + json={ + 'time': int(bookmarkTime), + 'title': title + }, + timeout=10 + ) + return responseData if isinstance(responseData, dict) else None + + def save_bookmark(self, itemId: str, bookmarkTime: int, title: str) -> Optional[Dict]: + """ + Create or update a bookmark on Audiobookshelf. + + Returns: + Bookmark dictionary on success, None on failure + """ + bookmarkData = self.create_bookmark(itemId, bookmarkTime, title) + if bookmarkData: + return bookmarkData + + bookmarkData = self.update_bookmark(itemId, bookmarkTime, title) + if bookmarkData: + return bookmarkData + + existingBookmarks = self.get_bookmarks(itemId) + if existingBookmarks is None: + return None + + for bookmark in existingBookmarks: + if not isinstance(bookmark, dict): + continue + if int(bookmark.get('time', -1)) == int(bookmarkTime): + return bookmark + + return None + + def delete_bookmark(self, itemId: str, bookmarkTime: int) -> bool: + """Delete a bookmark for an item.""" + response = self._make_request( + 'DELETE', + f"/api/me/item/{itemId}/bookmark/{int(bookmarkTime)}", + expectedStatus=(200, 204), + timeout=10 + ) + return response is not None + def create_session(self, itemId: str) -> Optional[str]: """ - Create a new listening session + Compatibility helper for older call sites. - Args: - itemId: Library item ID - - Returns: - Session ID, or None if error + Streaming should normally use /api/items/{id}/play instead. """ - 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 = { + responseData = self._request_json( + 'POST', + '/api/session/local', + expectedStatus=(200,), + headers={'Content-Type': 'application/json'}, + json={ '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') - 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") + 'deviceInfo': self.deviceInfo + }, + timeout=10 + ) + if not isinstance(responseData, dict): return None + return responseData.get('id') - def sync_session(self, sessionId: str, currentTime: float, duration: float, progress: float) -> bool: - """ - Sync session progress + def sync_session(self, sessionId: str, currentTime: float, duration: float, progress: float = None, + timeListened: float = 0.0) -> bool: + """Sync an active playback session.""" + currentTime = max(0.0, self._coerce_float(currentTime)) + duration = max(0.0, self._coerce_float(duration)) + timeListened = max(0.0, self._coerce_float(timeListened)) + if progress is None: + progress = min(currentTime / duration, 1.0) if duration > 0 else 0.0 - 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 = { + response = self._make_request( + 'POST', + f"/api/session/{sessionId}/sync", + expectedStatus=(200, 204), + headers={'Content-Type': 'application/json'}, + json={ 'currentTime': currentTime, 'duration': duration, - 'progress': progress - } + 'progress': progress, + 'timeListened': timeListened + }, + timeout=10 + ) + return response is not None - response = requests.post(url, json=payload, headers=headers, timeout=10) + def close_session(self, sessionId: str, currentTime: float = 0.0, duration: float = 0.0, + progress: float = None, timeListened: float = 0.0) -> bool: + """Close an active playback session with final timing data.""" + currentTime = max(0.0, self._coerce_float(currentTime)) + duration = max(0.0, self._coerce_float(duration)) + timeListened = max(0.0, self._coerce_float(timeListened)) + if progress is None: + progress = min(currentTime / duration, 1.0) if duration > 0 else 0.0 - 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 + response = self._make_request( + 'POST', + f"/api/session/{sessionId}/close", + expectedStatus=(200, 204), + headers={'Content-Type': 'application/json'}, + json={ + 'currentTime': currentTime, + 'duration': duration, + 'progress': progress, + 'timeListened': timeListened + }, + timeout=10 + ) + return response is not None 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}") + """Get series in a library.""" + responseData = self._request_json('GET', f"/api/libraries/{libraryId}/series", expectedStatus=(200,), timeout=15) + if responseData is None: return None + seriesList = self._extract_list(responseData, ['series', 'results', 'items']) + return seriesList if seriesList else 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") + """Get collections in a library.""" + responseData = self._request_json('GET', f"/api/libraries/{libraryId}/collections", expectedStatus=(200,), timeout=15) + if responseData is None: 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 - - + collectionsList = self._extract_list(responseData, ['collections', 'results', 'items']) + return collectionsList if collectionsList else None diff --git a/src/audiobookshelf_menu.py b/src/audiobookshelf_menu.py index a1e5a62..a3b650b 100644 --- a/src/audiobookshelf_menu.py +++ b/src/audiobookshelf_menu.py @@ -8,6 +8,7 @@ Browse libraries, audiobooks, series, and collections. """ from pathlib import Path +import time class AudiobookshelfMenu: @@ -31,6 +32,8 @@ class AudiobookshelfMenu: self.currentView = 'libraries' # 'libraries', 'books', 'stream_download' self.currentSelection = 0 self.items = [] + self.searchBuffer = '' + self.lastSearchAt = 0.0 # Context tracking self.selectedLibrary = None @@ -50,6 +53,7 @@ class AudiobookshelfMenu: """Enter the Audiobookshelf browser""" self.inMenu = True self.currentSelection = 0 + self._reset_search() # Reset state from previous sessions self.selectedLibrary = None @@ -77,7 +81,7 @@ class AudiobookshelfMenu: self.currentView = 'libraries' self.items = libraries if self.speechEngine: - self.speechEngine.speak(f"Found {len(libraries)} libraries. Use arrow keys to navigate.") + self.speechEngine.speak(f"Found {len(libraries)} libraries. Use arrow keys to navigate or type to search.") self._speak_current_item() def _load_books(self): @@ -110,9 +114,10 @@ class AudiobookshelfMenu: self.items = books self.currentView = 'books' self.currentSelection = 0 + self._reset_search() if self.speechEngine: - self.speechEngine.speak(f"Loaded {len(books)} books. Use arrow keys to navigate, left-right to change view.") + self.speechEngine.speak(f"Loaded {len(books)} books. Use arrow keys to navigate, left-right to change view, or type to search.") self._speak_current_item() def _check_if_local(self, book): @@ -179,6 +184,61 @@ class AudiobookshelfMenu: self._speak_current_item() + def _reset_search(self): + """Reset incremental search state.""" + self.searchBuffer = '' + self.lastSearchAt = 0.0 + + def _get_search_text(self, item): + """Build searchable text for the current item.""" + if self.currentView == 'libraries': + return item.get('name', '').lower() + + if self.currentView != 'books': + return '' + + if self.booksViewMode == 'series': + return item.get('name', '').lower() + + if self.booksViewMode == 'collections': + return item.get('name', '').lower() + + media = item.get('media', {}) + metadata = media.get('metadata', {}) + title = metadata.get('title', '') + author = metadata.get('authorName', '') + return f"{title} {author}".strip().lower() + + def search_items(self, searchText): + """ + Jump to the first item whose spoken label starts with the typed text. + + Args: + searchText: Newly typed character(s) + + Returns: + True if a match was found + """ + if self.currentView == 'stream_download' or not self.items: + return False + + now = time.monotonic() + if now - self.lastSearchAt > 1.5: + self.searchBuffer = '' + + self.lastSearchAt = now + self.searchBuffer = f"{self.searchBuffer}{searchText.lower()}" + + for itemIndex, item in enumerate(self.items): + if self._get_search_text(item).startswith(self.searchBuffer): + self.currentSelection = itemIndex + self._speak_current_item() + return True + + if self.speechEngine: + self.speechEngine.speak(f"No match for {self.searchBuffer}") + return False + def change_view(self, direction): """Change view mode (only in books view)""" if self.currentView != 'books': @@ -289,6 +349,7 @@ class AudiobookshelfMenu: # Go back to books list self.currentView = 'books' self.currentSelection = 0 + self._reset_search() if self.speechEngine: self.speechEngine.speak("Cancelled") self._speak_current_item() @@ -337,6 +398,7 @@ class AudiobookshelfMenu: self.items = seriesBooks self.booksViewMode = 'all' # Switch to "all books" view self.currentSelection = 0 + self._reset_search() if self.speechEngine: seriesName = item.get('name', 'Series') @@ -356,6 +418,7 @@ class AudiobookshelfMenu: self.items = collectionBooks self.booksViewMode = 'all' # Switch to "all books" view self.currentSelection = 0 + self._reset_search() if self.speechEngine: collectionName = item.get('name', 'Collection') @@ -409,6 +472,7 @@ class AudiobookshelfMenu: # Book not local - enter stream/download submenu self.currentView = 'stream_download' self.currentSelection = 0 + self._reset_search() if self.speechEngine: self.speechEngine.speak("Choose playback option. Use arrow keys to navigate.") self._speak_current_item() @@ -459,6 +523,7 @@ class AudiobookshelfMenu: # Go back to books list self.currentView = 'books' self.currentSelection = 0 + self._reset_search() if self.speechEngine: self.speechEngine.speak("Back to books") self._speak_current_item() @@ -470,6 +535,7 @@ class AudiobookshelfMenu: self.items = libraries self.currentSelection = 0 self.selectedLibrary = None + self._reset_search() if self.speechEngine: self.speechEngine.speak("Back to libraries") self._speak_current_item() @@ -511,6 +577,7 @@ class AudiobookshelfMenu: self.items = seriesList self.currentView = 'books' # Keep in books view but showing series self.currentSelection = 0 + self._reset_search() if self.speechEngine: self.speechEngine.speak(f"Loaded {len(seriesList)} series") @@ -543,6 +610,7 @@ class AudiobookshelfMenu: self.items = collectionsList self.currentView = 'books' # Keep in books view but showing collections self.currentSelection = 0 + self._reset_search() if self.speechEngine: self.speechEngine.speak(f"Loaded {len(collectionsList)} collections") @@ -555,6 +623,6 @@ class AudiobookshelfMenu: self.items = [] self.selectedLibrary = None self.selectedBook = None + self._reset_search() if self.speechEngine: self.speechEngine.speak("Closed Audiobookshelf browser") - diff --git a/src/audiobookshelf_sync.py b/src/audiobookshelf_sync.py index 42f30d8..afdf4e3 100644 --- a/src/audiobookshelf_sync.py +++ b/src/audiobookshelf_sync.py @@ -3,19 +3,19 @@ """ Audiobookshelf Sync Manager -Manages synchronization between BookStorm and Audiobookshelf server, -including playback sessions and progress tracking. +Tracks streaming session lifecycle and persisted progress updates. """ +import time from typing import Optional, Dict class AudiobookshelfSync: - """Manages sync between BookStorm and Audiobookshelf server""" + """Manage Audiobookshelf playback sync for a single active item.""" def __init__(self, absClient, serverLinkManager): """ - Initialize sync manager + Initialize sync manager. Args: absClient: AudiobookshelfClient instance @@ -27,131 +27,186 @@ class AudiobookshelfSync: self.serverId = None self.duration = 0.0 self.bookPath = None + self.lastSyncPosition = 0.0 + self.lastSyncAt = None + + def _calculate_progress(self, currentTime: float) -> float: + """Calculate normalized playback progress.""" + if self.duration <= 0: + return 0.0 + return min(max(currentTime, 0.0) / self.duration, 1.0) + + def _calculate_time_listened(self, currentTime: float) -> float: + """ + Estimate listening time since the previous session sync. + + Audiobookshelf expects time spent listening, not position delta. + """ + if self.lastSyncAt is None: + return 0.0 + + wallElapsed = max(0.0, time.monotonic() - self.lastSyncAt) + positionDelta = max(0.0, currentTime - self.lastSyncPosition) + + if positionDelta > 0: + return min(wallElapsed, positionDelta + 1.0) + return min(wallElapsed, 1.0) + + def _update_sync_state(self, currentTime: float): + """Store the latest synced playback state.""" + self.lastSyncPosition = max(0.0, float(currentTime)) + self.lastSyncAt = time.monotonic() + + def mark_progress_checkpoint(self, currentTime: float): + """Reset session timing around a seek, pause, or resume.""" + self._update_sync_state(currentTime) def start_streaming_session(self, serverBook: Dict) -> Optional[Dict]: """ - Start a streaming playback session. - - The /play endpoint creates a session on the server and returns - the session ID, stream URL, and user's saved progress position. - - Args: - serverBook: Server book dictionary from API + Start a streaming playback session with /play. Returns: - Dict with {streamUrl, sessionId, startPosition} or None on error + Dict with stream and session information, or None on error. """ serverId = serverBook.get('id') or serverBook.get('libraryItemId') if not serverId: - print("ERROR: No server ID found in book metadata") + print("ERROR: No Audiobookshelf item ID found for stream start.") return None if not self.client or not self.client.is_authenticated(): - print("ERROR: Not authenticated to Audiobookshelf") + print("ERROR: Not authenticated to Audiobookshelf.") return None - # Get stream URL - this creates the session on server result = self.client.get_stream_url(serverId, itemDetails=serverBook) if not result: return None - # Store session info for later syncing self.sessionId = result.get('sessionId') self.serverId = serverId - self.duration = serverBook.get('media', {}).get('duration', 0.0) + self.duration = max( + self.client._coerce_float(result.get('duration')), + self.client._coerce_float(serverBook.get('media', {}).get('duration')) + ) + + startPosition = self.client._coerce_float(result.get('currentTime')) + self._update_sync_state(startPosition) if self.sessionId: - print(f"Started session: {self.sessionId}") + print(f"Started Audiobookshelf session: {self.sessionId}") else: - print("Warning: No session ID in play response") + print("Audiobookshelf stream started without a session ID. Falling back to direct progress updates.") return { 'streamUrl': result.get('streamUrl'), + 'streamUrls': result.get('streamUrls', []), 'sessionId': self.sessionId, - 'startPosition': result.get('currentTime', 0.0) + 'startPosition': startPosition, + 'playData': result.get('playData', {}) } def set_book_path(self, bookPath: str): - """ - Set book path for server link updates - - Args: - bookPath: Path to book file or stream URL - """ + """Store the path or URL used for this stream.""" self.bookPath = bookPath - def sync_progress(self, currentTime: float) -> bool: + def sync_progress(self, currentTime: float, updateSession: bool = True, isFinished: bool = None) -> bool: """ - Sync current playback position to server + Sync current playback position to Audiobookshelf. Args: currentTime: Current playback position in seconds - - Returns: - True if sync successful, False otherwise + updateSession: Whether to send session heartbeat data + isFinished: Optional finished flag override """ if not self.serverId or not self.client or not self.client.is_authenticated(): return False - if self.duration <= 0: - return False + currentTime = max(0.0, self.client._coerce_float(currentTime)) + progress = self._calculate_progress(currentTime) + if isFinished is None: + isFinished = progress >= 0.999 and self.duration > 0 - progress = min(currentTime / self.duration, 1.0) - - # Update media progress (persists in server DB) - success = self.client.update_progress( - self.serverId, currentTime, self.duration, progress + progressSaved = self.client.update_progress( + self.serverId, + currentTime, + self.duration, + progress=progress, + isFinished=isFinished ) - if success: - print(f"Progress synced: {progress * 100:.1f}%") - # Sync session if active - if self.sessionId: - sessionSuccess = self.client.sync_session( - self.sessionId, currentTime, self.duration, progress + sessionSaved = True + if self.sessionId and updateSession: + timeListened = self._calculate_time_listened(currentTime) + sessionSaved = self.client.sync_session( + self.sessionId, + currentTime, + self.duration, + progress=progress, + timeListened=timeListened ) - if not sessionSuccess: - # Session might have expired, try to continue without it - print("Session sync failed, continuing with progress updates only") + if not sessionSaved: + print("Audiobookshelf session sync failed. Continuing with direct progress updates.") self.sessionId = None - # Update session in server link - if self.bookPath: - self.linkManager.update_session(str(self.bookPath), self.sessionId) + if progressSaved or sessionSaved: + self._update_sync_state(currentTime) - return success + if self.bookPath: + self.linkManager.update_session(str(self.bookPath), self.sessionId) - def close_session(self): - """Close the active playback session""" - if self.sessionId and self.client: - print(f"Closing session: {self.sessionId}") - self.client.close_session(self.sessionId) + return progressSaved - # Clear session from link metadata - if self.bookPath: - self.linkManager.clear_session(str(self.bookPath)) + def close_session(self, currentTime: float = 0.0, isFinished: bool = None): + """Close the active playback session and persist final progress.""" + currentTime = max(0.0, self.client._coerce_float(currentTime)) + progress = self._calculate_progress(currentTime) + if isFinished is None: + isFinished = progress >= 0.999 and self.duration > 0 + + if self.serverId and self.client and self.client.is_authenticated(): + self.client.update_progress( + self.serverId, + currentTime, + self.duration, + progress=progress, + isFinished=isFinished + ) + + if self.sessionId and self.client and self.client.is_authenticated(): + timeListened = self._calculate_time_listened(currentTime) + print(f"Closing Audiobookshelf session: {self.sessionId}") + self.client.close_session( + self.sessionId, + currentTime=currentTime, + duration=self.duration, + progress=progress, + timeListened=timeListened + ) + + if self.bookPath: + self.linkManager.clear_session(str(self.bookPath)) self.sessionId = None + self.lastSyncAt = None def get_server_progress(self) -> Optional[Dict]: - """ - Get current progress from server - - Returns: - Progress dict with currentTime, duration, progress or None - """ + """Fetch persisted progress for the active server item.""" if not self.serverId or not self.client: return None - return self.client.get_progress(self.serverId) def is_active(self) -> bool: - """Check if sync manager has an active session""" - return self.sessionId is not None and self.serverId is not None + """Return True when an active server item is being tracked.""" + return self.serverId is not None + + def has_open_session(self) -> bool: + """Return True when a live Audiobookshelf session is open.""" + return self.sessionId is not None def reset(self): - """Reset sync state (for when switching books)""" + """Clear sync state for the current item.""" self.sessionId = None self.serverId = None self.duration = 0.0 self.bookPath = None + self.lastSyncPosition = 0.0 + self.lastSyncAt = None diff --git a/src/book_selector.py b/src/book_selector.py index fb6c96f..3c0f158 100644 --- a/src/book_selector.py +++ b/src/book_selector.py @@ -8,6 +8,7 @@ Supports navigation and filtering by supported formats. """ from pathlib import Path +import time import zipfile @@ -35,6 +36,8 @@ class BookSelector: self.currentSelection = 0 self.inBrowser = False self.items = [] + self.searchBuffer = '' + self.lastSearchAt = 0.0 def _list_items(self): @@ -182,9 +185,10 @@ class BookSelector: self.inBrowser = True self.currentSelection = 0 self.items = self._list_items() + self._reset_search() if self.speechEngine: - self.speechEngine.speak("Book browser. Use arrow keys to navigate, Enter to select, Backspace for parent directory, L to set library, Escape to cancel.") + self.speechEngine.speak("Book browser. Use arrow keys to navigate, type to search, Enter to select, Backspace for parent directory, Shift L to set library, Escape to cancel.") # Speak current directory and first item if self.speechEngine: @@ -208,6 +212,41 @@ class BookSelector: self._speak_current_item() + def _reset_search(self): + """Reset incremental search state.""" + self.searchBuffer = '' + self.lastSearchAt = 0.0 + + def search_items(self, searchText): + """ + Jump to the first item whose name starts with the typed text. + + Args: + searchText: Text typed by the user + + Returns: + True if a match was found + """ + if not self.items: + return False + + now = time.monotonic() + if now - self.lastSearchAt > 1.5: + self.searchBuffer = '' + + self.lastSearchAt = now + self.searchBuffer = f"{self.searchBuffer}{searchText.lower()}" + + for itemIndex, item in enumerate(self.items): + if item.get('name', '').lower().startswith(self.searchBuffer): + self.currentSelection = itemIndex + self._speak_current_item() + return True + + if self.speechEngine: + self.speechEngine.speak(f"No match for {self.searchBuffer}") + return False + def _speak_current_item(self): """Speak current item with name first, then type""" if not self.items or not self.speechEngine: @@ -251,6 +290,7 @@ class BookSelector: self.currentDir = item['path'] self.currentSelection = 0 self.items = self._list_items() + self._reset_search() if self.speechEngine: dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir) @@ -275,6 +315,7 @@ class BookSelector: self.currentDir = parent self.currentSelection = 0 self.items = self._list_items() + self._reset_search() if self.speechEngine: dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir) @@ -295,5 +336,6 @@ class BookSelector: def exit_browser(self): """Exit the browser""" self.inBrowser = False + self._reset_search() if self.speechEngine: self.speechEngine.speak("Cancelled") diff --git a/src/bookmark_manager.py b/src/bookmark_manager.py index 2ffd2ef..8ca6599 100644 --- a/src/bookmark_manager.py +++ b/src/bookmark_manager.py @@ -67,10 +67,23 @@ class BookmarkManager: paragraph_index INTEGER NOT NULL DEFAULT 0, audio_position REAL DEFAULT 0.0, created_at TEXT NOT NULL, + server_library_item_id TEXT, + server_time INTEGER, + server_created_at INTEGER, UNIQUE(book_id, name) ) ''') + for columnName, columnType in [ + ('server_library_item_id', 'TEXT'), + ('server_time', 'INTEGER'), + ('server_created_at', 'INTEGER') + ]: + try: + cursor.execute(f'ALTER TABLE named_bookmarks ADD COLUMN {columnName} {columnType}') + except sqlite3.OperationalError: + pass + conn.commit() def _get_book_id(self, bookPath): @@ -189,7 +202,8 @@ class BookmarkManager: return bookmarks - def create_named_bookmark(self, bookPath, name, chapterIndex, paragraphIndex, audioPosition=0.0): + def create_named_bookmark(self, bookPath, name, chapterIndex, paragraphIndex, audioPosition=0.0, + serverLibraryItemId=None, serverTime=None, serverCreatedAt=None): """ Create a named bookmark for a book @@ -201,7 +215,7 @@ class BookmarkManager: audioPosition: Audio position in seconds (default: 0.0) Returns: - True if created successfully, False if name already exists + Bookmark ID if created successfully, None if name already exists """ bookId = self._get_book_id(bookPath) timestamp = datetime.now().isoformat() @@ -212,16 +226,18 @@ class BookmarkManager: cursor.execute(''' INSERT INTO named_bookmarks - (book_id, name, chapter_index, paragraph_index, audio_position, created_at) - VALUES (?, ?, ?, ?, ?, ?) - ''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp)) + (book_id, name, chapter_index, paragraph_index, audio_position, created_at, + server_library_item_id, server_time, server_created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp, + serverLibraryItemId, serverTime, serverCreatedAt)) conn.commit() - return True + return cursor.lastrowid except sqlite3.IntegrityError: # Bookmark with this name already exists - return False + return None def get_named_bookmarks(self, bookPath): """ @@ -239,7 +255,8 @@ class BookmarkManager: cursor = conn.cursor() cursor.execute(''' - SELECT id, name, chapter_index, paragraph_index, audio_position, created_at + SELECT id, name, chapter_index, paragraph_index, audio_position, created_at, + server_library_item_id, server_time, server_created_at FROM named_bookmarks WHERE book_id = ? ORDER BY created_at DESC @@ -255,11 +272,95 @@ class BookmarkManager: 'chapterIndex': row[2], 'paragraphIndex': row[3], 'audioPosition': row[4], - 'createdAt': row[5] + 'createdAt': row[5], + 'serverLibraryItemId': row[6], + 'serverTime': row[7], + 'serverCreatedAt': row[8] }) return bookmarks + def update_named_bookmark_server_data(self, bookmarkId, serverLibraryItemId=None, serverTime=None, + serverCreatedAt=None): + """ + Update server metadata for an existing named bookmark. + + Args: + bookmarkId: Local bookmark ID + serverLibraryItemId: Linked Audiobookshelf item ID + serverTime: Server bookmark time in seconds + serverCreatedAt: Server bookmark created timestamp + """ + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE named_bookmarks + SET server_library_item_id = ?, server_time = ?, server_created_at = ? + WHERE id = ? + ''', (serverLibraryItemId, serverTime, serverCreatedAt, bookmarkId)) + conn.commit() + + def upsert_named_bookmark_from_server(self, bookPath, name, chapterIndex, paragraphIndex, audioPosition=0.0, + serverLibraryItemId=None, serverTime=None, serverCreatedAt=None): + """ + Insert or update a local bookmark from Audiobookshelf server data. + + Returns: + Local bookmark ID + """ + bookId = self._get_book_id(bookPath) + timestamp = datetime.now().isoformat() + + with sqlite3.connect(self.dbPath) as conn: + cursor = conn.cursor() + + if serverLibraryItemId and serverTime is not None: + cursor.execute(''' + SELECT id FROM named_bookmarks + WHERE book_id = ? AND server_library_item_id = ? AND server_time = ? + ''', (bookId, serverLibraryItemId, serverTime)) + row = cursor.fetchone() + if row: + bookmarkId = row[0] + cursor.execute(''' + UPDATE named_bookmarks + SET name = ?, chapter_index = ?, paragraph_index = ?, audio_position = ?, + "server_created_at" = ?, created_at = ? + WHERE id = ? + ''', (name, chapterIndex, paragraphIndex, audioPosition, + serverCreatedAt, timestamp, bookmarkId)) + conn.commit() + return bookmarkId + + cursor.execute(''' + INSERT OR IGNORE INTO named_bookmarks + (book_id, name, chapter_index, paragraph_index, audio_position, created_at, + server_library_item_id, server_time, server_created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp, + serverLibraryItemId, serverTime, serverCreatedAt)) + + if cursor.lastrowid: + conn.commit() + return cursor.lastrowid + + cursor.execute(''' + SELECT id FROM named_bookmarks + WHERE book_id = ? AND name = ? + ''', (bookId, name)) + row = cursor.fetchone() + bookmarkId = row[0] if row else None + if bookmarkId: + cursor.execute(''' + UPDATE named_bookmarks + SET chapter_index = ?, paragraph_index = ?, audio_position = ?, + "server_library_item_id" = ?, "server_time" = ?, "server_created_at" = ? + WHERE id = ? + ''', (chapterIndex, paragraphIndex, audioPosition, + serverLibraryItemId, serverTime, serverCreatedAt, bookmarkId)) + conn.commit() + return bookmarkId + def delete_named_bookmark(self, bookmarkId): """ Delete a named bookmark by ID @@ -300,7 +401,8 @@ class BookmarkManager: cursor = conn.cursor() cursor.execute(''' - SELECT name, chapter_index, paragraph_index, audio_position + SELECT name, chapter_index, paragraph_index, audio_position, + server_library_item_id, server_time, server_created_at FROM named_bookmarks WHERE id = ? ''', (bookmarkId,)) @@ -312,8 +414,10 @@ class BookmarkManager: 'name': row[0], 'chapterIndex': row[1], 'paragraphIndex': row[2], - 'audioPosition': row[3] + 'audioPosition': row[3], + 'serverLibraryItemId': row[4], + 'serverTime': row[5], + 'serverCreatedAt': row[6] } return None - diff --git a/src/bookmarks_menu.py b/src/bookmarks_menu.py index e2da772..ba094eb 100644 --- a/src/bookmarks_menu.py +++ b/src/bookmarks_menu.py @@ -143,6 +143,45 @@ class BookmarksMenu: return None + def get_current_bookmark(self): + """ + Get the currently selected bookmark. + + Returns: + Bookmark dictionary or None if selection is not a bookmark + """ + if not self.listOptions: + return None + + if self.currentSelection >= len(self.listOptions): + return None + + option = self.listOptions[self.currentSelection] + if option['type'] != 'bookmark': + return None + + return option['data'] + + def remove_bookmark(self, bookmarkId, bookmarkName=None): + """ + Remove a bookmark from the local menu and database. + + Args: + bookmarkId: Local bookmark ID + bookmarkName: Optional spoken name for feedback + """ + self.bookmarkManager.delete_named_bookmark(bookmarkId) + + if self.speechEngine and bookmarkName: + self.speechEngine.speak(f"Deleted bookmark: {bookmarkName}") + + self._load_bookmarks() + + if self.currentSelection >= len(self.listOptions): + self.currentSelection = max(0, len(self.listOptions) - 1) + + self._speak_current_item() + def delete_current_bookmark(self): """ Delete currently selected bookmark @@ -162,21 +201,7 @@ class BookmarksMenu: 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() + self.remove_bookmark(bookmarkId, bookmarkName) return True return False