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:
+68
-69
@@ -51,6 +51,7 @@ from src.recent_books_menu import RecentBooksMenu
|
|||||||
from src.audiobookshelf_client import AudiobookshelfClient
|
from src.audiobookshelf_client import AudiobookshelfClient
|
||||||
from src.audiobookshelf_menu import AudiobookshelfMenu
|
from src.audiobookshelf_menu import AudiobookshelfMenu
|
||||||
from src.server_link_manager import ServerLinkManager
|
from src.server_link_manager import ServerLinkManager
|
||||||
|
from src.audiobookshelf_sync import AudiobookshelfSync
|
||||||
from src.bookmarks_menu import BookmarksMenu
|
from src.bookmarks_menu import BookmarksMenu
|
||||||
from src.wav_exporter import WavExporter
|
from src.wav_exporter import WavExporter
|
||||||
from src.braille_output import BrailleOutput
|
from src.braille_output import BrailleOutput
|
||||||
@@ -139,9 +140,10 @@ class BookReader:
|
|||||||
self.absClient = None
|
self.absClient = None
|
||||||
self.absMenu = None
|
self.absMenu = None
|
||||||
self.serverLinkManager = ServerLinkManager()
|
self.serverLinkManager = ServerLinkManager()
|
||||||
|
self.absSync = None # Sync manager (created when absClient initialized)
|
||||||
self.serverBook = None # Server book metadata for streaming
|
self.serverBook = None # Server book metadata for streaming
|
||||||
self.isStreaming = False # Track if currently streaming
|
self.isStreaming = False # Track if currently streaming
|
||||||
self.sessionId = None # Active listening session ID
|
self.sessionId = None # Active listening session ID (legacy, now managed by absSync)
|
||||||
|
|
||||||
# Initialize reading engine based on config
|
# Initialize reading engine based on config
|
||||||
readerEngine = self.config.get_reader_engine()
|
readerEngine = self.config.get_reader_engine()
|
||||||
@@ -452,18 +454,17 @@ class BookReader:
|
|||||||
if not self.absClient or not self.absClient.is_authenticated():
|
if not self.absClient or not self.absClient.is_authenticated():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if this is a streaming book or server-linked book
|
# For streaming books, use the sync manager
|
||||||
serverId = None
|
if self.isStreaming and self.absSync and self.absSync.is_active():
|
||||||
|
self.absSync.sync_progress(audioPosition)
|
||||||
|
return
|
||||||
|
|
||||||
if self.serverBook:
|
# For downloaded/linked local books, use direct API calls
|
||||||
# Streaming book
|
serverLink = self.serverLinkManager.get_link(str(self.bookPath))
|
||||||
serverId = self.serverBook.get('id')
|
if not serverLink:
|
||||||
else:
|
return
|
||||||
# Check if local book is linked to server
|
|
||||||
serverLink = self.serverLinkManager.get_link(str(self.bookPath))
|
|
||||||
if serverLink:
|
|
||||||
serverId = serverLink.get('server_id')
|
|
||||||
|
|
||||||
|
serverId = serverLink.get('server_id')
|
||||||
if not serverId:
|
if not serverId:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -483,23 +484,6 @@ class BookReader:
|
|||||||
if success:
|
if success:
|
||||||
print(f"Progress synced to server: {progress * 100:.1f}%")
|
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):
|
def reload_tts_engine(self):
|
||||||
"""Reload TTS engine with current config settings"""
|
"""Reload TTS engine with current config settings"""
|
||||||
readerEngine = self.config.get_reader_engine()
|
readerEngine = self.config.get_reader_engine()
|
||||||
@@ -902,7 +886,12 @@ class BookReader:
|
|||||||
self.audioPlayer.stop_audio_file()
|
self.audioPlayer.stop_audio_file()
|
||||||
|
|
||||||
# Close Audiobookshelf session if active
|
# 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:
|
try:
|
||||||
self.absClient.close_session(self.sessionId)
|
self.absClient.close_session(self.sessionId)
|
||||||
except:
|
except:
|
||||||
@@ -1694,6 +1683,8 @@ class BookReader:
|
|||||||
if not self.absClient:
|
if not self.absClient:
|
||||||
serverUrl = self.config.get_abs_server_url()
|
serverUrl = self.config.get_abs_server_url()
|
||||||
self.absClient = AudiobookshelfClient(serverUrl, self.config)
|
self.absClient = AudiobookshelfClient(serverUrl, self.config)
|
||||||
|
# Initialize sync manager with the client
|
||||||
|
self.absSync = AudiobookshelfSync(self.absClient, self.serverLinkManager)
|
||||||
|
|
||||||
# Check if already authenticated
|
# Check if already authenticated
|
||||||
if not self.absClient.is_authenticated():
|
if not self.absClient.is_authenticated():
|
||||||
@@ -1727,6 +1718,12 @@ class BookReader:
|
|||||||
|
|
||||||
def _handle_audiobookshelf_key(self, event):
|
def _handle_audiobookshelf_key(self, event):
|
||||||
"""Handle key events when in Audiobookshelf menu"""
|
"""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:
|
if event.key == pygame.K_UP:
|
||||||
self.absMenu.navigate_menu('up')
|
self.absMenu.navigate_menu('up')
|
||||||
elif event.key == pygame.K_DOWN:
|
elif event.key == pygame.K_DOWN:
|
||||||
@@ -1886,20 +1883,26 @@ class BookReader:
|
|||||||
author = metadata.get('authorName', '')
|
author = metadata.get('authorName', '')
|
||||||
duration = media.get('duration', 0.0)
|
duration = media.get('duration', 0.0)
|
||||||
|
|
||||||
|
# Get streaming URL and start session via sync manager
|
||||||
# Get streaming URL (pass full book details to avoid re-fetching)
|
# This single call creates the session and returns stream info with saved position
|
||||||
self.speechEngine.speak(f"Loading stream for {title}. Please wait.")
|
self.speechEngine.speak(f"Loading stream for {title}. Please wait.")
|
||||||
print(f"\nGetting stream URL for: {title}")
|
print(f"\nGetting stream URL for: {title}")
|
||||||
|
|
||||||
streamUrl = self.absClient.get_stream_url(serverId, itemDetails=serverBook)
|
sessionData = self.absSync.start_streaming_session(serverBook)
|
||||||
if not streamUrl:
|
if not sessionData:
|
||||||
self.speechEngine.speak("Failed to get stream URL. Check terminal for errors.")
|
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())}")
|
print(f"Book structure keys: {list(serverBook.keys())}")
|
||||||
if 'media' in serverBook:
|
if 'media' in serverBook:
|
||||||
print(f"Media keys: {list(serverBook['media'].keys())}")
|
print(f"Media keys: {list(serverBook['media'].keys())}")
|
||||||
return
|
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)
|
# Get chapters from server (from the book details we already have)
|
||||||
serverChapters = media.get('chapters', [])
|
serverChapters = media.get('chapters', [])
|
||||||
|
|
||||||
@@ -1912,12 +1915,12 @@ class BookReader:
|
|||||||
if serverChapters:
|
if serverChapters:
|
||||||
for chapterData in serverChapters:
|
for chapterData in serverChapters:
|
||||||
chapterTitle = chapterData.get('title', 'Untitled')
|
chapterTitle = chapterData.get('title', 'Untitled')
|
||||||
startTime = chapterData.get('start', 0.0)
|
chapterStart = chapterData.get('start', 0.0)
|
||||||
chapterDuration = chapterData.get('end', 0.0) - startTime
|
chapterDuration = chapterData.get('end', 0.0) - chapterStart
|
||||||
|
|
||||||
chapter = AudioChapter(
|
chapter = AudioChapter(
|
||||||
title=chapterTitle,
|
title=chapterTitle,
|
||||||
startTime=startTime,
|
startTime=chapterStart,
|
||||||
duration=chapterDuration
|
duration=chapterDuration
|
||||||
)
|
)
|
||||||
book.add_chapter(chapter)
|
book.add_chapter(chapter)
|
||||||
@@ -1938,18 +1941,13 @@ class BookReader:
|
|||||||
self.book = book
|
self.book = book
|
||||||
self.bookPath = streamUrl # Store URL as path for tracking
|
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
|
# Save server book reference for resume on restart
|
||||||
# Use special format: abs://{server_id} so we can detect and resume
|
# Use special format: abs://{server_id} so we can detect and resume
|
||||||
self.config.set_last_book(f"abs://{serverId}")
|
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
|
# Save session and server metadata to server link manager
|
||||||
# This allows resuming the stream with the same session
|
# This allows resuming the stream with the same session
|
||||||
self.serverLinkManager.create_link(
|
self.serverLinkManager.create_link(
|
||||||
@@ -1965,21 +1963,15 @@ class BookReader:
|
|||||||
serverBook=serverBook
|
serverBook=serverBook
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to load progress from server
|
# Use position from server (already retrieved during session start)
|
||||||
serverProgress = self.absClient.get_progress(serverId)
|
if startPosition > 0:
|
||||||
if serverProgress:
|
self.savedAudioPosition = startPosition
|
||||||
progressTime = serverProgress.get('currentTime', 0.0)
|
|
||||||
minutes = int(progressTime // 60)
|
|
||||||
seconds = int(progressTime % 60)
|
|
||||||
|
|
||||||
# Save the exact position for playback resume
|
|
||||||
self.savedAudioPosition = progressTime
|
|
||||||
|
|
||||||
# Find chapter that contains this time
|
# Find chapter that contains this time
|
||||||
for i, chap in enumerate(book.chapters):
|
for i, chap in enumerate(book.chapters):
|
||||||
if hasattr(chap, 'startTime'):
|
if hasattr(chap, 'startTime'):
|
||||||
chapterEnd = chap.startTime + chap.duration
|
chapterEnd = chap.startTime + chap.duration
|
||||||
if chap.startTime <= progressTime < chapterEnd:
|
if chap.startTime <= startPosition < chapterEnd:
|
||||||
self.currentChapter = i
|
self.currentChapter = i
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -1991,14 +1983,21 @@ class BookReader:
|
|||||||
self.currentParagraph = 0
|
self.currentParagraph = 0
|
||||||
|
|
||||||
# Load stream URL directly - mpv will handle streaming natively
|
# 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.")
|
self.speechEngine.speak("Loading stream. This may take a moment.")
|
||||||
|
|
||||||
# Load the stream URL - mpv handles streaming with auth headers
|
# Load the stream URL - mpv handles streaming with auth headers
|
||||||
# Pass auth token for authentication
|
# Pass auth token for authentication and start position for resuming
|
||||||
# Use saved playback speed from config
|
|
||||||
playbackSpeed = self.config.get_playback_speed()
|
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.")
|
self.speechEngine.speak("Failed to load stream. Check terminal for errors.")
|
||||||
print("\nERROR: Failed to load stream from server")
|
print("\nERROR: Failed to load stream from server")
|
||||||
print("Make sure mpv is installed: sudo pacman -S mpv")
|
print("Make sure mpv is installed: sudo pacman -S mpv")
|
||||||
@@ -2021,13 +2020,8 @@ class BookReader:
|
|||||||
if self.config.get_show_text() and hasattr(self, 'screen') and self.screen:
|
if self.config.get_show_text() and hasattr(self, 'screen') and self.screen:
|
||||||
self._render_screen()
|
self._render_screen()
|
||||||
|
|
||||||
# Start playback from saved position (if any)
|
# Start playback (position already set during load)
|
||||||
startPos = self.savedAudioPosition if self.savedAudioPosition > 0 else 0.0
|
self.audioPlayer.play_audio_file()
|
||||||
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)
|
|
||||||
self.isPlaying = True
|
self.isPlaying = True
|
||||||
self.isAudioBook = True
|
self.isAudioBook = True
|
||||||
|
|
||||||
@@ -2566,11 +2560,14 @@ class BookReader:
|
|||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Cleanup resources"""
|
"""Cleanup resources"""
|
||||||
# Close active listening session if any
|
# Close active listening session via sync manager
|
||||||
if self.sessionId and self.absClient:
|
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}")
|
print(f"Closing listening session: {self.sessionId}")
|
||||||
self.absClient.close_session(self.sessionId)
|
self.absClient.close_session(self.sessionId)
|
||||||
# Clear session from link metadata
|
|
||||||
if self.bookPath:
|
if self.bookPath:
|
||||||
self.serverLinkManager.clear_session(str(self.bookPath))
|
self.serverLinkManager.clear_session(str(self.bookPath))
|
||||||
self.sessionId = None
|
self.sessionId = None
|
||||||
@@ -2687,6 +2684,7 @@ def main():
|
|||||||
|
|
||||||
reader = BookReader(None, config)
|
reader = BookReader(None, config)
|
||||||
reader.absClient = absClient
|
reader.absClient = absClient
|
||||||
|
reader.absSync = AudiobookshelfSync(absClient, reader.serverLinkManager)
|
||||||
|
|
||||||
# Restore session ID if it was saved
|
# Restore session ID if it was saved
|
||||||
savedSessionId = cachedLink.get('session_id')
|
savedSessionId = cachedLink.get('session_id')
|
||||||
@@ -2738,6 +2736,7 @@ def main():
|
|||||||
|
|
||||||
reader = BookReader(None, config)
|
reader = BookReader(None, config)
|
||||||
reader.absClient = absClient
|
reader.absClient = absClient
|
||||||
|
reader.absSync = AudiobookshelfSync(absClient, reader.serverLinkManager)
|
||||||
reader._stream_audiobook(bookDetails)
|
reader._stream_audiobook(bookDetails)
|
||||||
reader.run_interactive()
|
reader.run_interactive()
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -147,13 +147,13 @@ class AudiobookshelfClient:
|
|||||||
print(f"Get libraries error: {e}")
|
print(f"Get libraries error: {e}")
|
||||||
return None
|
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
|
Get audiobooks in a library
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
libraryId: Library ID
|
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)
|
page: Page number for pagination (default 0)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -166,9 +166,12 @@ class AudiobookshelfClient:
|
|||||||
try:
|
try:
|
||||||
url = f"{self.serverUrl}/api/libraries/{libraryId}/items"
|
url = f"{self.serverUrl}/api/libraries/{libraryId}/items"
|
||||||
headers = {'Authorization': f'Bearer {self.authToken}'}
|
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:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -327,16 +330,19 @@ class AudiobookshelfClient:
|
|||||||
print(f"File write error: {e}")
|
print(f"File write error: {e}")
|
||||||
return False
|
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:
|
Args:
|
||||||
itemId: Library item ID
|
itemId: Library item ID
|
||||||
itemDetails: Optional pre-fetched item details (not required)
|
itemDetails: Optional pre-fetched item details (not required)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Streaming URL with auth token, or None if error
|
Dict with {streamUrl, sessionId, currentTime} or None if error
|
||||||
"""
|
"""
|
||||||
if not self.authToken:
|
if not self.authToken:
|
||||||
print("ERROR: Not authenticated")
|
print("ERROR: Not authenticated")
|
||||||
@@ -372,6 +378,13 @@ class AudiobookshelfClient:
|
|||||||
|
|
||||||
playData = response.json()
|
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
|
# Extract the actual stream URL from the play response
|
||||||
# The response contains either 'audioTracks' or direct 'url'
|
# The response contains either 'audioTracks' or direct 'url'
|
||||||
streamUrl = None
|
streamUrl = None
|
||||||
@@ -397,7 +410,11 @@ class AudiobookshelfClient:
|
|||||||
if streamUrl.startswith('/'):
|
if streamUrl.startswith('/'):
|
||||||
streamUrl = f"{self.serverUrl}{streamUrl}"
|
streamUrl = f"{self.serverUrl}{streamUrl}"
|
||||||
|
|
||||||
return streamUrl
|
return {
|
||||||
|
'streamUrl': streamUrl,
|
||||||
|
'sessionId': sessionId,
|
||||||
|
'currentTime': currentTime
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Get stream URL error: {e}")
|
print(f"Get stream URL error: {e}")
|
||||||
|
|||||||
@@ -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
|
||||||
+8
-1
@@ -368,7 +368,7 @@ class MpvPlayer:
|
|||||||
|
|
||||||
# Audio file playback methods (for audiobooks)
|
# 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
|
Load an audio file for streaming playback
|
||||||
|
|
||||||
@@ -376,6 +376,7 @@ class MpvPlayer:
|
|||||||
audioPath: Path to audio file or URL
|
audioPath: Path to audio file or URL
|
||||||
authToken: Optional Bearer token for authenticated URLs
|
authToken: Optional Bearer token for authenticated URLs
|
||||||
playbackSpeed: Playback speed (0.5 to 2.0, default 1.0)
|
playbackSpeed: Playback speed (0.5 to 2.0, default 1.0)
|
||||||
|
startPosition: Start time in seconds (for resuming playback)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if loaded successfully
|
True if loaded successfully
|
||||||
@@ -404,6 +405,12 @@ class MpvPlayer:
|
|||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
self.player.http_header_fields = []
|
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)
|
# Load the file (mpv handles all formats natively)
|
||||||
self.player.loadfile(audioPath, 'replace')
|
self.player.loadfile(audioPath, 'replace')
|
||||||
self.player.pause = True # Keep paused until play_audio_file() is called
|
self.player.pause = True # Keep paused until play_audio_file() is called
|
||||||
|
|||||||
Reference in New Issue
Block a user