diff --git a/bookstorm.py b/bookstorm.py index 41b8909..6a7d7ea 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -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 diff --git a/src/book_selector.py b/src/book_selector.py index 5602f36..431fbb8 100644 --- a/src/book_selector.py +++ b/src/book_selector.py @@ -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: diff --git a/src/folder_audiobook_parser.py b/src/folder_audiobook_parser.py new file mode 100644 index 0000000..3f7a4cc --- /dev/null +++ b/src/folder_audiobook_parser.py @@ -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 diff --git a/src/mpv_player.py b/src/mpv_player.py index dd89370..ffdb771 100644 --- a/src/mpv_player.py +++ b/src/mpv_player.py @@ -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 \ No newline at end of file + 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 \ No newline at end of file