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.txt_parser import TxtParser
|
||||
from src.audio_parser import AudioParser
|
||||
from src.folder_audiobook_parser import FolderAudiobookParser
|
||||
from src.bookmark_manager import BookmarkManager
|
||||
from src.tts_engine import TtsEngine
|
||||
from src.config_manager import ConfigManager
|
||||
@@ -158,31 +159,41 @@ class BookReader:
|
||||
|
||||
def load_book(self):
|
||||
"""Load and parse the book"""
|
||||
message = f"Loading book {self.bookPath.stem}"
|
||||
print(message)
|
||||
self.speechEngine.speak(message)
|
||||
# Check if bookPath is a directory (folder audiobook)
|
||||
if self.bookPath.is_dir():
|
||||
message = f"Loading audiobook folder {self.bookPath.name}"
|
||||
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()
|
||||
# Use folder audiobook parser
|
||||
self.parser = FolderAudiobookParser()
|
||||
self.book = self.parser.parse(self.bookPath)
|
||||
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"Chapters: {self.book.get_total_chapters()}")
|
||||
@@ -191,8 +202,16 @@ class BookReader:
|
||||
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
|
||||
# Get saved playback speed from config
|
||||
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
|
||||
if self.book.get_total_chapters() == 1:
|
||||
@@ -345,6 +364,11 @@ class BookReader:
|
||||
# For audio books, calculate current playback position
|
||||
audioPosition = 0.0
|
||||
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
|
||||
chapter = self.book.get_chapter(self.currentChapter)
|
||||
if chapter and hasattr(chapter, 'startTime'):
|
||||
@@ -1099,7 +1123,7 @@ class BookReader:
|
||||
positionInChapter = audioPosition - chapter.startTime
|
||||
# Seek to position
|
||||
if self.audioPlayer.is_audio_file_loaded():
|
||||
self.audioPlayer.seek_audio_position(audioPosition)
|
||||
self.audioPlayer.seek_audio(audioPosition)
|
||||
break
|
||||
|
||||
# Speak feedback
|
||||
@@ -1849,10 +1873,15 @@ class BookReader:
|
||||
self.displayText = f"Playing: {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
|
||||
# If we have a saved audio position and we're on the saved chapter, use it
|
||||
if self.savedAudioPosition > 0.0:
|
||||
startTime = self.savedAudioPosition
|
||||
# Save the position before clearing it (we'll need it later for multi-file)
|
||||
resumePosition = self.savedAudioPosition
|
||||
if resumePosition > 0.0:
|
||||
startTime = resumePosition
|
||||
# Clear saved position so we don't use it again (only for initial resume)
|
||||
self.savedAudioPosition = 0.0
|
||||
minutes = int(startTime // 60)
|
||||
@@ -1867,7 +1896,31 @@ class BookReader:
|
||||
|
||||
# Seek to position and play
|
||||
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:
|
||||
print("ERROR: Audio file not loaded!")
|
||||
self.isPlaying = False
|
||||
|
||||
+74
-15
@@ -117,6 +117,9 @@ class BookSelector:
|
||||
"""
|
||||
items = []
|
||||
|
||||
# Supported audio formats for folder audiobooks
|
||||
audioFormats = {'.mp3', '.m4a', '.m4b', '.opus', '.ogg', '.flac', '.wav', '.aac', '.wma'}
|
||||
|
||||
try:
|
||||
# Add directories (excluding hidden)
|
||||
for item in sorted(self.currentDir.iterdir()):
|
||||
@@ -124,10 +127,14 @@ class BookSelector:
|
||||
continue
|
||||
|
||||
if item.is_dir():
|
||||
# Check if directory contains audio files (audiobook folder)
|
||||
audioFileCount = self._count_audio_files(item, audioFormats)
|
||||
items.append({
|
||||
'name': item.name,
|
||||
'path': item,
|
||||
'isDir': True
|
||||
'isDir': True,
|
||||
'isAudiobookFolder': audioFileCount >= 2, # At least 2 files for multi-file audiobook
|
||||
'audioFileCount': audioFileCount
|
||||
})
|
||||
|
||||
# Add supported book files
|
||||
@@ -144,7 +151,9 @@ class BookSelector:
|
||||
items.append({
|
||||
'name': item.name,
|
||||
'path': item,
|
||||
'isDir': False
|
||||
'isDir': False,
|
||||
'isAudiobookFolder': False,
|
||||
'audioFileCount': 0
|
||||
})
|
||||
|
||||
except PermissionError:
|
||||
@@ -188,6 +197,45 @@ class BookSelector:
|
||||
# If we can't read it, don't show it
|
||||
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):
|
||||
"""
|
||||
Reset browser to a specific directory
|
||||
@@ -246,7 +294,11 @@ class BookSelector:
|
||||
|
||||
item = self.items[self.currentSelection]
|
||||
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:
|
||||
text = f"{item['name']}, book"
|
||||
|
||||
@@ -267,21 +319,28 @@ class BookSelector:
|
||||
item = self.items[self.currentSelection]
|
||||
|
||||
if item['isDir']:
|
||||
# Navigate into directory
|
||||
self.currentDir = item['path']
|
||||
self.currentSelection = 0
|
||||
self.items = self._list_items()
|
||||
# Check if it's an audiobook folder
|
||||
if item.get('isAudiobookFolder', False):
|
||||
# Treat audiobook folder as a book (return path to folder)
|
||||
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:
|
||||
dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir)
|
||||
self.speechEngine.speak(f"Entered directory: {dirName}")
|
||||
if self.speechEngine:
|
||||
dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir)
|
||||
self.speechEngine.speak(f"Entered directory: {dirName}")
|
||||
|
||||
if self.items:
|
||||
self._speak_current_item()
|
||||
elif self.speechEngine:
|
||||
self.speechEngine.speak("Empty directory")
|
||||
if self.items:
|
||||
self._speak_current_item()
|
||||
elif self.speechEngine:
|
||||
self.speechEngine.speak("Empty directory")
|
||||
|
||||
return None
|
||||
return None
|
||||
else:
|
||||
# Book selected
|
||||
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.playbackSpeed = 1.0 # Current playback speed
|
||||
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:
|
||||
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.speed = self.playbackSpeed
|
||||
self.audioFileLoaded = True
|
||||
self.playlist = [] # Clear playlist (single file mode)
|
||||
self.currentPlaylistIndex = 0
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -183,6 +187,59 @@ class MpvPlayer:
|
||||
self.audioFileLoaded = 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):
|
||||
"""
|
||||
Play loaded audio file from a specific position
|
||||
@@ -291,4 +348,55 @@ class MpvPlayer:
|
||||
Args:
|
||||
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