diff --git a/bookstorm.py b/bookstorm.py index 016da10..ec86222 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -51,6 +51,7 @@ 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.audiobookshelf_sync import AudiobookshelfSync from src.bookmarks_menu import BookmarksMenu from src.wav_exporter import WavExporter from src.braille_output import BrailleOutput @@ -139,9 +140,10 @@ class BookReader: self.absClient = None self.absMenu = None self.serverLinkManager = ServerLinkManager() + self.absSync = None # Sync manager (created when absClient initialized) self.serverBook = None # Server book metadata for streaming self.isStreaming = False # Track if currently streaming - self.sessionId = None # Active listening session ID + self.sessionId = None # Active listening session ID (legacy, now managed by absSync) # Initialize reading engine based on config readerEngine = self.config.get_reader_engine() @@ -452,18 +454,17 @@ class BookReader: if not self.absClient or not self.absClient.is_authenticated(): return - # Check if this is a streaming book or server-linked book - serverId = None + # For streaming books, use the sync manager + if self.isStreaming and self.absSync and self.absSync.is_active(): + self.absSync.sync_progress(audioPosition) + return - 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') + # 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 @@ -483,23 +484,6 @@ class BookReader: if success: print(f"Progress synced to server: {progress * 100:.1f}%") - # Also sync session if active - if self.sessionId: - syncSuccess = self.absClient.sync_session(self.sessionId, currentTime, duration, progress) - if syncSuccess: - # Update session in server link to persist it - if self.bookPath: - self.serverLinkManager.update_session(str(self.bookPath), self.sessionId) - else: - # Session sync failed - might be expired, create new one - print(f"Session sync failed, creating new session...") - newSessionId = self.absClient.create_session(serverId) - if newSessionId: - self.sessionId = newSessionId - if self.bookPath: - self.serverLinkManager.update_session(str(self.bookPath), self.sessionId) - print(f"Created new session: {self.sessionId}") - def reload_tts_engine(self): """Reload TTS engine with current config settings""" readerEngine = self.config.get_reader_engine() @@ -902,7 +886,12 @@ class BookReader: self.audioPlayer.stop_audio_file() # Close Audiobookshelf session if active - if self.sessionId and self.absClient: + 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: @@ -1694,6 +1683,8 @@ class BookReader: if not self.absClient: serverUrl = self.config.get_abs_server_url() self.absClient = AudiobookshelfClient(serverUrl, self.config) + # Initialize sync manager with the client + self.absSync = AudiobookshelfSync(self.absClient, self.serverLinkManager) # Check if already authenticated if not self.absClient.is_authenticated(): @@ -1727,6 +1718,12 @@ class BookReader: def _handle_audiobookshelf_key(self, event): """Handle key events when in Audiobookshelf menu""" + # Control key: Stop speech + if event.key == pygame.K_LCTRL or event.key == pygame.K_RCTRL: + if self.speechEngine: + self.speechEngine.stop() + return + if event.key == pygame.K_UP: self.absMenu.navigate_menu('up') elif event.key == pygame.K_DOWN: @@ -1886,20 +1883,26 @@ class BookReader: author = metadata.get('authorName', '') duration = media.get('duration', 0.0) - - # Get streaming URL (pass full book details to avoid re-fetching) + # Get streaming URL and start session via sync manager + # This single call creates the session and returns stream info with saved position 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: + sessionData = self.absSync.start_streaming_session(serverBook) + if not sessionData: self.speechEngine.speak("Failed to get stream URL. Check terminal for errors.") - print("\nERROR: Could not get stream URL") + print("\nERROR: Could not start streaming session") print(f"Book structure keys: {list(serverBook.keys())}") if 'media' in serverBook: print(f"Media keys: {list(serverBook['media'].keys())}") return + streamUrl = sessionData.get('streamUrl') + startPosition = sessionData.get('startPosition', 0.0) + + # 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', []) @@ -1912,12 +1915,12 @@ class BookReader: if serverChapters: for chapterData in serverChapters: chapterTitle = chapterData.get('title', 'Untitled') - startTime = chapterData.get('start', 0.0) - chapterDuration = chapterData.get('end', 0.0) - startTime + chapterStart = chapterData.get('start', 0.0) + chapterDuration = chapterData.get('end', 0.0) - chapterStart chapter = AudioChapter( title=chapterTitle, - startTime=startTime, + startTime=chapterStart, duration=chapterDuration ) book.add_chapter(chapter) @@ -1938,18 +1941,13 @@ class BookReader: self.book = book self.bookPath = streamUrl # Store URL as path for tracking + # Set book path in sync manager for progress updates + self.absSync.set_book_path(streamUrl) + # 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}") - # Create listening session (only if we don't already have one from resume) - if not self.sessionId: - self.sessionId = self.absClient.create_session(serverId) - if self.sessionId: - print(f"Created listening session: {self.sessionId}") - else: - print(f"Using existing session ID: {self.sessionId}") - # Save session and server metadata to server link manager # This allows resuming the stream with the same session self.serverLinkManager.create_link( @@ -1965,21 +1963,15 @@ class BookReader: serverBook=serverBook ) - # Try to load progress from server - serverProgress = self.absClient.get_progress(serverId) - if serverProgress: - progressTime = serverProgress.get('currentTime', 0.0) - minutes = int(progressTime // 60) - seconds = int(progressTime % 60) - - # Save the exact position for playback resume - self.savedAudioPosition = progressTime + # 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 <= progressTime < chapterEnd: + if chap.startTime <= startPosition < chapterEnd: self.currentChapter = i break else: @@ -1991,14 +1983,21 @@ class BookReader: self.currentParagraph = 0 # Load stream URL directly - mpv will handle streaming natively - print(f"Loading stream: {streamUrl[:80]}...") + # Pass start position when loading (more reliable for streams than seeking after) + startPos = startPosition if startPosition > 0 else 0.0 + if startPos > 0: + minutes = int(startPos // 60) + seconds = int(startPos % 60) + print(f"Loading stream from {minutes}m {seconds}s: {streamUrl[:60]}...") + else: + 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 - # Use saved playback speed from config + # 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): + if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken, + playbackSpeed=playbackSpeed, startPosition=startPos): 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") @@ -2021,13 +2020,8 @@ class BookReader: if self.config.get_show_text() and hasattr(self, 'screen') and self.screen: self._render_screen() - # Start playback from saved position (if any) - startPos = self.savedAudioPosition if self.savedAudioPosition > 0 else 0.0 - if startPos > 0: - minutes = int(startPos // 60) - seconds = int(startPos % 60) - print(f"Seeking to {minutes}m {seconds}s...") - self.audioPlayer.play_audio_file(startPosition=startPos) + # Start playback (position already set during load) + self.audioPlayer.play_audio_file() self.isPlaying = True self.isAudioBook = True @@ -2566,11 +2560,14 @@ class BookReader: def cleanup(self): """Cleanup resources""" - # Close active listening session if any - if self.sessionId and self.absClient: + # 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) - # Clear session from link metadata if self.bookPath: self.serverLinkManager.clear_session(str(self.bookPath)) self.sessionId = None @@ -2687,6 +2684,7 @@ 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') @@ -2738,6 +2736,7 @@ def main(): reader = BookReader(None, config) reader.absClient = absClient + reader.absSync = AudiobookshelfSync(absClient, reader.serverLinkManager) reader._stream_audiobook(bookDetails) reader.run_interactive() return 0 diff --git a/src/audiobookshelf_client.py b/src/audiobookshelf_client.py index e188cea..15ec9b0 100644 --- a/src/audiobookshelf_client.py +++ b/src/audiobookshelf_client.py @@ -147,13 +147,13 @@ class AudiobookshelfClient: print(f"Get libraries error: {e}") return None - def get_library_items(self, libraryId: str, limit: int = 100, page: int = 0) -> Optional[List[Dict]]: + def get_library_items(self, libraryId: str, limit: int = 0, page: int = 0) -> Optional[List[Dict]]: """ Get audiobooks in a library Args: libraryId: Library ID - limit: Max items to return (default 100) + limit: Max items to return (0 = no limit) page: Page number for pagination (default 0) Returns: @@ -166,9 +166,12 @@ class AudiobookshelfClient: try: url = f"{self.serverUrl}/api/libraries/{libraryId}/items" headers = {'Authorization': f'Bearer {self.authToken}'} - params = {'limit': limit, 'page': page} + # Only include limit param if explicitly set (0 means no limit) + params = {'page': page} + if limit > 0: + params['limit'] = limit - response = requests.get(url, headers=headers, params=params, timeout=10) + response = requests.get(url, headers=headers, params=params, timeout=30) if response.status_code == 200: data = response.json() @@ -327,16 +330,19 @@ class AudiobookshelfClient: print(f"File write error: {e}") return False - def get_stream_url(self, itemId: str, itemDetails: Optional[Dict] = None) -> Optional[str]: + def get_stream_url(self, itemId: str, itemDetails: Optional[Dict] = None) -> Optional[Dict]: """ - Get streaming URL for an audiobook using /play endpoint + 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: - Streaming URL with auth token, or None if error + Dict with {streamUrl, sessionId, currentTime} or None if error """ if not self.authToken: print("ERROR: Not authenticated") @@ -372,6 +378,13 @@ class AudiobookshelfClient: 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 @@ -397,7 +410,11 @@ class AudiobookshelfClient: if streamUrl.startswith('/'): streamUrl = f"{self.serverUrl}{streamUrl}" - return streamUrl + return { + 'streamUrl': streamUrl, + 'sessionId': sessionId, + 'currentTime': currentTime + } except Exception as e: print(f"Get stream URL error: {e}") diff --git a/src/audiobookshelf_sync.py b/src/audiobookshelf_sync.py new file mode 100644 index 0000000..42f30d8 --- /dev/null +++ b/src/audiobookshelf_sync.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Audiobookshelf Sync Manager + +Manages synchronization between BookStorm and Audiobookshelf server, +including playback sessions and progress tracking. +""" + +from typing import Optional, Dict + + +class AudiobookshelfSync: + """Manages sync between BookStorm and Audiobookshelf server""" + + def __init__(self, absClient, serverLinkManager): + """ + Initialize sync manager + + Args: + absClient: AudiobookshelfClient instance + serverLinkManager: ServerLinkManager instance + """ + self.client = absClient + self.linkManager = serverLinkManager + self.sessionId = None + self.serverId = None + self.duration = 0.0 + self.bookPath = None + + 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 + + Returns: + Dict with {streamUrl, sessionId, startPosition} or None on error + """ + serverId = serverBook.get('id') or serverBook.get('libraryItemId') + if not serverId: + print("ERROR: No server ID found in book metadata") + return None + + if not self.client or not self.client.is_authenticated(): + 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) + + if self.sessionId: + print(f"Started session: {self.sessionId}") + else: + print("Warning: No session ID in play response") + + return { + 'streamUrl': result.get('streamUrl'), + 'sessionId': self.sessionId, + 'startPosition': result.get('currentTime', 0.0) + } + + def set_book_path(self, bookPath: str): + """ + Set book path for server link updates + + Args: + bookPath: Path to book file or stream URL + """ + self.bookPath = bookPath + + def sync_progress(self, currentTime: float) -> bool: + """ + Sync current playback position to server + + Args: + currentTime: Current playback position in seconds + + Returns: + True if sync successful, False otherwise + """ + if not self.serverId or not self.client or not self.client.is_authenticated(): + return False + + if self.duration <= 0: + return False + + 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 + ) + 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 + ) + if not sessionSuccess: + # Session might have expired, try to continue without it + print("Session sync failed, continuing with progress updates only") + self.sessionId = None + + # Update session in server link + if self.bookPath: + self.linkManager.update_session(str(self.bookPath), self.sessionId) + + return success + + 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) + + # Clear session from link metadata + if self.bookPath: + self.linkManager.clear_session(str(self.bookPath)) + + self.sessionId = None + + def get_server_progress(self) -> Optional[Dict]: + """ + Get current progress from server + + Returns: + Progress dict with currentTime, duration, progress or None + """ + 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 + + def reset(self): + """Reset sync state (for when switching books)""" + self.sessionId = None + self.serverId = None + self.duration = 0.0 + self.bookPath = None diff --git a/src/mpv_player.py b/src/mpv_player.py index e034718..5b8b5a6 100644 --- a/src/mpv_player.py +++ b/src/mpv_player.py @@ -368,7 +368,7 @@ class MpvPlayer: # Audio file playback methods (for audiobooks) - def load_audio_file(self, audioPath, authToken=None, playbackSpeed=1.0): + def load_audio_file(self, audioPath, authToken=None, playbackSpeed=1.0, startPosition=0.0): """ Load an audio file for streaming playback @@ -376,6 +376,7 @@ class MpvPlayer: audioPath: Path to audio file or URL authToken: Optional Bearer token for authenticated URLs playbackSpeed: Playback speed (0.5 to 2.0, default 1.0) + startPosition: Start time in seconds (for resuming playback) Returns: True if loaded successfully @@ -404,6 +405,12 @@ class MpvPlayer: # pylint: disable=no-member self.player.http_header_fields = [] + # Set start position before loading (more reliable for streams than seeking after) + if startPosition > 0: + self.player.start = startPosition + else: + self.player.start = 0 + # Load the file (mpv handles all formats natively) self.player.loadfile(audioPath, 'replace') self.player.pause = True # Keep paused until play_audio_file() is called