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