Folder based audiobooks added. Still a little fixing of some malfunctioning navigation keys needed.

This commit is contained in:
Storm Dragon
2025-10-13 18:50:35 -04:00
parent 57e4dceabf
commit 2106143768
4 changed files with 605 additions and 44 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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