Folder based audiobooks added. Still a little fixing of some malfunctioning navigation keys needed.
This commit is contained in:
+81
-28
@@ -35,6 +35,7 @@ from src.epub_parser import EpubParser
|
|||||||
from src.pdf_parser import PdfParser
|
from src.pdf_parser import PdfParser
|
||||||
from src.txt_parser import TxtParser
|
from src.txt_parser import TxtParser
|
||||||
from src.audio_parser import AudioParser
|
from src.audio_parser import AudioParser
|
||||||
|
from src.folder_audiobook_parser import FolderAudiobookParser
|
||||||
from src.bookmark_manager import BookmarkManager
|
from src.bookmark_manager import BookmarkManager
|
||||||
from src.tts_engine import TtsEngine
|
from src.tts_engine import TtsEngine
|
||||||
from src.config_manager import ConfigManager
|
from src.config_manager import ConfigManager
|
||||||
@@ -158,31 +159,41 @@ class BookReader:
|
|||||||
|
|
||||||
def load_book(self):
|
def load_book(self):
|
||||||
"""Load and parse the book"""
|
"""Load and parse the book"""
|
||||||
message = f"Loading book {self.bookPath.stem}"
|
# Check if bookPath is a directory (folder audiobook)
|
||||||
print(message)
|
if self.bookPath.is_dir():
|
||||||
self.speechEngine.speak(message)
|
message = f"Loading audiobook folder {self.bookPath.name}"
|
||||||
|
print(message)
|
||||||
|
self.speechEngine.speak(message)
|
||||||
|
|
||||||
# Detect format and create appropriate parser
|
# Use folder audiobook parser
|
||||||
suffix = self.bookPath.suffix.lower()
|
self.parser = FolderAudiobookParser()
|
||||||
if suffix in ['.epub']:
|
|
||||||
self.parser = EpubParser()
|
|
||||||
self.book = self.parser.parse(self.bookPath)
|
|
||||||
elif suffix in ['.zip']:
|
|
||||||
# Assume DAISY format for zip files
|
|
||||||
self.parser = DaisyParser()
|
|
||||||
self.book = self.parser.parse(self.bookPath)
|
|
||||||
elif suffix in ['.pdf']:
|
|
||||||
self.parser = PdfParser()
|
|
||||||
self.book = self.parser.parse(self.bookPath)
|
|
||||||
elif suffix in ['.txt']:
|
|
||||||
self.parser = TxtParser()
|
|
||||||
self.book = self.parser.parse(self.bookPath)
|
|
||||||
elif suffix in ['.m4b', '.m4a', '.mp3']:
|
|
||||||
# Audio book file
|
|
||||||
self.parser = AudioParser()
|
|
||||||
self.book = self.parser.parse(self.bookPath)
|
self.book = self.parser.parse(self.bookPath)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported book format: {self.bookPath.suffix}")
|
message = f"Loading book {self.bookPath.stem}"
|
||||||
|
print(message)
|
||||||
|
self.speechEngine.speak(message)
|
||||||
|
|
||||||
|
# Detect format and create appropriate parser
|
||||||
|
suffix = self.bookPath.suffix.lower()
|
||||||
|
if suffix in ['.epub']:
|
||||||
|
self.parser = EpubParser()
|
||||||
|
self.book = self.parser.parse(self.bookPath)
|
||||||
|
elif suffix in ['.zip']:
|
||||||
|
# Assume DAISY format for zip files
|
||||||
|
self.parser = DaisyParser()
|
||||||
|
self.book = self.parser.parse(self.bookPath)
|
||||||
|
elif suffix in ['.pdf']:
|
||||||
|
self.parser = PdfParser()
|
||||||
|
self.book = self.parser.parse(self.bookPath)
|
||||||
|
elif suffix in ['.txt']:
|
||||||
|
self.parser = TxtParser()
|
||||||
|
self.book = self.parser.parse(self.bookPath)
|
||||||
|
elif suffix in ['.m4b', '.m4a', '.mp3']:
|
||||||
|
# Audio book file
|
||||||
|
self.parser = AudioParser()
|
||||||
|
self.book = self.parser.parse(self.bookPath)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported book format: {self.bookPath.suffix}")
|
||||||
|
|
||||||
print(f"Loaded: {self.book.title}")
|
print(f"Loaded: {self.book.title}")
|
||||||
print(f"Chapters: {self.book.get_total_chapters()}")
|
print(f"Chapters: {self.book.get_total_chapters()}")
|
||||||
@@ -191,8 +202,16 @@ class BookReader:
|
|||||||
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
|
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
|
||||||
# Get saved playback speed from config
|
# Get saved playback speed from config
|
||||||
playbackSpeed = self.config.get_playback_speed()
|
playbackSpeed = self.config.get_playback_speed()
|
||||||
if not self.audioPlayer.load_audio_file(self.book.audioPath, playbackSpeed=playbackSpeed):
|
|
||||||
raise Exception("Failed to load audio file")
|
# Check if multi-file audiobook (folder)
|
||||||
|
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
|
||||||
|
# Multi-file audiobook - load playlist into player
|
||||||
|
if not self.audioPlayer.load_audio_playlist(self.book.audioFiles, playbackSpeed=playbackSpeed):
|
||||||
|
raise Exception("Failed to load audio playlist")
|
||||||
|
else:
|
||||||
|
# Single-file audiobook
|
||||||
|
if not self.audioPlayer.load_audio_file(self.book.audioPath, playbackSpeed=playbackSpeed):
|
||||||
|
raise Exception("Failed to load audio file")
|
||||||
|
|
||||||
# Inform user about navigation capabilities
|
# Inform user about navigation capabilities
|
||||||
if self.book.get_total_chapters() == 1:
|
if self.book.get_total_chapters() == 1:
|
||||||
@@ -345,6 +364,11 @@ class BookReader:
|
|||||||
# For audio books, calculate current playback position
|
# For audio books, calculate current playback position
|
||||||
audioPosition = 0.0
|
audioPosition = 0.0
|
||||||
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
|
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
|
||||||
|
# For multi-file audiobooks, sync currentChapter with playlist position
|
||||||
|
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
|
||||||
|
playlistIndex = self.audioPlayer.get_current_playlist_index()
|
||||||
|
self.currentChapter = playlistIndex
|
||||||
|
|
||||||
# Get current chapter start time
|
# Get current chapter start time
|
||||||
chapter = self.book.get_chapter(self.currentChapter)
|
chapter = self.book.get_chapter(self.currentChapter)
|
||||||
if chapter and hasattr(chapter, 'startTime'):
|
if chapter and hasattr(chapter, 'startTime'):
|
||||||
@@ -1099,7 +1123,7 @@ class BookReader:
|
|||||||
positionInChapter = audioPosition - chapter.startTime
|
positionInChapter = audioPosition - chapter.startTime
|
||||||
# Seek to position
|
# Seek to position
|
||||||
if self.audioPlayer.is_audio_file_loaded():
|
if self.audioPlayer.is_audio_file_loaded():
|
||||||
self.audioPlayer.seek_audio_position(audioPosition)
|
self.audioPlayer.seek_audio(audioPosition)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Speak feedback
|
# Speak feedback
|
||||||
@@ -1849,10 +1873,15 @@ class BookReader:
|
|||||||
self.displayText = f"Playing: {chapter.title}"
|
self.displayText = f"Playing: {chapter.title}"
|
||||||
self.statusText = f"Ch {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title}"
|
self.statusText = f"Ch {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title}"
|
||||||
|
|
||||||
|
# Check if multi-file audiobook (each chapter is a separate file)
|
||||||
|
isMultiFile = hasattr(self.book, 'isMultiFile') and self.book.isMultiFile
|
||||||
|
|
||||||
# Determine start position
|
# Determine start position
|
||||||
# If we have a saved audio position and we're on the saved chapter, use it
|
# If we have a saved audio position and we're on the saved chapter, use it
|
||||||
if self.savedAudioPosition > 0.0:
|
# Save the position before clearing it (we'll need it later for multi-file)
|
||||||
startTime = self.savedAudioPosition
|
resumePosition = self.savedAudioPosition
|
||||||
|
if resumePosition > 0.0:
|
||||||
|
startTime = resumePosition
|
||||||
# Clear saved position so we don't use it again (only for initial resume)
|
# Clear saved position so we don't use it again (only for initial resume)
|
||||||
self.savedAudioPosition = 0.0
|
self.savedAudioPosition = 0.0
|
||||||
minutes = int(startTime // 60)
|
minutes = int(startTime // 60)
|
||||||
@@ -1867,7 +1896,31 @@ class BookReader:
|
|||||||
|
|
||||||
# Seek to position and play
|
# Seek to position and play
|
||||||
if self.audioPlayer.audioFileLoaded:
|
if self.audioPlayer.audioFileLoaded:
|
||||||
self.audioPlayer.play_audio_file(startPosition=startTime)
|
if isMultiFile:
|
||||||
|
# For multi-file audiobooks, seek to the correct file in playlist
|
||||||
|
# Chapter index = file index (each file is a chapter)
|
||||||
|
if self.audioPlayer.seek_to_playlist_index(self.currentChapter):
|
||||||
|
# Calculate position within current file
|
||||||
|
# If resuming (saved position > 0), use position relative to chapter start
|
||||||
|
positionInFile = 0.0
|
||||||
|
if resumePosition > 0.0:
|
||||||
|
# Get current chapter's start time to calculate position within file
|
||||||
|
chapter = self.book.get_chapter(self.currentChapter)
|
||||||
|
if chapter and hasattr(chapter, 'startTime'):
|
||||||
|
positionInFile = resumePosition - chapter.startTime
|
||||||
|
# Ensure position is within file bounds
|
||||||
|
if positionInFile < 0:
|
||||||
|
positionInFile = 0.0
|
||||||
|
elif hasattr(chapter, 'duration') and positionInFile > chapter.duration:
|
||||||
|
positionInFile = chapter.duration
|
||||||
|
# Start playback at calculated position
|
||||||
|
self.audioPlayer.play_audio_file(startPosition=positionInFile)
|
||||||
|
else:
|
||||||
|
print(f"ERROR: Could not seek to playlist index {self.currentChapter}")
|
||||||
|
self.isPlaying = False
|
||||||
|
else:
|
||||||
|
# Single-file audiobook with chapter markers
|
||||||
|
self.audioPlayer.play_audio_file(startPosition=startTime)
|
||||||
else:
|
else:
|
||||||
print("ERROR: Audio file not loaded!")
|
print("ERROR: Audio file not loaded!")
|
||||||
self.isPlaying = False
|
self.isPlaying = False
|
||||||
|
|||||||
+74
-15
@@ -117,6 +117,9 @@ class BookSelector:
|
|||||||
"""
|
"""
|
||||||
items = []
|
items = []
|
||||||
|
|
||||||
|
# Supported audio formats for folder audiobooks
|
||||||
|
audioFormats = {'.mp3', '.m4a', '.m4b', '.opus', '.ogg', '.flac', '.wav', '.aac', '.wma'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Add directories (excluding hidden)
|
# Add directories (excluding hidden)
|
||||||
for item in sorted(self.currentDir.iterdir()):
|
for item in sorted(self.currentDir.iterdir()):
|
||||||
@@ -124,10 +127,14 @@ class BookSelector:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if item.is_dir():
|
if item.is_dir():
|
||||||
|
# Check if directory contains audio files (audiobook folder)
|
||||||
|
audioFileCount = self._count_audio_files(item, audioFormats)
|
||||||
items.append({
|
items.append({
|
||||||
'name': item.name,
|
'name': item.name,
|
||||||
'path': item,
|
'path': item,
|
||||||
'isDir': True
|
'isDir': True,
|
||||||
|
'isAudiobookFolder': audioFileCount >= 2, # At least 2 files for multi-file audiobook
|
||||||
|
'audioFileCount': audioFileCount
|
||||||
})
|
})
|
||||||
|
|
||||||
# Add supported book files
|
# Add supported book files
|
||||||
@@ -144,7 +151,9 @@ class BookSelector:
|
|||||||
items.append({
|
items.append({
|
||||||
'name': item.name,
|
'name': item.name,
|
||||||
'path': item,
|
'path': item,
|
||||||
'isDir': False
|
'isDir': False,
|
||||||
|
'isAudiobookFolder': False,
|
||||||
|
'audioFileCount': 0
|
||||||
})
|
})
|
||||||
|
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@@ -188,6 +197,45 @@ class BookSelector:
|
|||||||
# If we can't read it, don't show it
|
# If we can't read it, don't show it
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _count_audio_files(self, folderPath, audioFormats):
|
||||||
|
"""
|
||||||
|
Count audio files in a folder (non-recursive)
|
||||||
|
Also checks if folder has subdirectories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folderPath: Path to folder
|
||||||
|
audioFormats: Set of audio file extensions
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of audio files found (0 if folder contains subdirectories)
|
||||||
|
"""
|
||||||
|
count = 0
|
||||||
|
hasSubdirs = False
|
||||||
|
try:
|
||||||
|
for item in folderPath.iterdir():
|
||||||
|
# Skip hidden items
|
||||||
|
if item.name.startswith('.'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If folder has subdirectories, don't treat it as audiobook folder
|
||||||
|
if item.is_dir():
|
||||||
|
hasSubdirs = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if item.is_file() and item.suffix.lower() in audioFormats:
|
||||||
|
count += 1
|
||||||
|
# Stop counting after finding 2 (enough to determine if it's a folder audiobook)
|
||||||
|
if count >= 2:
|
||||||
|
break
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# If folder has subdirectories, return 0 (treat as regular folder)
|
||||||
|
if hasSubdirs:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
def reset_to_directory(self, directory):
|
def reset_to_directory(self, directory):
|
||||||
"""
|
"""
|
||||||
Reset browser to a specific directory
|
Reset browser to a specific directory
|
||||||
@@ -246,7 +294,11 @@ class BookSelector:
|
|||||||
|
|
||||||
item = self.items[self.currentSelection]
|
item = self.items[self.currentSelection]
|
||||||
if item['isDir']:
|
if item['isDir']:
|
||||||
text = f"{item['name']}, directory"
|
if item.get('isAudiobookFolder', False):
|
||||||
|
audioCount = item.get('audioFileCount', 0)
|
||||||
|
text = f"{item['name']}, audiobook folder, {audioCount} files"
|
||||||
|
else:
|
||||||
|
text = f"{item['name']}, directory"
|
||||||
else:
|
else:
|
||||||
text = f"{item['name']}, book"
|
text = f"{item['name']}, book"
|
||||||
|
|
||||||
@@ -267,21 +319,28 @@ class BookSelector:
|
|||||||
item = self.items[self.currentSelection]
|
item = self.items[self.currentSelection]
|
||||||
|
|
||||||
if item['isDir']:
|
if item['isDir']:
|
||||||
# Navigate into directory
|
# Check if it's an audiobook folder
|
||||||
self.currentDir = item['path']
|
if item.get('isAudiobookFolder', False):
|
||||||
self.currentSelection = 0
|
# Treat audiobook folder as a book (return path to folder)
|
||||||
self.items = self._list_items()
|
if self.speechEngine:
|
||||||
|
self.speechEngine.speak(f"Loading audiobook folder: {item['name']}")
|
||||||
|
return str(item['path'])
|
||||||
|
else:
|
||||||
|
# Navigate into directory
|
||||||
|
self.currentDir = item['path']
|
||||||
|
self.currentSelection = 0
|
||||||
|
self.items = self._list_items()
|
||||||
|
|
||||||
if self.speechEngine:
|
if self.speechEngine:
|
||||||
dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir)
|
dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir)
|
||||||
self.speechEngine.speak(f"Entered directory: {dirName}")
|
self.speechEngine.speak(f"Entered directory: {dirName}")
|
||||||
|
|
||||||
if self.items:
|
if self.items:
|
||||||
self._speak_current_item()
|
self._speak_current_item()
|
||||||
elif self.speechEngine:
|
elif self.speechEngine:
|
||||||
self.speechEngine.speak("Empty directory")
|
self.speechEngine.speak("Empty directory")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
# Book selected
|
# Book selected
|
||||||
if self.speechEngine:
|
if self.speechEngine:
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Folder Audiobook Parser
|
||||||
|
|
||||||
|
Parses a folder of audio files as a single multi-file audiobook.
|
||||||
|
Uses metadata (track numbers, disc numbers) and falls back to natural filename sorting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from src.audio_parser import AudioBook, AudioChapter
|
||||||
|
|
||||||
|
|
||||||
|
class FolderAudiobookParser:
|
||||||
|
"""Parser for folder-based audiobooks (multiple audio files)"""
|
||||||
|
|
||||||
|
# Supported audio formats (what mpv can handle)
|
||||||
|
AUDIO_EXTENSIONS = {'.mp3', '.m4a', '.m4b', '.opus', '.ogg', '.flac', '.wav', '.aac', '.wma'}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize folder audiobook parser"""
|
||||||
|
self.mutagen = None
|
||||||
|
try:
|
||||||
|
import mutagen
|
||||||
|
import mutagen.mp4
|
||||||
|
import mutagen.mp3
|
||||||
|
import mutagen.oggopus
|
||||||
|
import mutagen.oggvorbis
|
||||||
|
import mutagen.flac
|
||||||
|
self.mutagen = mutagen
|
||||||
|
except ImportError:
|
||||||
|
print("Warning: mutagen not installed. Install with: pip install mutagen")
|
||||||
|
|
||||||
|
def parse(self, folderPath):
|
||||||
|
"""
|
||||||
|
Parse folder of audio files as a single audiobook
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folderPath: Path to folder containing audio files
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AudioBook object with multiple chapters (one per file)
|
||||||
|
"""
|
||||||
|
if not self.mutagen:
|
||||||
|
raise ImportError("mutagen library required for folder audiobooks")
|
||||||
|
|
||||||
|
folderPath = Path(folderPath)
|
||||||
|
if not folderPath.is_dir():
|
||||||
|
raise ValueError(f"Not a directory: {folderPath}")
|
||||||
|
|
||||||
|
# Find all audio files in folder
|
||||||
|
audioFiles = self._find_audio_files(folderPath)
|
||||||
|
if not audioFiles:
|
||||||
|
raise ValueError(f"No audio files found in: {folderPath}")
|
||||||
|
|
||||||
|
print(f"Found {len(audioFiles)} audio files in folder")
|
||||||
|
|
||||||
|
# Extract metadata from each file
|
||||||
|
fileMetadata = []
|
||||||
|
for audioFile in audioFiles:
|
||||||
|
metadata = self._extract_file_metadata(audioFile)
|
||||||
|
fileMetadata.append(metadata)
|
||||||
|
|
||||||
|
# Sort files intelligently
|
||||||
|
sortedFiles = self._sort_files(fileMetadata)
|
||||||
|
|
||||||
|
# Create audiobook
|
||||||
|
folderName = folderPath.name
|
||||||
|
book = AudioBook(title=folderName, author="Unknown")
|
||||||
|
|
||||||
|
# Determine book title and author from most common tags
|
||||||
|
bookTitle, bookAuthor = self._determine_book_metadata(sortedFiles)
|
||||||
|
book.title = bookTitle or folderName
|
||||||
|
book.author = bookAuthor
|
||||||
|
|
||||||
|
# Calculate total duration and create chapters
|
||||||
|
totalDuration = 0.0
|
||||||
|
for fileData in sortedFiles:
|
||||||
|
audioPath = fileData['path']
|
||||||
|
chapterTitle = fileData['title']
|
||||||
|
duration = fileData['duration']
|
||||||
|
|
||||||
|
chapter = AudioChapter(
|
||||||
|
title=chapterTitle,
|
||||||
|
startTime=totalDuration,
|
||||||
|
duration=duration
|
||||||
|
)
|
||||||
|
# Store file path in chapter for multi-file playback
|
||||||
|
chapter.audioPath = str(audioPath)
|
||||||
|
book.add_chapter(chapter)
|
||||||
|
|
||||||
|
totalDuration += duration
|
||||||
|
|
||||||
|
book.totalDuration = totalDuration
|
||||||
|
|
||||||
|
# Store all audio file paths in book for playlist playback
|
||||||
|
book.audioFiles = [fileData['path'] for fileData in sortedFiles]
|
||||||
|
book.isMultiFile = True
|
||||||
|
|
||||||
|
print(f"Loaded: {book.title} by {book.author}")
|
||||||
|
print(f"Total duration: {totalDuration / 60:.1f} minutes")
|
||||||
|
print(f"Chapters: {len(book.chapters)}")
|
||||||
|
|
||||||
|
return book
|
||||||
|
|
||||||
|
def _find_audio_files(self, folderPath):
|
||||||
|
"""
|
||||||
|
Find all audio files in folder (non-recursive)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folderPath: Path to folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Path objects for audio files
|
||||||
|
"""
|
||||||
|
audioFiles = []
|
||||||
|
for filePath in folderPath.iterdir():
|
||||||
|
if filePath.is_file() and filePath.suffix.lower() in self.AUDIO_EXTENSIONS:
|
||||||
|
audioFiles.append(filePath)
|
||||||
|
return audioFiles
|
||||||
|
|
||||||
|
def _extract_file_metadata(self, audioPath):
|
||||||
|
"""
|
||||||
|
Extract metadata from audio file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audioPath: Path to audio file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with metadata:
|
||||||
|
- path: File path
|
||||||
|
- filename: Filename without extension
|
||||||
|
- title: Track title (from tags or filename)
|
||||||
|
- album: Album name
|
||||||
|
- artist: Artist name
|
||||||
|
- trackNumber: Track number (int or None)
|
||||||
|
- discNumber: Disc number (int or None)
|
||||||
|
- duration: Duration in seconds
|
||||||
|
"""
|
||||||
|
metadata = {
|
||||||
|
'path': audioPath,
|
||||||
|
'filename': audioPath.stem,
|
||||||
|
'title': audioPath.stem,
|
||||||
|
'album': None,
|
||||||
|
'artist': None,
|
||||||
|
'trackNumber': None,
|
||||||
|
'discNumber': None,
|
||||||
|
'duration': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
audioFile = self.mutagen.File(audioPath)
|
||||||
|
if not audioFile:
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
# Get duration
|
||||||
|
if hasattr(audioFile.info, 'length'):
|
||||||
|
metadata['duration'] = audioFile.info.length
|
||||||
|
|
||||||
|
# Extract tags based on format
|
||||||
|
# MP4/M4A/M4B tags
|
||||||
|
if '\xa9nam' in audioFile: # Title
|
||||||
|
metadata['title'] = str(audioFile['\xa9nam'][0])
|
||||||
|
if '\xa9alb' in audioFile: # Album
|
||||||
|
metadata['album'] = str(audioFile['\xa9alb'][0])
|
||||||
|
if '\xa9ART' in audioFile: # Artist
|
||||||
|
metadata['artist'] = str(audioFile['\xa9ART'][0])
|
||||||
|
if 'trkn' in audioFile: # Track number
|
||||||
|
try:
|
||||||
|
trackData = audioFile['trkn'][0]
|
||||||
|
if isinstance(trackData, tuple):
|
||||||
|
metadata['trackNumber'] = int(trackData[0])
|
||||||
|
else:
|
||||||
|
metadata['trackNumber'] = int(trackData)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
if 'disk' in audioFile: # Disc number
|
||||||
|
try:
|
||||||
|
discData = audioFile['disk'][0]
|
||||||
|
if isinstance(discData, tuple):
|
||||||
|
metadata['discNumber'] = int(discData[0])
|
||||||
|
else:
|
||||||
|
metadata['discNumber'] = int(discData)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# MP3 ID3 tags
|
||||||
|
if 'TIT2' in audioFile: # Title
|
||||||
|
metadata['title'] = str(audioFile['TIT2'].text[0])
|
||||||
|
if 'TALB' in audioFile: # Album
|
||||||
|
metadata['album'] = str(audioFile['TALB'].text[0])
|
||||||
|
if 'TPE1' in audioFile: # Artist
|
||||||
|
metadata['artist'] = str(audioFile['TPE1'].text[0])
|
||||||
|
if 'TRCK' in audioFile: # Track number
|
||||||
|
try:
|
||||||
|
trackStr = str(audioFile['TRCK'].text[0])
|
||||||
|
# Handle "3/12" format
|
||||||
|
if '/' in trackStr:
|
||||||
|
trackStr = trackStr.split('/')[0]
|
||||||
|
metadata['trackNumber'] = int(trackStr)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
if 'TPOS' in audioFile: # Disc number
|
||||||
|
try:
|
||||||
|
discStr = str(audioFile['TPOS'].text[0])
|
||||||
|
# Handle "1/2" format
|
||||||
|
if '/' in discStr:
|
||||||
|
discStr = discStr.split('/')[0]
|
||||||
|
metadata['discNumber'] = int(discStr)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Ogg Vorbis / Opus tags (lowercase)
|
||||||
|
if 'title' in audioFile:
|
||||||
|
metadata['title'] = str(audioFile['title'][0])
|
||||||
|
if 'album' in audioFile:
|
||||||
|
metadata['album'] = str(audioFile['album'][0])
|
||||||
|
if 'artist' in audioFile:
|
||||||
|
metadata['artist'] = str(audioFile['artist'][0])
|
||||||
|
if 'tracknumber' in audioFile:
|
||||||
|
try:
|
||||||
|
trackStr = str(audioFile['tracknumber'][0])
|
||||||
|
if '/' in trackStr:
|
||||||
|
trackStr = trackStr.split('/')[0]
|
||||||
|
metadata['trackNumber'] = int(trackStr)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
if 'discnumber' in audioFile:
|
||||||
|
try:
|
||||||
|
discStr = str(audioFile['discnumber'][0])
|
||||||
|
if '/' in discStr:
|
||||||
|
discStr = discStr.split('/')[0]
|
||||||
|
metadata['discNumber'] = int(discStr)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not read metadata from {audioPath.name}: {e}")
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def _sort_files(self, fileMetadata):
|
||||||
|
"""
|
||||||
|
Sort files intelligently using metadata and filename
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. Disc number (if present) + Track number
|
||||||
|
2. Track number only
|
||||||
|
3. Natural filename sorting
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fileMetadata: List of metadata dictionaries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sorted list of metadata dictionaries
|
||||||
|
"""
|
||||||
|
# Check if any files have disc numbers
|
||||||
|
hasDiscNumbers = any(f['discNumber'] is not None for f in fileMetadata)
|
||||||
|
|
||||||
|
# Check if any files have track numbers
|
||||||
|
hasTrackNumbers = any(f['trackNumber'] is not None for f in fileMetadata)
|
||||||
|
|
||||||
|
if hasDiscNumbers:
|
||||||
|
# Sort by disc number, then track number, then filename
|
||||||
|
sortedFiles = sorted(fileMetadata, key=lambda f: (
|
||||||
|
f['discNumber'] if f['discNumber'] is not None else 999,
|
||||||
|
f['trackNumber'] if f['trackNumber'] is not None else 999,
|
||||||
|
self._natural_sort_key(f['filename'])
|
||||||
|
))
|
||||||
|
elif hasTrackNumbers:
|
||||||
|
# Sort by track number, then filename
|
||||||
|
sortedFiles = sorted(fileMetadata, key=lambda f: (
|
||||||
|
f['trackNumber'] if f['trackNumber'] is not None else 999,
|
||||||
|
self._natural_sort_key(f['filename'])
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Fall back to natural filename sorting
|
||||||
|
sortedFiles = sorted(fileMetadata, key=lambda f: self._natural_sort_key(f['filename']))
|
||||||
|
|
||||||
|
return sortedFiles
|
||||||
|
|
||||||
|
def _natural_sort_key(self, filename):
|
||||||
|
"""
|
||||||
|
Generate natural sort key for filename
|
||||||
|
|
||||||
|
Handles: "Chapter 1", "Chapter 2", "Chapter 10" correctly
|
||||||
|
Also handles: "1 - Intro", "01-First", "Part 1.mp3"
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Filename string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of strings and integers for sorting
|
||||||
|
"""
|
||||||
|
def convert(text):
|
||||||
|
return int(text) if text.isdigit() else text.lower()
|
||||||
|
|
||||||
|
return [convert(c) for c in re.split('([0-9]+)', filename)]
|
||||||
|
|
||||||
|
def _determine_book_metadata(self, sortedFiles):
|
||||||
|
"""
|
||||||
|
Determine book title and author from file tags
|
||||||
|
|
||||||
|
Uses most common album tag as title and artist tag as author
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sortedFiles: Sorted list of file metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (title, author)
|
||||||
|
"""
|
||||||
|
# Count album names
|
||||||
|
albumCounts = {}
|
||||||
|
for fileData in sortedFiles:
|
||||||
|
album = fileData['album']
|
||||||
|
if album:
|
||||||
|
albumCounts[album] = albumCounts.get(album, 0) + 1
|
||||||
|
|
||||||
|
# Count artist names
|
||||||
|
artistCounts = {}
|
||||||
|
for fileData in sortedFiles:
|
||||||
|
artist = fileData['artist']
|
||||||
|
if artist:
|
||||||
|
artistCounts[artist] = artistCounts.get(artist, 0) + 1
|
||||||
|
|
||||||
|
# Most common album becomes book title
|
||||||
|
bookTitle = None
|
||||||
|
if albumCounts:
|
||||||
|
bookTitle = max(albumCounts, key=albumCounts.get)
|
||||||
|
|
||||||
|
# Most common artist becomes author
|
||||||
|
bookAuthor = None
|
||||||
|
if artistCounts:
|
||||||
|
bookAuthor = max(artistCounts, key=artistCounts.get)
|
||||||
|
|
||||||
|
return bookTitle, bookAuthor
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Clean up resources (no temp files for folder audiobooks)"""
|
||||||
|
pass
|
||||||
+109
-1
@@ -29,6 +29,8 @@ class MpvPlayer:
|
|||||||
self.audioFileLoaded = False # Track if audio file is loaded
|
self.audioFileLoaded = False # Track if audio file is loaded
|
||||||
self.playbackSpeed = 1.0 # Current playback speed
|
self.playbackSpeed = 1.0 # Current playback speed
|
||||||
self.endFileCallback = None # Callback for when file finishes
|
self.endFileCallback = None # Callback for when file finishes
|
||||||
|
self.playlist = [] # Current playlist (for multi-file audiobooks)
|
||||||
|
self.currentPlaylistIndex = 0 # Current file index in playlist
|
||||||
|
|
||||||
if not HAS_MPV:
|
if not HAS_MPV:
|
||||||
print("Warning: python-mpv not installed. Audio playback will not work.")
|
print("Warning: python-mpv not installed. Audio playback will not work.")
|
||||||
@@ -176,6 +178,8 @@ class MpvPlayer:
|
|||||||
self.player.pause = True # Keep paused until play_audio_file() is called
|
self.player.pause = True # Keep paused until play_audio_file() is called
|
||||||
self.player.speed = self.playbackSpeed
|
self.player.speed = self.playbackSpeed
|
||||||
self.audioFileLoaded = True
|
self.audioFileLoaded = True
|
||||||
|
self.playlist = [] # Clear playlist (single file mode)
|
||||||
|
self.currentPlaylistIndex = 0
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -183,6 +187,59 @@ class MpvPlayer:
|
|||||||
self.audioFileLoaded = False
|
self.audioFileLoaded = False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def load_audio_playlist(self, audioFiles, authToken=None, playbackSpeed=1.0):
|
||||||
|
"""
|
||||||
|
Load a playlist of audio files (for multi-file audiobooks)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audioFiles: List of audio file paths
|
||||||
|
authToken: Optional Bearer token for authenticated URLs
|
||||||
|
playbackSpeed: Playback speed (0.5 to 2.0, default 1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if loaded successfully
|
||||||
|
"""
|
||||||
|
if not self.isInitialized:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not audioFiles:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.playlist = [str(path) for path in audioFiles]
|
||||||
|
self.currentPlaylistIndex = 0
|
||||||
|
self.playbackSpeed = max(0.5, min(2.0, float(playbackSpeed)))
|
||||||
|
|
||||||
|
# Load first file in playlist
|
||||||
|
firstFile = self.playlist[0]
|
||||||
|
|
||||||
|
# Check if this is a URL
|
||||||
|
isUrl = firstFile.startswith('http://') or firstFile.startswith('https://')
|
||||||
|
|
||||||
|
if isUrl and authToken:
|
||||||
|
# pylint: disable=no-member
|
||||||
|
self.player.http_header_fields = [f'Authorization: Bearer {authToken}']
|
||||||
|
else:
|
||||||
|
# pylint: disable=no-member
|
||||||
|
self.player.http_header_fields = []
|
||||||
|
|
||||||
|
# Load first file
|
||||||
|
self.player.loadfile(firstFile, 'replace')
|
||||||
|
|
||||||
|
# Add remaining files to mpv playlist
|
||||||
|
for audioFile in self.playlist[1:]:
|
||||||
|
self.player.loadfile(str(audioFile), 'append')
|
||||||
|
|
||||||
|
self.player.pause = True
|
||||||
|
self.player.speed = self.playbackSpeed
|
||||||
|
self.audioFileLoaded = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading audio playlist: {e}")
|
||||||
|
self.audioFileLoaded = False
|
||||||
|
return False
|
||||||
|
|
||||||
def play_audio_file(self, startPosition=0.0):
|
def play_audio_file(self, startPosition=0.0):
|
||||||
"""
|
"""
|
||||||
Play loaded audio file from a specific position
|
Play loaded audio file from a specific position
|
||||||
@@ -291,4 +348,55 @@ class MpvPlayer:
|
|||||||
Args:
|
Args:
|
||||||
callback: Function to call (no arguments)
|
callback: Function to call (no arguments)
|
||||||
"""
|
"""
|
||||||
self.endFileCallback = callback
|
self.endFileCallback = callback
|
||||||
|
|
||||||
|
def get_current_playlist_index(self):
|
||||||
|
"""
|
||||||
|
Get current playlist index (for multi-file audiobooks)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current file index in playlist (0 if not using playlist)
|
||||||
|
"""
|
||||||
|
if not self.isInitialized or not self.audioFileLoaded:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not self.playlist:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get current playlist position from mpv
|
||||||
|
# pylint: disable=no-member
|
||||||
|
playlistPos = self.player.playlist_pos
|
||||||
|
if playlistPos is not None and 0 <= playlistPos < len(self.playlist):
|
||||||
|
self.currentPlaylistIndex = playlistPos
|
||||||
|
return playlistPos
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return self.currentPlaylistIndex
|
||||||
|
|
||||||
|
def seek_to_playlist_index(self, index):
|
||||||
|
"""
|
||||||
|
Seek to a specific file in the playlist
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: File index in playlist
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if seek successful
|
||||||
|
"""
|
||||||
|
if not self.isInitialized or not self.audioFileLoaded:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.playlist or index < 0 or index >= len(self.playlist):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Set playlist position
|
||||||
|
# pylint: disable=no-member
|
||||||
|
self.player.playlist_pos = index
|
||||||
|
self.currentPlaylistIndex = index
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error seeking to playlist index: {e}")
|
||||||
|
return False
|
||||||
Reference in New Issue
Block a user