Fixed bookmarking with audio books.

This commit is contained in:
Storm Dragon
2025-10-16 16:30:58 -04:00
parent 542764be2a
commit 0bdc5bdf17
9 changed files with 648 additions and 178 deletions
+18 -18
View File
@@ -297,27 +297,27 @@ class AudiobookshelfClient:
print(f"DEBUG: Downloading from: {downloadUrl}")
# Download with streaming to handle large files
response = requests.get(downloadUrl, headers=headers, stream=True, timeout=30)
# Use context manager to ensure response cleanup
with requests.get(downloadUrl, headers=headers, stream=True, timeout=30) as response:
if response.status_code != 200:
print(f"Download failed: {response.status_code}")
return False
if response.status_code != 200:
print(f"Download failed: {response.status_code}")
return False
# Get total file size
totalSize = int(response.headers.get('content-length', 0))
# Get total file size
totalSize = int(response.headers.get('content-length', 0))
# Download to file
downloaded = 0
with open(outputPath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
# Download to file
downloaded = 0
with open(outputPath, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded += len(chunk)
# Progress callback
if progressCallback and totalSize > 0:
percent = int((downloaded / totalSize) * 100)
progressCallback(percent)
# Progress callback
if progressCallback and totalSize > 0:
percent = int((downloaded / totalSize) * 100)
progressCallback(percent)
print(f"Download complete: {outputPath}")
return True
+97 -111
View File
@@ -34,45 +34,44 @@ class BookmarkManager:
def _init_db(self):
"""Initialize database schema"""
conn = sqlite3.connect(self.dbPath)
cursor = conn.cursor()
with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS bookmarks (
book_id TEXT PRIMARY KEY,
book_path TEXT NOT NULL,
book_title TEXT,
chapter_index INTEGER NOT NULL DEFAULT 0,
paragraph_index INTEGER NOT NULL DEFAULT 0,
sentence_index INTEGER NOT NULL DEFAULT 0,
last_accessed TEXT,
created_at TEXT
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS bookmarks (
book_id TEXT PRIMARY KEY,
book_path TEXT NOT NULL,
book_title TEXT,
chapter_index INTEGER NOT NULL DEFAULT 0,
paragraph_index INTEGER NOT NULL DEFAULT 0,
sentence_index INTEGER NOT NULL DEFAULT 0,
last_accessed TEXT,
created_at TEXT
)
''')
# Add audio_position column if it doesn't exist (migration for existing databases)
try:
cursor.execute('ALTER TABLE bookmarks ADD COLUMN audio_position REAL DEFAULT 0.0')
except sqlite3.OperationalError:
# Column already exists
pass
# Add audio_position column if it doesn't exist (migration for existing databases)
try:
cursor.execute('ALTER TABLE bookmarks ADD COLUMN audio_position REAL DEFAULT 0.0')
except sqlite3.OperationalError:
# Column already exists
pass
# Create named_bookmarks table for multiple bookmarks per book
cursor.execute('''
CREATE TABLE IF NOT EXISTS named_bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT NOT NULL,
name TEXT NOT NULL,
chapter_index INTEGER NOT NULL DEFAULT 0,
paragraph_index INTEGER NOT NULL DEFAULT 0,
audio_position REAL DEFAULT 0.0,
created_at TEXT NOT NULL,
UNIQUE(book_id, name)
)
''')
# Create named_bookmarks table for multiple bookmarks per book
cursor.execute('''
CREATE TABLE IF NOT EXISTS named_bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id TEXT NOT NULL,
name TEXT NOT NULL,
chapter_index INTEGER NOT NULL DEFAULT 0,
paragraph_index INTEGER NOT NULL DEFAULT 0,
audio_position REAL DEFAULT 0.0,
created_at TEXT NOT NULL,
UNIQUE(book_id, name)
)
''')
conn.commit()
conn.close()
conn.commit()
def _get_book_id(self, bookPath):
"""Generate unique book ID from file path"""
@@ -94,20 +93,19 @@ class BookmarkManager:
bookId = self._get_book_id(bookPath)
timestamp = datetime.now().isoformat()
conn = sqlite3.connect(self.dbPath)
cursor = conn.cursor()
with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT OR REPLACE INTO bookmarks
(book_id, book_path, book_title, chapter_index, paragraph_index,
sentence_index, audio_position, last_accessed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
COALESCE((SELECT created_at FROM bookmarks WHERE book_id = ?), ?))
''', (bookId, str(bookPath), bookTitle, chapterIndex, paragraphIndex,
sentenceIndex, audioPosition, timestamp, bookId, timestamp))
cursor.execute('''
INSERT OR REPLACE INTO bookmarks
(book_id, book_path, book_title, chapter_index, paragraph_index,
sentence_index, audio_position, last_accessed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
COALESCE((SELECT created_at FROM bookmarks WHERE book_id = ?), ?))
''', (bookId, str(bookPath), bookTitle, chapterIndex, paragraphIndex,
sentenceIndex, audioPosition, timestamp, bookId, timestamp))
conn.commit()
conn.close()
conn.commit()
def get_bookmark(self, bookPath):
"""
@@ -121,18 +119,17 @@ class BookmarkManager:
"""
bookId = self._get_book_id(bookPath)
conn = sqlite3.connect(self.dbPath)
cursor = conn.cursor()
with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT chapter_index, paragraph_index, sentence_index,
book_title, last_accessed, audio_position
FROM bookmarks
WHERE book_id = ?
''', (bookId,))
cursor.execute('''
SELECT chapter_index, paragraph_index, sentence_index,
book_title, last_accessed, audio_position
FROM bookmarks
WHERE book_id = ?
''', (bookId,))
row = cursor.fetchone()
conn.close()
row = cursor.fetchone()
if row:
return {
@@ -155,13 +152,10 @@ class BookmarkManager:
"""
bookId = self._get_book_id(bookPath)
conn = sqlite3.connect(self.dbPath)
cursor = conn.cursor()
cursor.execute('DELETE FROM bookmarks WHERE book_id = ?', (bookId,))
conn.commit()
conn.close()
with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM bookmarks WHERE book_id = ?', (bookId,))
conn.commit()
def list_bookmarks(self):
"""
@@ -170,18 +164,17 @@ class BookmarkManager:
Returns:
List of dictionaries with bookmark data
"""
conn = sqlite3.connect(self.dbPath)
cursor = conn.cursor()
with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT book_path, book_title, chapter_index, paragraph_index,
sentence_index, last_accessed
FROM bookmarks
ORDER BY last_accessed DESC
''')
cursor.execute('''
SELECT book_path, book_title, chapter_index, paragraph_index,
sentence_index, last_accessed
FROM bookmarks
ORDER BY last_accessed DESC
''')
rows = cursor.fetchall()
conn.close()
rows = cursor.fetchall()
bookmarks = []
for row in rows:
@@ -213,23 +206,21 @@ class BookmarkManager:
bookId = self._get_book_id(bookPath)
timestamp = datetime.now().isoformat()
conn = sqlite3.connect(self.dbPath)
cursor = conn.cursor()
try:
cursor.execute('''
INSERT INTO named_bookmarks
(book_id, name, chapter_index, paragraph_index, audio_position, created_at)
VALUES (?, ?, ?, ?, ?, ?)
''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp))
with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor()
conn.commit()
conn.close()
cursor.execute('''
INSERT INTO named_bookmarks
(book_id, name, chapter_index, paragraph_index, audio_position, created_at)
VALUES (?, ?, ?, ?, ?, ?)
''', (bookId, name, chapterIndex, paragraphIndex, audioPosition, timestamp))
conn.commit()
return True
except sqlite3.IntegrityError:
# Bookmark with this name already exists
conn.close()
return False
def get_named_bookmarks(self, bookPath):
@@ -244,18 +235,17 @@ class BookmarkManager:
"""
bookId = self._get_book_id(bookPath)
conn = sqlite3.connect(self.dbPath)
cursor = conn.cursor()
with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT id, name, chapter_index, paragraph_index, audio_position, created_at
FROM named_bookmarks
WHERE book_id = ?
ORDER BY created_at DESC
''', (bookId,))
cursor.execute('''
SELECT id, name, chapter_index, paragraph_index, audio_position, created_at
FROM named_bookmarks
WHERE book_id = ?
ORDER BY created_at DESC
''', (bookId,))
rows = cursor.fetchall()
conn.close()
rows = cursor.fetchall()
bookmarks = []
for row in rows:
@@ -277,13 +267,10 @@ class BookmarkManager:
Args:
bookmarkId: Bookmark ID
"""
conn = sqlite3.connect(self.dbPath)
cursor = conn.cursor()
cursor.execute('DELETE FROM named_bookmarks WHERE id = ?', (bookmarkId,))
conn.commit()
conn.close()
with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM named_bookmarks WHERE id = ?', (bookmarkId,))
conn.commit()
def get_named_bookmark_by_id(self, bookmarkId):
"""
@@ -295,17 +282,16 @@ class BookmarkManager:
Returns:
Bookmark dictionary or None if not found
"""
conn = sqlite3.connect(self.dbPath)
cursor = conn.cursor()
with sqlite3.connect(self.dbPath) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT name, chapter_index, paragraph_index, audio_position
FROM named_bookmarks
WHERE id = ?
''', (bookmarkId,))
cursor.execute('''
SELECT name, chapter_index, paragraph_index, audio_position
FROM named_bookmarks
WHERE id = ?
''', (bookmarkId,))
row = cursor.fetchone()
conn.close()
row = cursor.fetchone()
if row:
return {
+7 -2
View File
@@ -47,13 +47,18 @@ class DaisyParser:
# Detect DAISY version and parse accordingly
if (tempPath / "ncc.html").exists():
return self._parse_daisy2(tempPath)
book = self._parse_daisy2(tempPath)
elif (tempPath / "navigation.ncx").exists() or list(tempPath.glob("*.ncx")):
return self._parse_daisy3(tempPath)
book = self._parse_daisy3(tempPath)
else:
raise ValueError("Unknown DAISY format: no ncc.html or navigation.ncx found")
# Cleanup before returning
self.cleanup()
return book
except Exception as e:
# Ensure cleanup on error
self.cleanup()
raise e
+5 -2
View File
@@ -152,6 +152,7 @@ class FolderAudiobookParser:
try:
audioFile = self.mutagen.File(audioPath)
if not audioFile:
# File format not recognized by mutagen - use filename fallback
return metadata
# Get duration
@@ -235,8 +236,10 @@ class FolderAudiobookParser:
except:
pass
except Exception as e:
print(f"Warning: Could not read metadata from {audioPath.name}: {e}")
except Exception:
# Silently fall back to filename-based metadata
# This is normal for files without tags or unsupported formats
pass
return metadata
+36 -4
View File
@@ -325,11 +325,19 @@ class MpvPlayer:
return False
try:
self.player.seek(position, reference='absolute')
# For very small positions (< 1 second), just skip seeking
# This avoids issues with mpv not being ready yet
if position < 1.0:
position = 0.0
if position > 0:
self.player.seek(position, reference='absolute')
return True
except Exception as e:
print(f"Error seeking audio: {e}")
return False
print(f"Error seeking audio to {position}s: {e}")
# Don't fail completely - just start from beginning
return True
def unload_audio_file(self):
"""Unload the current audio file"""
@@ -386,17 +394,41 @@ class MpvPlayer:
True if seek successful
"""
if not self.isInitialized or not self.audioFileLoaded:
print(f"ERROR: Cannot seek - mpv not initialized or no audio loaded")
return False
if not self.playlist or index < 0 or index >= len(self.playlist):
print(f"ERROR: Invalid playlist index: {index} (playlist has {len(self.playlist) if self.playlist else 0} items)")
return False
try:
# Set playlist position
# pylint: disable=no-member
currentPos = self.player.playlist_pos
# If playlist is idle (pos = -1), we need to unpause first to activate it
if currentPos == -1:
# Unpause to activate the playlist
self.player.pause = False
import time
time.sleep(0.1)
# Now pause again so we can seek
self.player.pause = True
time.sleep(0.1)
# Now set the playlist position
self.player.playlist_pos = index
# Wait for mpv to switch files
import time
time.sleep(0.1)
# Update our internal tracking AFTER mpv has switched
self.currentPlaylistIndex = index
return True
except Exception as e:
print(f"Error seeking to playlist index: {e}")
print(f"ERROR: Exception seeking to playlist index {index}: {e}")
import traceback
traceback.print_exc()
return False
+7 -9
View File
@@ -15,6 +15,8 @@ try:
except ImportError:
HAS_SPEECHD = False
from .text_validator import is_valid_text
class SpeechEngine:
"""Text-to-speech engine for UI accessibility using speech-dispatcher"""
@@ -61,7 +63,7 @@ class SpeechEngine:
text: Text to speak
interrupt: If True, stop current speech first (default: True)
"""
if not self.isAvailable or not text:
if not self.isAvailable or not is_valid_text(text):
return
# Safety: Wait for previous UI speech thread to finish if still running
@@ -104,8 +106,8 @@ class SpeechEngine:
print("ERROR: Speech-dispatcher not available")
return
if not text:
print("ERROR: No text to speak")
if not is_valid_text(text):
print("ERROR: No valid text to speak (empty or no alphanumeric characters)")
return
try:
@@ -293,9 +295,5 @@ class SpeechEngine:
return self.isAvailable
def cleanup(self):
"""Close speech-dispatcher connection"""
if self.isAvailable and self.client:
try:
self.client.close()
except Exception:
pass
"""Cleanup resources - alias for close()"""
self.close()
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Text Validator
Validates text before sending to speech engines or other processors.
Ensures text contains at least one alphanumeric character.
"""
import re
def is_valid_text(text):
"""
Check if text is valid for processing
Args:
text: Text to validate
Returns:
bool: True if text contains at least one alphanumeric character, False otherwise
"""
if not text:
return False
# Check if there's at least one alphanumeric character
return bool(re.search(r'[a-zA-Z0-9]', str(text)))
def sanitize_text(text, defaultText=""):
"""
Sanitize text for processing, returning valid text or default
Args:
text: Text to sanitize
defaultText: Default text to return if invalid (default: "")
Returns:
str: Original text if valid, defaultText if invalid
"""
if is_valid_text(text):
return str(text)
return defaultText
+14 -1
View File
@@ -11,11 +11,24 @@ import subprocess
import wave
import io
import struct
import shutil
from .text_validator import is_valid_text
class TtsEngine:
"""Text-to-speech engine using piper-tts"""
@staticmethod
def is_available():
"""
Check if piper-tts is available on the system
Returns:
bool: True if piper-tts is installed, False otherwise
"""
return shutil.which('piper-tts') is not None
def __init__(self, modelPath="/usr/share/piper-voices/en/en_US/hfc_male/medium/en_US-hfc_male-medium.onnx"):
"""
Initialize TTS engine
@@ -41,7 +54,7 @@ class TtsEngine:
Raises:
RuntimeError: If piper-tts fails
"""
if not text.strip():
if not is_valid_text(text):
return None
# Safety: Limit text size to prevent excessive memory usage