type to get to book added, chaning library binding to shift+l. Improved audiobookshelf support.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+397
-140
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+510
-610
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
|
||||
+124
-69
@@ -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
|
||||
|
||||
+43
-1
@@ -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")
|
||||
|
||||
+116
-12
@@ -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
|
||||
|
||||
|
||||
+40
-15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user