Removed the hard limit of 100 books that was in place for testing. All books should be visible now. Fixed control not stopping speech-dispatcher in the audiobookshelf browser. Reorganized audiobookshelf streaming into it's on file in src to make it easier to work with/maintain.

This commit is contained in:
Storm Dragon
2025-12-11 20:45:19 -05:00
parent 02e772c799
commit cfb1b982a8
4 changed files with 258 additions and 78 deletions

View File

@@ -51,6 +51,7 @@ from src.recent_books_menu import RecentBooksMenu
from src.audiobookshelf_client import AudiobookshelfClient
from src.audiobookshelf_menu import AudiobookshelfMenu
from src.server_link_manager import ServerLinkManager
from src.audiobookshelf_sync import AudiobookshelfSync
from src.bookmarks_menu import BookmarksMenu
from src.wav_exporter import WavExporter
from src.braille_output import BrailleOutput
@@ -139,9 +140,10 @@ class BookReader:
self.absClient = None
self.absMenu = None
self.serverLinkManager = ServerLinkManager()
self.absSync = None # Sync manager (created when absClient initialized)
self.serverBook = None # Server book metadata for streaming
self.isStreaming = False # Track if currently streaming
self.sessionId = None # Active listening session ID
self.sessionId = None # Active listening session ID (legacy, now managed by absSync)
# Initialize reading engine based on config
readerEngine = self.config.get_reader_engine()
@@ -452,18 +454,17 @@ class BookReader:
if not self.absClient or not self.absClient.is_authenticated():
return
# Check if this is a streaming book or server-linked book
serverId = None
# For streaming books, use the sync manager
if self.isStreaming and self.absSync and self.absSync.is_active():
self.absSync.sync_progress(audioPosition)
return
if self.serverBook:
# Streaming book
serverId = self.serverBook.get('id')
else:
# Check if local book is linked to server
serverLink = self.serverLinkManager.get_link(str(self.bookPath))
if serverLink:
serverId = serverLink.get('server_id')
# For downloaded/linked local books, use direct API calls
serverLink = self.serverLinkManager.get_link(str(self.bookPath))
if not serverLink:
return
serverId = serverLink.get('server_id')
if not serverId:
return
@@ -483,23 +484,6 @@ class BookReader:
if success:
print(f"Progress synced to server: {progress * 100:.1f}%")
# Also sync session if active
if self.sessionId:
syncSuccess = self.absClient.sync_session(self.sessionId, currentTime, duration, progress)
if syncSuccess:
# Update session in server link to persist it
if self.bookPath:
self.serverLinkManager.update_session(str(self.bookPath), self.sessionId)
else:
# Session sync failed - might be expired, create new one
print(f"Session sync failed, creating new session...")
newSessionId = self.absClient.create_session(serverId)
if newSessionId:
self.sessionId = newSessionId
if self.bookPath:
self.serverLinkManager.update_session(str(self.bookPath), self.sessionId)
print(f"Created new session: {self.sessionId}")
def reload_tts_engine(self):
"""Reload TTS engine with current config settings"""
readerEngine = self.config.get_reader_engine()
@@ -902,7 +886,12 @@ class BookReader:
self.audioPlayer.stop_audio_file()
# Close Audiobookshelf session if active
if self.sessionId and self.absClient:
if self.absSync and self.absSync.is_active():
try:
self.absSync.close_session()
except:
pass
elif self.sessionId and self.absClient:
try:
self.absClient.close_session(self.sessionId)
except:
@@ -1694,6 +1683,8 @@ class BookReader:
if not self.absClient:
serverUrl = self.config.get_abs_server_url()
self.absClient = AudiobookshelfClient(serverUrl, self.config)
# Initialize sync manager with the client
self.absSync = AudiobookshelfSync(self.absClient, self.serverLinkManager)
# Check if already authenticated
if not self.absClient.is_authenticated():
@@ -1727,6 +1718,12 @@ class BookReader:
def _handle_audiobookshelf_key(self, event):
"""Handle key events when in Audiobookshelf menu"""
# Control key: Stop speech
if event.key == pygame.K_LCTRL or event.key == pygame.K_RCTRL:
if self.speechEngine:
self.speechEngine.stop()
return
if event.key == pygame.K_UP:
self.absMenu.navigate_menu('up')
elif event.key == pygame.K_DOWN:
@@ -1886,20 +1883,26 @@ class BookReader:
author = metadata.get('authorName', '')
duration = media.get('duration', 0.0)
# Get streaming URL (pass full book details to avoid re-fetching)
# Get streaming URL and start session via sync manager
# This single call creates the session and returns stream info with saved position
self.speechEngine.speak(f"Loading stream for {title}. Please wait.")
print(f"\nGetting stream URL for: {title}")
streamUrl = self.absClient.get_stream_url(serverId, itemDetails=serverBook)
if not streamUrl:
sessionData = self.absSync.start_streaming_session(serverBook)
if not sessionData:
self.speechEngine.speak("Failed to get stream URL. Check terminal for errors.")
print("\nERROR: Could not get stream URL")
print("\nERROR: Could not start streaming session")
print(f"Book structure keys: {list(serverBook.keys())}")
if 'media' in serverBook:
print(f"Media keys: {list(serverBook['media'].keys())}")
return
streamUrl = sessionData.get('streamUrl')
startPosition = sessionData.get('startPosition', 0.0)
# Store session ID for backward compatibility
self.sessionId = sessionData.get('sessionId')
# Get chapters from server (from the book details we already have)
serverChapters = media.get('chapters', [])
@@ -1912,12 +1915,12 @@ class BookReader:
if serverChapters:
for chapterData in serverChapters:
chapterTitle = chapterData.get('title', 'Untitled')
startTime = chapterData.get('start', 0.0)
chapterDuration = chapterData.get('end', 0.0) - startTime
chapterStart = chapterData.get('start', 0.0)
chapterDuration = chapterData.get('end', 0.0) - chapterStart
chapter = AudioChapter(
title=chapterTitle,
startTime=startTime,
startTime=chapterStart,
duration=chapterDuration
)
book.add_chapter(chapter)
@@ -1938,18 +1941,13 @@ class BookReader:
self.book = book
self.bookPath = streamUrl # Store URL as path for tracking
# Set book path in sync manager for progress updates
self.absSync.set_book_path(streamUrl)
# Save server book reference for resume on restart
# Use special format: abs://{server_id} so we can detect and resume
self.config.set_last_book(f"abs://{serverId}")
# Create listening session (only if we don't already have one from resume)
if not self.sessionId:
self.sessionId = self.absClient.create_session(serverId)
if self.sessionId:
print(f"Created listening session: {self.sessionId}")
else:
print(f"Using existing session ID: {self.sessionId}")
# Save session and server metadata to server link manager
# This allows resuming the stream with the same session
self.serverLinkManager.create_link(
@@ -1965,21 +1963,15 @@ class BookReader:
serverBook=serverBook
)
# Try to load progress from server
serverProgress = self.absClient.get_progress(serverId)
if serverProgress:
progressTime = serverProgress.get('currentTime', 0.0)
minutes = int(progressTime // 60)
seconds = int(progressTime % 60)
# Save the exact position for playback resume
self.savedAudioPosition = progressTime
# Use position from server (already retrieved during session start)
if startPosition > 0:
self.savedAudioPosition = startPosition
# Find chapter that contains this time
for i, chap in enumerate(book.chapters):
if hasattr(chap, 'startTime'):
chapterEnd = chap.startTime + chap.duration
if chap.startTime <= progressTime < chapterEnd:
if chap.startTime <= startPosition < chapterEnd:
self.currentChapter = i
break
else:
@@ -1991,14 +1983,21 @@ class BookReader:
self.currentParagraph = 0
# Load stream URL directly - mpv will handle streaming natively
print(f"Loading stream: {streamUrl[:80]}...")
# Pass start position when loading (more reliable for streams than seeking after)
startPos = startPosition if startPosition > 0 else 0.0
if startPos > 0:
minutes = int(startPos // 60)
seconds = int(startPos % 60)
print(f"Loading stream from {minutes}m {seconds}s: {streamUrl[:60]}...")
else:
print(f"Loading stream: {streamUrl[:80]}...")
self.speechEngine.speak("Loading stream. This may take a moment.")
# Load the stream URL - mpv handles streaming with auth headers
# Pass auth token for authentication
# Use saved playback speed from config
# Pass auth token for authentication and start position for resuming
playbackSpeed = self.config.get_playback_speed()
if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken, playbackSpeed=playbackSpeed):
if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken,
playbackSpeed=playbackSpeed, startPosition=startPos):
self.speechEngine.speak("Failed to load stream. Check terminal for errors.")
print("\nERROR: Failed to load stream from server")
print("Make sure mpv is installed: sudo pacman -S mpv")
@@ -2021,13 +2020,8 @@ class BookReader:
if self.config.get_show_text() and hasattr(self, 'screen') and self.screen:
self._render_screen()
# Start playback from saved position (if any)
startPos = self.savedAudioPosition if self.savedAudioPosition > 0 else 0.0
if startPos > 0:
minutes = int(startPos // 60)
seconds = int(startPos % 60)
print(f"Seeking to {minutes}m {seconds}s...")
self.audioPlayer.play_audio_file(startPosition=startPos)
# Start playback (position already set during load)
self.audioPlayer.play_audio_file()
self.isPlaying = True
self.isAudioBook = True
@@ -2566,11 +2560,14 @@ class BookReader:
def cleanup(self):
"""Cleanup resources"""
# Close active listening session if any
if self.sessionId and self.absClient:
# Close active listening session via sync manager
if self.absSync and self.absSync.is_active():
self.absSync.close_session()
self.sessionId = None
elif self.sessionId and self.absClient:
# Fallback for legacy code path
print(f"Closing listening session: {self.sessionId}")
self.absClient.close_session(self.sessionId)
# Clear session from link metadata
if self.bookPath:
self.serverLinkManager.clear_session(str(self.bookPath))
self.sessionId = None
@@ -2687,6 +2684,7 @@ def main():
reader = BookReader(None, config)
reader.absClient = absClient
reader.absSync = AudiobookshelfSync(absClient, reader.serverLinkManager)
# Restore session ID if it was saved
savedSessionId = cachedLink.get('session_id')
@@ -2738,6 +2736,7 @@ def main():
reader = BookReader(None, config)
reader.absClient = absClient
reader.absSync = AudiobookshelfSync(absClient, reader.serverLinkManager)
reader._stream_audiobook(bookDetails)
reader.run_interactive()
return 0

View File

@@ -147,13 +147,13 @@ class AudiobookshelfClient:
print(f"Get libraries error: {e}")
return None
def get_library_items(self, libraryId: str, limit: int = 100, page: int = 0) -> Optional[List[Dict]]:
def get_library_items(self, libraryId: str, limit: int = 0, page: int = 0) -> Optional[List[Dict]]:
"""
Get audiobooks in a library
Args:
libraryId: Library ID
limit: Max items to return (default 100)
limit: Max items to return (0 = no limit)
page: Page number for pagination (default 0)
Returns:
@@ -166,9 +166,12 @@ class AudiobookshelfClient:
try:
url = f"{self.serverUrl}/api/libraries/{libraryId}/items"
headers = {'Authorization': f'Bearer {self.authToken}'}
params = {'limit': limit, 'page': page}
# Only include limit param if explicitly set (0 means no limit)
params = {'page': page}
if limit > 0:
params['limit'] = limit
response = requests.get(url, headers=headers, params=params, timeout=10)
response = requests.get(url, headers=headers, params=params, timeout=30)
if response.status_code == 200:
data = response.json()
@@ -327,16 +330,19 @@ class AudiobookshelfClient:
print(f"File write error: {e}")
return False
def get_stream_url(self, itemId: str, itemDetails: Optional[Dict] = None) -> Optional[str]:
def get_stream_url(self, itemId: str, itemDetails: Optional[Dict] = None) -> Optional[Dict]:
"""
Get streaming URL for an audiobook using /play endpoint
Get streaming URL for an audiobook using /play endpoint.
This endpoint creates a playback session on the server and returns
the session ID along with the stream URL and current playback position.
Args:
itemId: Library item ID
itemDetails: Optional pre-fetched item details (not required)
Returns:
Streaming URL with auth token, or None if error
Dict with {streamUrl, sessionId, currentTime} or None if error
"""
if not self.authToken:
print("ERROR: Not authenticated")
@@ -372,6 +378,13 @@ class AudiobookshelfClient:
playData = response.json()
# Extract session ID from the play response
# The /play endpoint creates a session and returns it
sessionId = playData.get('id')
# Extract current playback time (user's saved progress)
currentTime = playData.get('currentTime', 0.0)
# Extract the actual stream URL from the play response
# The response contains either 'audioTracks' or direct 'url'
streamUrl = None
@@ -397,7 +410,11 @@ class AudiobookshelfClient:
if streamUrl.startswith('/'):
streamUrl = f"{self.serverUrl}{streamUrl}"
return streamUrl
return {
'streamUrl': streamUrl,
'sessionId': sessionId,
'currentTime': currentTime
}
except Exception as e:
print(f"Get stream URL error: {e}")

157
src/audiobookshelf_sync.py Normal file
View File

@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Audiobookshelf Sync Manager
Manages synchronization between BookStorm and Audiobookshelf server,
including playback sessions and progress tracking.
"""
from typing import Optional, Dict
class AudiobookshelfSync:
"""Manages sync between BookStorm and Audiobookshelf server"""
def __init__(self, absClient, serverLinkManager):
"""
Initialize sync manager
Args:
absClient: AudiobookshelfClient instance
serverLinkManager: ServerLinkManager instance
"""
self.client = absClient
self.linkManager = serverLinkManager
self.sessionId = None
self.serverId = None
self.duration = 0.0
self.bookPath = None
def start_streaming_session(self, serverBook: Dict) -> Optional[Dict]:
"""
Start a streaming playback session.
The /play endpoint creates a session on the server and returns
the session ID, stream URL, and user's saved progress position.
Args:
serverBook: Server book dictionary from API
Returns:
Dict with {streamUrl, sessionId, startPosition} or None on error
"""
serverId = serverBook.get('id') or serverBook.get('libraryItemId')
if not serverId:
print("ERROR: No server ID found in book metadata")
return None
if not self.client or not self.client.is_authenticated():
print("ERROR: Not authenticated to Audiobookshelf")
return None
# Get stream URL - this creates the session on server
result = self.client.get_stream_url(serverId, itemDetails=serverBook)
if not result:
return None
# Store session info for later syncing
self.sessionId = result.get('sessionId')
self.serverId = serverId
self.duration = serverBook.get('media', {}).get('duration', 0.0)
if self.sessionId:
print(f"Started session: {self.sessionId}")
else:
print("Warning: No session ID in play response")
return {
'streamUrl': result.get('streamUrl'),
'sessionId': self.sessionId,
'startPosition': result.get('currentTime', 0.0)
}
def set_book_path(self, bookPath: str):
"""
Set book path for server link updates
Args:
bookPath: Path to book file or stream URL
"""
self.bookPath = bookPath
def sync_progress(self, currentTime: float) -> bool:
"""
Sync current playback position to server
Args:
currentTime: Current playback position in seconds
Returns:
True if sync successful, False otherwise
"""
if not self.serverId or not self.client or not self.client.is_authenticated():
return False
if self.duration <= 0:
return False
progress = min(currentTime / self.duration, 1.0)
# Update media progress (persists in server DB)
success = self.client.update_progress(
self.serverId, currentTime, self.duration, progress
)
if success:
print(f"Progress synced: {progress * 100:.1f}%")
# Sync session if active
if self.sessionId:
sessionSuccess = self.client.sync_session(
self.sessionId, currentTime, self.duration, progress
)
if not sessionSuccess:
# Session might have expired, try to continue without it
print("Session sync failed, continuing with progress updates only")
self.sessionId = None
# Update session in server link
if self.bookPath:
self.linkManager.update_session(str(self.bookPath), self.sessionId)
return success
def close_session(self):
"""Close the active playback session"""
if self.sessionId and self.client:
print(f"Closing session: {self.sessionId}")
self.client.close_session(self.sessionId)
# Clear session from link metadata
if self.bookPath:
self.linkManager.clear_session(str(self.bookPath))
self.sessionId = None
def get_server_progress(self) -> Optional[Dict]:
"""
Get current progress from server
Returns:
Progress dict with currentTime, duration, progress or None
"""
if not self.serverId or not self.client:
return None
return self.client.get_progress(self.serverId)
def is_active(self) -> bool:
"""Check if sync manager has an active session"""
return self.sessionId is not None and self.serverId is not None
def reset(self):
"""Reset sync state (for when switching books)"""
self.sessionId = None
self.serverId = None
self.duration = 0.0
self.bookPath = None

View File

@@ -368,7 +368,7 @@ class MpvPlayer:
# Audio file playback methods (for audiobooks)
def load_audio_file(self, audioPath, authToken=None, playbackSpeed=1.0):
def load_audio_file(self, audioPath, authToken=None, playbackSpeed=1.0, startPosition=0.0):
"""
Load an audio file for streaming playback
@@ -376,6 +376,7 @@ class MpvPlayer:
audioPath: Path to audio file or URL
authToken: Optional Bearer token for authenticated URLs
playbackSpeed: Playback speed (0.5 to 2.0, default 1.0)
startPosition: Start time in seconds (for resuming playback)
Returns:
True if loaded successfully
@@ -404,6 +405,12 @@ class MpvPlayer:
# pylint: disable=no-member
self.player.http_header_fields = []
# Set start position before loading (more reliable for streams than seeking after)
if startPosition > 0:
self.player.start = startPosition
else:
self.player.start = 0
# Load the file (mpv handles all formats natively)
self.player.loadfile(audioPath, 'replace')
self.player.pause = True # Keep paused until play_audio_file() is called