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
- **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
View File
@@ -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
+1
View File
@@ -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
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
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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