Fixed bookmarking with audio books.
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user