Folder based audiobooks added. Still a little fixing of some malfunctioning navigation keys needed.
This commit is contained in:
@@ -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:
|
||||
|
||||
341
src/folder_audiobook_parser.py
Normal file
341
src/folder_audiobook_parser.py
Normal 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
|
||||
@@ -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