type to get to book added, chaning library binding to shift+l. Improved audiobookshelf support.

This commit is contained in:
Storm Dragon
2026-02-26 20:29:13 -05:00
parent cfb1b982a8
commit badab833df
9 changed files with 1309 additions and 855 deletions
+7 -5
View File
@@ -100,9 +100,10 @@ python bookstorm.py mybook.epub --wav --output-dir ./audiobooks
### Book Browser Controls ### Book Browser Controls
- **UP/DOWN** - Navigate items - **UP/DOWN** - Navigate items
- **Type letters** - Jump to the first item whose name starts with what you typed
- **ENTER** - Select book or enter directory - **ENTER** - Select book or enter directory
- **BACKSPACE/LEFT** - Go to parent 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 - **ESC** - Cancel and return
## Audiobookshelf Integration ## 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 - **Browse Libraries**: Navigate multiple libraries, series, and collections
- **Stream or Download**: Choose to stream directly or download for offline use - **Stream or Download**: Choose to stream directly or download for offline use
- **Progress Sync**: Your reading progress syncs between BookStorm and Audiobookshelf - **Progress Sync**: Streaming and linked local books sync playback progress to Audiobookshelf, including on quit
- **Bookmark Sync**: Named bookmarks sync to the server (when enabled) - **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 - **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 ### Stream vs Download
@@ -189,7 +191,7 @@ Bookmarks are stored in `~/.bookstorm/bookmarks.db` (SQLite database).
### Auto-Save Bookmarks ### 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 ### Named Bookmarks
@@ -199,7 +201,7 @@ Press **k** to open the bookmarks menu where you can:
- Delete bookmarks you no longer need - Delete bookmarks you no longer need
- View all bookmarks for the current book - 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 ## Supported Formats
+397 -140
View File
@@ -144,6 +144,7 @@ class BookReader:
self.serverBook = None # Server book metadata for streaming self.serverBook = None # Server book metadata for streaming
self.isStreaming = False # Track if currently streaming self.isStreaming = False # Track if currently streaming
self.sessionId = None # Active listening session ID (legacy, now managed by absSync) 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 # Initialize reading engine based on config
readerEngine = self.config.get_reader_engine() readerEngine = self.config.get_reader_engine()
@@ -286,14 +287,7 @@ class BookReader:
# For audio books, save exact position # For audio books, save exact position
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
self.savedAudioPosition = progressTime self.savedAudioPosition = progressTime
self._set_position_from_audio_time(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
else: else:
# Text book - use chapter/paragraph from server if available # Text book - use chapter/paragraph from server if available
# (Audiobookshelf doesn't track paragraph, so we'd need to enhance this) # (Audiobookshelf doesn't track paragraph, so we'd need to enhance this)
@@ -391,6 +385,266 @@ class BookReader:
return True return True
return False 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): def save_bookmark(self, speakFeedback=True):
"""Save current position as bookmark """Save current position as bookmark
@@ -413,77 +667,22 @@ class BookReader:
if playlistIndex != self.currentChapter: if playlistIndex != self.currentChapter:
self.currentChapter = playlistIndex self.currentChapter = playlistIndex
# For audio books, calculate current playback position
audioPosition = 0.0
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
# Get current chapter start time audioPosition = self._get_current_audio_position()
chapter = self.book.get_chapter(self.currentChapter) self._save_audio_bookmark(audioPosition, speakFeedback=speakFeedback)
if chapter and hasattr(chapter, 'startTime'): return
chapterStartTime = chapter.startTime
else:
chapterStartTime = 0.0
# Get playback position within the audio file
if self.audioPlayer.is_audio_file_playing() or self.audioPlayer.is_paused():
playbackPos = self.audioPlayer.get_audio_position()
# Total position = chapter start + position within current playback
audioPosition = chapterStartTime + playbackPos
self.bookmarkManager.save_bookmark( self.bookmarkManager.save_bookmark(
self.bookPath, self.bookPath,
self.book.title, self.book.title,
self.currentChapter, self.currentChapter,
self.currentParagraph, self.currentParagraph,
audioPosition=audioPosition audioPosition=0.0
) )
# Sync progress to server if streaming or server-linked
self._sync_progress_to_server(audioPosition)
if speakFeedback: if speakFeedback:
self.speechEngine.speak("Bookmark saved") 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): def reload_tts_engine(self):
"""Reload TTS engine with current config settings""" """Reload TTS engine with current config settings"""
readerEngine = self.config.get_reader_engine() readerEngine = self.config.get_reader_engine()
@@ -738,6 +937,8 @@ class BookReader:
if self.isPlaying and not inAnyMenu and self.book: if self.isPlaying and not inAnyMenu and self.book:
if isAudioBook: if isAudioBook:
self._maybe_sync_abs_progress()
# For multi-file audiobooks, sync playlist position periodically # For multi-file audiobooks, sync playlist position periodically
# This keeps currentChapter in sync with mpv's actual position # This keeps currentChapter in sync with mpv's actual position
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile: 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(): if not self.audioPlayer.is_audio_file_playing() and not self.audioPlayer.is_paused():
# Audio chapter finished, advance to next chapter # Audio chapter finished, advance to next chapter
if not self.next_chapter(): if not self.next_chapter():
# Book finished - restart from beginning self._handle_finished_audiobook()
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()
else: else:
# Start next chapter # Start next chapter
self._start_paragraph_playback() self._start_paragraph_playback()
@@ -874,6 +1066,7 @@ class BookReader:
finally: finally:
# Save bookmark BEFORE stopping (so we can get current position) # Save bookmark BEFORE stopping (so we can get current position)
self.save_bookmark(speakFeedback=False) self.save_bookmark(speakFeedback=False)
self._close_abs_session()
# Stop playback # Stop playback
readerEngine = self.config.get_reader_engine() readerEngine = self.config.get_reader_engine()
@@ -885,18 +1078,6 @@ class BookReader:
if self.audioPlayer.is_audio_file_loaded(): if self.audioPlayer.is_audio_file_loaded():
self.audioPlayer.stop_audio_file() 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 # Clean up speech engines
if self.speechEngine: if self.speechEngine:
self.speechEngine.close() self.speechEngine.close()
@@ -1013,8 +1194,13 @@ class BookReader:
# Handle audio book pause/resume # Handle audio book pause/resume
if self.audioPlayer.is_paused(): if self.audioPlayer.is_paused():
self.audioPlayer.resume_audio_file() self.audioPlayer.resume_audio_file()
if self.isStreaming and self.absSync:
self.absSync.mark_progress_checkpoint(self._get_current_audio_position())
else: else:
self._sync_progress_to_server(self._get_current_audio_position(), updateSession=False)
self.audioPlayer.pause_audio_file() self.audioPlayer.pause_audio_file()
if self.isStreaming and self.absSync:
self.absSync.mark_progress_checkpoint(self._get_current_audio_position())
elif readerEngine == 'speechd': elif readerEngine == 'speechd':
# Handle speech-dispatcher pause/resume # Handle speech-dispatcher pause/resume
if self.readingEngine.is_reading_paused(): if self.readingEngine.is_reading_paused():
@@ -1173,6 +1359,7 @@ class BookReader:
elif event.key == pygame.K_k: elif event.key == pygame.K_k:
# Open bookmarks menu # Open bookmarks menu
if self.book: if self.book:
self._sync_named_bookmarks_from_server()
self.bookmarksMenu.enter_menu(str(self.bookPath)) self.bookmarksMenu.enter_menu(str(self.bookPath))
else: else:
self.speechEngine.speak("No book loaded") self.speechEngine.speak("No book loaded")
@@ -1293,11 +1480,25 @@ class BookReader:
self.isPlaying = False self.isPlaying = False
self._stop_playback() 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) # Delete ALL bookmarks for current book (auto-save + named)
self.bookmarkManager.delete_bookmark(self.bookPath) # Auto-save bookmark 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 self.bookmarkCleared = True # Mark that bookmark was explicitly cleared
if serverDeleteFailed:
self.speechEngine.speak("Could not clear server bookmarks")
return
# Jump to beginning # Jump to beginning
self.currentChapter = 0 self.currentChapter = 0
self.currentParagraph = 0 self.currentParagraph = 0
@@ -1522,7 +1723,17 @@ class BookReader:
elif event.key == pygame.K_DELETE or event.key == pygame.K_d: elif event.key == pygame.K_DELETE or event.key == pygame.K_d:
# Delete current bookmark # 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: elif event.key == pygame.K_ESCAPE:
self.bookmarksMenu.exit_menu() self.bookmarksMenu.exit_menu()
@@ -1644,8 +1855,7 @@ class BookReader:
playbackPos = self.audioPlayer.get_audio_position() playbackPos = self.audioPlayer.get_audio_position()
audioPosition = chapterStartTime + playbackPos audioPosition = chapterStartTime + playbackPos
# Create bookmark bookmarkId = self.bookmarkManager.create_named_bookmark(
success = self.bookmarkManager.create_named_bookmark(
self.bookPath, self.bookPath,
bookmarkName, bookmarkName,
self.currentChapter, self.currentChapter,
@@ -1653,18 +1863,33 @@ class BookReader:
audioPosition=audioPosition audioPosition=audioPosition
) )
if success: if bookmarkId is None:
if self.speechEngine:
self.speechEngine.speak(f"Bookmark created: {bookmarkName}")
print(f"Bookmark '{bookmarkName}' created!")
# Reload bookmarks in menu
self.bookmarksMenu._load_bookmarks()
self.bookmarksMenu._speak_current_item()
else:
if self.speechEngine: if self.speechEngine:
self.speechEngine.speak(f"Bookmark name already exists: {bookmarkName}") self.speechEngine.speak(f"Bookmark name already exists: {bookmarkName}")
print(f"ERROR: Bookmark with name '{bookmarkName}' already exists") 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): def _open_audiobookshelf_browser(self):
"""Open Audiobookshelf browser""" """Open Audiobookshelf browser"""
@@ -1763,6 +1988,9 @@ class BookReader:
elif event.key == pygame.K_ESCAPE: elif event.key == pygame.K_ESCAPE:
if self.absMenu: if self.absMenu:
self.absMenu.exit_menu() 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): def _handle_browser_key(self, event):
"""Handle key events when in book browser""" """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: elif event.key == pygame.K_BACKSPACE or event.key == pygame.K_LEFT:
# Go to parent directory # Go to parent directory
self.bookSelector.go_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 # Set current directory as library directory
currentDir = self.bookSelector.get_current_directory() currentDir = self.bookSelector.get_current_directory()
self.config.set_library_directory(str(currentDir)) self.config.set_library_directory(str(currentDir))
dirName = currentDir.name if currentDir.name else str(currentDir) dirName = currentDir.name if currentDir.name else str(currentDir)
self.speechEngine.speak(f"Library set to {dirName}") 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: elif event.key == pygame.K_ESCAPE:
self.bookSelector.exit_browser() self.bookSelector.exit_browser()
@@ -1867,6 +2097,14 @@ class BookReader:
Args: Args:
serverBook: Server book dictionary from API 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 # Extract book metadata
# Try different ID fields (structure varies by API endpoint) # Try different ID fields (structure varies by API endpoint)
serverId = serverBook.get('id') or serverBook.get('libraryItemId') serverId = serverBook.get('id') or serverBook.get('libraryItemId')
@@ -1898,18 +2136,23 @@ class BookReader:
return return
streamUrl = sessionData.get('streamUrl') streamUrl = sessionData.get('streamUrl')
streamUrls = sessionData.get('streamUrls') or ([streamUrl] if streamUrl else [])
startPosition = sessionData.get('startPosition', 0.0) startPosition = sessionData.get('startPosition', 0.0)
playData = sessionData.get('playData', {})
# Store session ID for backward compatibility # Store session ID for backward compatibility
self.sessionId = sessionData.get('sessionId') self.sessionId = sessionData.get('sessionId')
# Get chapters from server (from the book details we already have) # Get chapters from server (from the book details we already have)
serverChapters = media.get('chapters', []) serverChapters = media.get('chapters', [])
audioTracks = playData.get('audioTracks', []) if isinstance(playData, dict) else []
# Create AudioBook object with stream URL # Create AudioBook object with stream URL
from src.audio_parser import AudioBook, AudioChapter from src.audio_parser import AudioBook, AudioChapter
book = AudioBook(title=title, author=author, audioPath=streamUrl) book = AudioBook(title=title, author=author, audioPath=streamUrl)
book.totalDuration = duration book.totalDuration = duration
book.audioFiles = streamUrls
book.isMultiFile = len(streamUrls) > 1
# Add chapters from server # Add chapters from server
if serverChapters: if serverChapters:
@@ -1924,6 +2167,32 @@ class BookReader:
duration=chapterDuration duration=chapterDuration
) )
book.add_chapter(chapter) 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: else:
# No chapters - treat entire book as single chapter # No chapters - treat entire book as single chapter
chapter = AudioChapter( chapter = AudioChapter(
@@ -1933,9 +2202,13 @@ class BookReader:
) )
book.add_chapter(chapter) 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 # Store server metadata for progress sync
self.serverBook = serverBook self.serverBook = serverBook
self.isStreaming = True self.isStreaming = True
self.lastAbsSyncAt = 0.0
# Load the book # Load the book
self.book = book self.book = book
@@ -1966,14 +2239,7 @@ class BookReader:
# Use position from server (already retrieved during session start) # Use position from server (already retrieved during session start)
if startPosition > 0: if startPosition > 0:
self.savedAudioPosition = startPosition self.savedAudioPosition = startPosition
self._set_position_from_audio_time(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
else: else:
# No server progress, start from beginning # No server progress, start from beginning
self.currentChapter = 0 self.currentChapter = 0
@@ -1993,14 +2259,20 @@ class BookReader:
print(f"Loading stream: {streamUrl[:80]}...") print(f"Loading stream: {streamUrl[:80]}...")
self.speechEngine.speak("Loading stream. This may take a moment.") 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() playbackSpeed = self.config.get_playback_speed()
if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken, if len(streamUrls) > 1:
playbackSpeed=playbackSpeed, startPosition=startPos): 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.") self.speechEngine.speak("Failed to load stream. Check terminal for errors.")
print("\nERROR: Failed to load stream from server") print("\nERROR: Failed to load stream from server")
print("Make sure mpv is installed: sudo pacman -S mpv") print("Make sure mpv is installed: sudo pacman -S mpv")
self._close_abs_session(finalPosition=startPos)
self.isStreaming = False
return return
# Restore saved volume setting # Restore saved volume setting
@@ -2020,13 +2292,9 @@ class BookReader:
if self.config.get_show_text() and hasattr(self, 'screen') and self.screen: if self.config.get_show_text() and hasattr(self, 'screen') and self.screen:
self._render_screen() self._render_screen()
# Start playback (position already set during load)
self.audioPlayer.play_audio_file()
self.isPlaying = True self.isPlaying = True
self.isAudioBook = True self.isAudioBook = True
self._start_audio_chapter_playback(self.book.get_chapter(self.currentChapter))
# Clear saved position after using it
self.savedAudioPosition = 0.0
def _download_audiobook(self, serverBook): def _download_audiobook(self, serverBook):
""" """
@@ -2038,9 +2306,9 @@ class BookReader:
# Check library directory is set # Check library directory is set
libraryDir = self.config.get_library_directory() libraryDir = self.config.get_library_directory()
if not libraryDir: 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("\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 return
libraryPath = Path(libraryDir) libraryPath = Path(libraryDir)
@@ -2212,6 +2480,7 @@ class BookReader:
# (so we can capture the current audio position) # (so we can capture the current audio position)
if self.book: if self.book:
self.save_bookmark(speakFeedback=False) self.save_bookmark(speakFeedback=False)
self._close_abs_session()
# Stop current playback # Stop current playback
self.audioPlayer.stop() self.audioPlayer.stop()
@@ -2226,6 +2495,11 @@ class BookReader:
# Reset audio position state # Reset audio position state
self.savedAudioPosition = 0.0 self.savedAudioPosition = 0.0
self.bookmarkCleared = False 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) # Load new book (which will restore bookmark if it exists)
try: try:
@@ -2560,17 +2834,7 @@ class BookReader:
def cleanup(self): def cleanup(self):
"""Cleanup resources""" """Cleanup resources"""
# Close active listening session via sync manager self._close_abs_session()
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
# Save current volume setting if audio book or Piper-TTS was used # Save current volume setting if audio book or Piper-TTS was used
if self.book: if self.book:
@@ -2685,13 +2949,6 @@ def main():
reader = BookReader(None, config) reader = BookReader(None, config)
reader.absClient = absClient reader.absClient = absClient
reader.absSync = AudiobookshelfSync(absClient, reader.serverLinkManager) 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._stream_audiobook(serverBook)
reader.run_interactive() reader.run_interactive()
return 0 return 0
+1
View File
@@ -6,6 +6,7 @@ mutagen>=1.45.0
pypdf pypdf
mpv mpv
requests>=2.25.0 requests>=2.25.0
setproctitle>=1.3.0
# Braille display support (optional) # Braille display support (optional)
# Note: These are system packages, not pip packages # Note: These are system packages, not pip packages
File diff suppressed because it is too large Load Diff
+71 -3
View File
@@ -8,6 +8,7 @@ Browse libraries, audiobooks, series, and collections.
""" """
from pathlib import Path from pathlib import Path
import time
class AudiobookshelfMenu: class AudiobookshelfMenu:
@@ -31,6 +32,8 @@ class AudiobookshelfMenu:
self.currentView = 'libraries' # 'libraries', 'books', 'stream_download' self.currentView = 'libraries' # 'libraries', 'books', 'stream_download'
self.currentSelection = 0 self.currentSelection = 0
self.items = [] self.items = []
self.searchBuffer = ''
self.lastSearchAt = 0.0
# Context tracking # Context tracking
self.selectedLibrary = None self.selectedLibrary = None
@@ -50,6 +53,7 @@ class AudiobookshelfMenu:
"""Enter the Audiobookshelf browser""" """Enter the Audiobookshelf browser"""
self.inMenu = True self.inMenu = True
self.currentSelection = 0 self.currentSelection = 0
self._reset_search()
# Reset state from previous sessions # Reset state from previous sessions
self.selectedLibrary = None self.selectedLibrary = None
@@ -77,7 +81,7 @@ class AudiobookshelfMenu:
self.currentView = 'libraries' self.currentView = 'libraries'
self.items = libraries self.items = libraries
if self.speechEngine: 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() self._speak_current_item()
def _load_books(self): def _load_books(self):
@@ -110,9 +114,10 @@ class AudiobookshelfMenu:
self.items = books self.items = books
self.currentView = 'books' self.currentView = 'books'
self.currentSelection = 0 self.currentSelection = 0
self._reset_search()
if self.speechEngine: 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() self._speak_current_item()
def _check_if_local(self, book): def _check_if_local(self, book):
@@ -179,6 +184,61 @@ class AudiobookshelfMenu:
self._speak_current_item() 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): def change_view(self, direction):
"""Change view mode (only in books view)""" """Change view mode (only in books view)"""
if self.currentView != 'books': if self.currentView != 'books':
@@ -289,6 +349,7 @@ class AudiobookshelfMenu:
# Go back to books list # Go back to books list
self.currentView = 'books' self.currentView = 'books'
self.currentSelection = 0 self.currentSelection = 0
self._reset_search()
if self.speechEngine: if self.speechEngine:
self.speechEngine.speak("Cancelled") self.speechEngine.speak("Cancelled")
self._speak_current_item() self._speak_current_item()
@@ -337,6 +398,7 @@ class AudiobookshelfMenu:
self.items = seriesBooks self.items = seriesBooks
self.booksViewMode = 'all' # Switch to "all books" view self.booksViewMode = 'all' # Switch to "all books" view
self.currentSelection = 0 self.currentSelection = 0
self._reset_search()
if self.speechEngine: if self.speechEngine:
seriesName = item.get('name', 'Series') seriesName = item.get('name', 'Series')
@@ -356,6 +418,7 @@ class AudiobookshelfMenu:
self.items = collectionBooks self.items = collectionBooks
self.booksViewMode = 'all' # Switch to "all books" view self.booksViewMode = 'all' # Switch to "all books" view
self.currentSelection = 0 self.currentSelection = 0
self._reset_search()
if self.speechEngine: if self.speechEngine:
collectionName = item.get('name', 'Collection') collectionName = item.get('name', 'Collection')
@@ -409,6 +472,7 @@ class AudiobookshelfMenu:
# Book not local - enter stream/download submenu # Book not local - enter stream/download submenu
self.currentView = 'stream_download' self.currentView = 'stream_download'
self.currentSelection = 0 self.currentSelection = 0
self._reset_search()
if self.speechEngine: if self.speechEngine:
self.speechEngine.speak("Choose playback option. Use arrow keys to navigate.") self.speechEngine.speak("Choose playback option. Use arrow keys to navigate.")
self._speak_current_item() self._speak_current_item()
@@ -459,6 +523,7 @@ class AudiobookshelfMenu:
# Go back to books list # Go back to books list
self.currentView = 'books' self.currentView = 'books'
self.currentSelection = 0 self.currentSelection = 0
self._reset_search()
if self.speechEngine: if self.speechEngine:
self.speechEngine.speak("Back to books") self.speechEngine.speak("Back to books")
self._speak_current_item() self._speak_current_item()
@@ -470,6 +535,7 @@ class AudiobookshelfMenu:
self.items = libraries self.items = libraries
self.currentSelection = 0 self.currentSelection = 0
self.selectedLibrary = None self.selectedLibrary = None
self._reset_search()
if self.speechEngine: if self.speechEngine:
self.speechEngine.speak("Back to libraries") self.speechEngine.speak("Back to libraries")
self._speak_current_item() self._speak_current_item()
@@ -511,6 +577,7 @@ class AudiobookshelfMenu:
self.items = seriesList self.items = seriesList
self.currentView = 'books' # Keep in books view but showing series self.currentView = 'books' # Keep in books view but showing series
self.currentSelection = 0 self.currentSelection = 0
self._reset_search()
if self.speechEngine: if self.speechEngine:
self.speechEngine.speak(f"Loaded {len(seriesList)} series") self.speechEngine.speak(f"Loaded {len(seriesList)} series")
@@ -543,6 +610,7 @@ class AudiobookshelfMenu:
self.items = collectionsList self.items = collectionsList
self.currentView = 'books' # Keep in books view but showing collections self.currentView = 'books' # Keep in books view but showing collections
self.currentSelection = 0 self.currentSelection = 0
self._reset_search()
if self.speechEngine: if self.speechEngine:
self.speechEngine.speak(f"Loaded {len(collectionsList)} collections") self.speechEngine.speak(f"Loaded {len(collectionsList)} collections")
@@ -555,6 +623,6 @@ class AudiobookshelfMenu:
self.items = [] self.items = []
self.selectedLibrary = None self.selectedLibrary = None
self.selectedBook = None self.selectedBook = None
self._reset_search()
if self.speechEngine: if self.speechEngine:
self.speechEngine.speak("Closed Audiobookshelf browser") self.speechEngine.speak("Closed Audiobookshelf browser")
+124 -69
View File
@@ -3,19 +3,19 @@
""" """
Audiobookshelf Sync Manager Audiobookshelf Sync Manager
Manages synchronization between BookStorm and Audiobookshelf server, Tracks streaming session lifecycle and persisted progress updates.
including playback sessions and progress tracking.
""" """
import time
from typing import Optional, Dict from typing import Optional, Dict
class AudiobookshelfSync: class AudiobookshelfSync:
"""Manages sync between BookStorm and Audiobookshelf server""" """Manage Audiobookshelf playback sync for a single active item."""
def __init__(self, absClient, serverLinkManager): def __init__(self, absClient, serverLinkManager):
""" """
Initialize sync manager Initialize sync manager.
Args: Args:
absClient: AudiobookshelfClient instance absClient: AudiobookshelfClient instance
@@ -27,131 +27,186 @@ class AudiobookshelfSync:
self.serverId = None self.serverId = None
self.duration = 0.0 self.duration = 0.0
self.bookPath = None 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]: def start_streaming_session(self, serverBook: Dict) -> Optional[Dict]:
""" """
Start a streaming playback session. Start a streaming playback session with /play.
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: 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') serverId = serverBook.get('id') or serverBook.get('libraryItemId')
if not serverId: if not serverId:
print("ERROR: No server ID found in book metadata") print("ERROR: No Audiobookshelf item ID found for stream start.")
return None return None
if not self.client or not self.client.is_authenticated(): if not self.client or not self.client.is_authenticated():
print("ERROR: Not authenticated to Audiobookshelf") print("ERROR: Not authenticated to Audiobookshelf.")
return None return None
# Get stream URL - this creates the session on server
result = self.client.get_stream_url(serverId, itemDetails=serverBook) result = self.client.get_stream_url(serverId, itemDetails=serverBook)
if not result: if not result:
return None return None
# Store session info for later syncing
self.sessionId = result.get('sessionId') self.sessionId = result.get('sessionId')
self.serverId = serverId 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: if self.sessionId:
print(f"Started session: {self.sessionId}") print(f"Started Audiobookshelf session: {self.sessionId}")
else: else:
print("Warning: No session ID in play response") print("Audiobookshelf stream started without a session ID. Falling back to direct progress updates.")
return { return {
'streamUrl': result.get('streamUrl'), 'streamUrl': result.get('streamUrl'),
'streamUrls': result.get('streamUrls', []),
'sessionId': self.sessionId, 'sessionId': self.sessionId,
'startPosition': result.get('currentTime', 0.0) 'startPosition': startPosition,
'playData': result.get('playData', {})
} }
def set_book_path(self, bookPath: str): def set_book_path(self, bookPath: str):
""" """Store the path or URL used for this stream."""
Set book path for server link updates
Args:
bookPath: Path to book file or stream URL
"""
self.bookPath = bookPath 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: Args:
currentTime: Current playback position in seconds currentTime: Current playback position in seconds
updateSession: Whether to send session heartbeat data
Returns: isFinished: Optional finished flag override
True if sync successful, False otherwise
""" """
if not self.serverId or not self.client or not self.client.is_authenticated(): if not self.serverId or not self.client or not self.client.is_authenticated():
return False return False
if self.duration <= 0: currentTime = max(0.0, self.client._coerce_float(currentTime))
return False progress = self._calculate_progress(currentTime)
if isFinished is None:
isFinished = progress >= 0.999 and self.duration > 0
progress = min(currentTime / self.duration, 1.0) progressSaved = self.client.update_progress(
self.serverId,
# Update media progress (persists in server DB) currentTime,
success = self.client.update_progress( self.duration,
self.serverId, currentTime, self.duration, progress progress=progress,
isFinished=isFinished
) )
if success:
print(f"Progress synced: {progress * 100:.1f}%")
# Sync session if active sessionSaved = True
if self.sessionId: if self.sessionId and updateSession:
sessionSuccess = self.client.sync_session( timeListened = self._calculate_time_listened(currentTime)
self.sessionId, currentTime, self.duration, progress sessionSaved = self.client.sync_session(
self.sessionId,
currentTime,
self.duration,
progress=progress,
timeListened=timeListened
) )
if not sessionSuccess: if not sessionSaved:
# Session might have expired, try to continue without it print("Audiobookshelf session sync failed. Continuing with direct progress updates.")
print("Session sync failed, continuing with progress updates only")
self.sessionId = None self.sessionId = None
# Update session in server link if progressSaved or sessionSaved:
if self.bookPath: self._update_sync_state(currentTime)
self.linkManager.update_session(str(self.bookPath), self.sessionId)
return success if self.bookPath:
self.linkManager.update_session(str(self.bookPath), self.sessionId)
def close_session(self): return progressSaved
"""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 def close_session(self, currentTime: float = 0.0, isFinished: bool = None):
if self.bookPath: """Close the active playback session and persist final progress."""
self.linkManager.clear_session(str(self.bookPath)) 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.sessionId = None
self.lastSyncAt = None
def get_server_progress(self) -> Optional[Dict]: def get_server_progress(self) -> Optional[Dict]:
""" """Fetch persisted progress for the active server item."""
Get current progress from server
Returns:
Progress dict with currentTime, duration, progress or None
"""
if not self.serverId or not self.client: if not self.serverId or not self.client:
return None return None
return self.client.get_progress(self.serverId) return self.client.get_progress(self.serverId)
def is_active(self) -> bool: def is_active(self) -> bool:
"""Check if sync manager has an active session""" """Return True when an active server item is being tracked."""
return self.sessionId is not None and self.serverId is not None 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): def reset(self):
"""Reset sync state (for when switching books)""" """Clear sync state for the current item."""
self.sessionId = None self.sessionId = None
self.serverId = None self.serverId = None
self.duration = 0.0 self.duration = 0.0
self.bookPath = None self.bookPath = None
self.lastSyncPosition = 0.0
self.lastSyncAt = None
+43 -1
View File
@@ -8,6 +8,7 @@ Supports navigation and filtering by supported formats.
""" """
from pathlib import Path from pathlib import Path
import time
import zipfile import zipfile
@@ -35,6 +36,8 @@ class BookSelector:
self.currentSelection = 0 self.currentSelection = 0
self.inBrowser = False self.inBrowser = False
self.items = [] self.items = []
self.searchBuffer = ''
self.lastSearchAt = 0.0
def _list_items(self): def _list_items(self):
@@ -182,9 +185,10 @@ class BookSelector:
self.inBrowser = True self.inBrowser = True
self.currentSelection = 0 self.currentSelection = 0
self.items = self._list_items() self.items = self._list_items()
self._reset_search()
if self.speechEngine: 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 # Speak current directory and first item
if self.speechEngine: if self.speechEngine:
@@ -208,6 +212,41 @@ class BookSelector:
self._speak_current_item() 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): def _speak_current_item(self):
"""Speak current item with name first, then type""" """Speak current item with name first, then type"""
if not self.items or not self.speechEngine: if not self.items or not self.speechEngine:
@@ -251,6 +290,7 @@ class BookSelector:
self.currentDir = item['path'] self.currentDir = item['path']
self.currentSelection = 0 self.currentSelection = 0
self.items = self._list_items() self.items = self._list_items()
self._reset_search()
if self.speechEngine: if self.speechEngine:
dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir) dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir)
@@ -275,6 +315,7 @@ class BookSelector:
self.currentDir = parent self.currentDir = parent
self.currentSelection = 0 self.currentSelection = 0
self.items = self._list_items() self.items = self._list_items()
self._reset_search()
if self.speechEngine: if self.speechEngine:
dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir) dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir)
@@ -295,5 +336,6 @@ class BookSelector:
def exit_browser(self): def exit_browser(self):
"""Exit the browser""" """Exit the browser"""
self.inBrowser = False self.inBrowser = False
self._reset_search()
if self.speechEngine: if self.speechEngine:
self.speechEngine.speak("Cancelled") self.speechEngine.speak("Cancelled")
+116 -12
View File
@@ -67,10 +67,23 @@ class BookmarkManager:
paragraph_index INTEGER NOT NULL DEFAULT 0, paragraph_index INTEGER NOT NULL DEFAULT 0,
audio_position REAL DEFAULT 0.0, audio_position REAL DEFAULT 0.0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
server_library_item_id TEXT,
server_time INTEGER,
server_created_at INTEGER,
UNIQUE(book_id, name) 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() conn.commit()
def _get_book_id(self, bookPath): def _get_book_id(self, bookPath):
@@ -189,7 +202,8 @@ class BookmarkManager:
return bookmarks 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 Create a named bookmark for a book
@@ -201,7 +215,7 @@ class BookmarkManager:
audioPosition: Audio position in seconds (default: 0.0) audioPosition: Audio position in seconds (default: 0.0)
Returns: 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) bookId = self._get_book_id(bookPath)
timestamp = datetime.now().isoformat() timestamp = datetime.now().isoformat()
@@ -212,16 +226,18 @@ class BookmarkManager:
cursor.execute(''' cursor.execute('''
INSERT INTO named_bookmarks INSERT INTO named_bookmarks
(book_id, name, chapter_index, paragraph_index, audio_position, created_at) (book_id, name, chapter_index, paragraph_index, audio_position, created_at,
VALUES (?, ?, ?, ?, ?, ?) server_library_item_id, server_time, server_created_at)
''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp,
serverLibraryItemId, serverTime, serverCreatedAt))
conn.commit() conn.commit()
return True return cursor.lastrowid
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
# Bookmark with this name already exists # Bookmark with this name already exists
return False return None
def get_named_bookmarks(self, bookPath): def get_named_bookmarks(self, bookPath):
""" """
@@ -239,7 +255,8 @@ class BookmarkManager:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT id, name, chapter_index, paragraph_index, audio_position, created_at SELECT id, name, chapter_index, paragraph_index, audio_position, created_at,
server_library_item_id, server_time, server_created_at
FROM named_bookmarks FROM named_bookmarks
WHERE book_id = ? WHERE book_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -255,11 +272,95 @@ class BookmarkManager:
'chapterIndex': row[2], 'chapterIndex': row[2],
'paragraphIndex': row[3], 'paragraphIndex': row[3],
'audioPosition': row[4], 'audioPosition': row[4],
'createdAt': row[5] 'createdAt': row[5],
'serverLibraryItemId': row[6],
'serverTime': row[7],
'serverCreatedAt': row[8]
}) })
return bookmarks 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): def delete_named_bookmark(self, bookmarkId):
""" """
Delete a named bookmark by ID Delete a named bookmark by ID
@@ -300,7 +401,8 @@ class BookmarkManager:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute('''
SELECT name, chapter_index, paragraph_index, audio_position SELECT name, chapter_index, paragraph_index, audio_position,
server_library_item_id, server_time, server_created_at
FROM named_bookmarks FROM named_bookmarks
WHERE id = ? WHERE id = ?
''', (bookmarkId,)) ''', (bookmarkId,))
@@ -312,8 +414,10 @@ class BookmarkManager:
'name': row[0], 'name': row[0],
'chapterIndex': row[1], 'chapterIndex': row[1],
'paragraphIndex': row[2], 'paragraphIndex': row[2],
'audioPosition': row[3] 'audioPosition': row[3],
'serverLibraryItemId': row[4],
'serverTime': row[5],
'serverCreatedAt': row[6]
} }
return None return None
+40 -15
View File
@@ -143,6 +143,45 @@ class BookmarksMenu:
return None 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): def delete_current_bookmark(self):
""" """
Delete currently selected bookmark Delete currently selected bookmark
@@ -162,21 +201,7 @@ class BookmarksMenu:
bookmark = option['data'] bookmark = option['data']
bookmarkId = bookmark['id'] bookmarkId = bookmark['id']
bookmarkName = bookmark['name'] bookmarkName = bookmark['name']
self.remove_bookmark(bookmarkId, bookmarkName)
# Delete from database
self.bookmarkManager.delete_named_bookmark(bookmarkId)
if self.speechEngine:
self.speechEngine.speak(f"Deleted bookmark: {bookmarkName}")
# Reload bookmarks
self._load_bookmarks()
# Adjust selection if needed
if self.currentSelection >= len(self.listOptions):
self.currentSelection = max(0, len(self.listOptions) - 1)
self._speak_current_item()
return True return True
return False return False