379 lines
12 KiB
Python
379 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Book Selector
|
|
|
|
Interactive file browser for selecting book files.
|
|
Supports navigation and filtering by supported formats.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
import zipfile
|
|
|
|
|
|
class BookSelector:
|
|
"""Book file selection interface"""
|
|
|
|
def __init__(self, startDir=None, supportedFormats=None, speechEngine=None):
|
|
"""
|
|
Initialize book selector
|
|
|
|
Args:
|
|
startDir: Starting directory (default: home)
|
|
supportedFormats: List of supported file extensions (default: ['.zip', '.epub'])
|
|
speechEngine: SpeechEngine instance for accessibility
|
|
"""
|
|
if startDir is None:
|
|
startDir = Path.home()
|
|
|
|
if supportedFormats is None:
|
|
supportedFormats = ['.zip', '.epub']
|
|
|
|
self.currentDir = Path(startDir).resolve()
|
|
self.supportedFormats = supportedFormats
|
|
self.speechEngine = speechEngine
|
|
self.currentSelection = 0
|
|
self.inBrowser = False
|
|
self.items = []
|
|
|
|
def select_book_interactive(self):
|
|
"""
|
|
Interactive book selection with directory navigation
|
|
|
|
Returns:
|
|
Selected book path or None if cancelled
|
|
"""
|
|
while True:
|
|
print(f"\nCurrent directory: {self.currentDir}")
|
|
print("-" * 60)
|
|
|
|
# List directories and supported files
|
|
items = self._list_items()
|
|
|
|
if not items:
|
|
print("No books or directories found")
|
|
print("\nCommands:")
|
|
print(" .. - Go to parent directory")
|
|
print(" q - Cancel")
|
|
print()
|
|
|
|
choice = input("Select> ").strip()
|
|
if choice == 'q':
|
|
return None
|
|
elif choice == '..':
|
|
self._go_parent()
|
|
continue
|
|
|
|
# Display items
|
|
for idx, item in enumerate(items):
|
|
prefix = "[DIR]" if item['isDir'] else "[BOOK]"
|
|
print(f"{idx + 1}. {prefix} {item['name']}")
|
|
|
|
print("-" * 60)
|
|
print("\nCommands:")
|
|
print(" <number> - Select item")
|
|
print(" .. - Go to parent directory")
|
|
print(" q - Cancel")
|
|
print()
|
|
|
|
try:
|
|
choice = input("Select> ").strip()
|
|
|
|
if choice == 'q':
|
|
return None
|
|
|
|
elif choice == '..':
|
|
self._go_parent()
|
|
|
|
else:
|
|
# Select item by number
|
|
try:
|
|
itemNum = int(choice)
|
|
if 1 <= itemNum <= len(items):
|
|
selectedItem = items[itemNum - 1]
|
|
|
|
if selectedItem['isDir']:
|
|
# Navigate into directory
|
|
self.currentDir = selectedItem['path']
|
|
else:
|
|
# Return selected book
|
|
return str(selectedItem['path'])
|
|
else:
|
|
print(f"Invalid number. Choose 1-{len(items)}")
|
|
|
|
except ValueError:
|
|
print("Invalid input. Enter a number, '..' for parent, or 'q' to cancel")
|
|
|
|
except (EOFError, KeyboardInterrupt):
|
|
print("\nCancelled")
|
|
return None
|
|
|
|
def _list_items(self):
|
|
"""
|
|
List directories and supported book files in current directory
|
|
|
|
Returns:
|
|
List of item dictionaries
|
|
"""
|
|
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()):
|
|
if item.name.startswith('.'):
|
|
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,
|
|
'isAudiobookFolder': audioFileCount >= 2, # At least 2 files for multi-file audiobook
|
|
'audioFileCount': audioFileCount
|
|
})
|
|
|
|
# Add supported book files
|
|
for item in sorted(self.currentDir.iterdir()):
|
|
if item.name.startswith('.'):
|
|
continue
|
|
|
|
if item.is_file() and item.suffix.lower() in self.supportedFormats:
|
|
# For zip files, validate that they're actually DAISY books
|
|
if item.suffix.lower() == '.zip':
|
|
if not self._is_daisy_zip(item):
|
|
continue # Skip non-DAISY zip files
|
|
|
|
items.append({
|
|
'name': item.name,
|
|
'path': item,
|
|
'isDir': False,
|
|
'isAudiobookFolder': False,
|
|
'audioFileCount': 0
|
|
})
|
|
|
|
except PermissionError:
|
|
print(f"Permission denied: {self.currentDir}")
|
|
|
|
return items
|
|
|
|
def _go_parent(self):
|
|
"""Navigate to parent directory"""
|
|
parent = self.currentDir.parent
|
|
if parent != self.currentDir: # Not at root
|
|
self.currentDir = parent
|
|
else:
|
|
print("Already at root directory")
|
|
|
|
def _is_daisy_zip(self, zipPath):
|
|
"""
|
|
Check if a zip file contains a DAISY book
|
|
|
|
Args:
|
|
zipPath: Path to zip file
|
|
|
|
Returns:
|
|
True if zip contains DAISY book markers, False otherwise
|
|
"""
|
|
try:
|
|
with zipfile.ZipFile(zipPath, 'r') as zf:
|
|
fileList = zf.namelist()
|
|
|
|
# Check for DAISY 2.02 marker (ncc.html)
|
|
if 'ncc.html' in fileList:
|
|
return True
|
|
|
|
# Check for DAISY 3 marker (.ncx file)
|
|
for filename in fileList:
|
|
if filename.lower().endswith('.ncx'):
|
|
return True
|
|
|
|
return False
|
|
except (zipfile.BadZipFile, PermissionError, OSError):
|
|
# 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
|
|
|
|
Args:
|
|
directory: Path to directory to reset to
|
|
"""
|
|
dirPath = Path(directory).resolve()
|
|
if dirPath.exists() and dirPath.is_dir():
|
|
self.currentDir = dirPath
|
|
|
|
def get_current_directory(self):
|
|
"""
|
|
Get current directory path
|
|
|
|
Returns:
|
|
Path object of current directory
|
|
"""
|
|
return self.currentDir
|
|
|
|
def enter_browser(self):
|
|
"""Enter the book browser"""
|
|
self.inBrowser = True
|
|
self.currentSelection = 0
|
|
self.items = self._list_items()
|
|
|
|
if self.speechEngine:
|
|
self.speechEngine.speak("Book browser. Use arrow keys to navigate, Enter to select, Backspace for parent directory, L to set library, Escape to cancel.")
|
|
|
|
# Speak current directory and first item
|
|
if self.speechEngine:
|
|
dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir)
|
|
self.speechEngine.speak(f"Directory: {dirName}")
|
|
|
|
if self.items:
|
|
self._speak_current_item()
|
|
elif self.speechEngine:
|
|
self.speechEngine.speak("Empty directory")
|
|
|
|
def navigate_browser(self, direction):
|
|
"""Navigate browser up or down"""
|
|
if not self.items:
|
|
return
|
|
|
|
if direction == 'up':
|
|
self.currentSelection = (self.currentSelection - 1) % len(self.items)
|
|
elif direction == 'down':
|
|
self.currentSelection = (self.currentSelection + 1) % len(self.items)
|
|
|
|
self._speak_current_item()
|
|
|
|
def _speak_current_item(self):
|
|
"""Speak current item with name first, then type"""
|
|
if not self.items or not self.speechEngine:
|
|
return
|
|
|
|
item = self.items[self.currentSelection]
|
|
if item['isDir']:
|
|
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"
|
|
|
|
self.speechEngine.speak(text)
|
|
|
|
def activate_current_item(self):
|
|
"""
|
|
Activate current item (enter directory or return book path)
|
|
|
|
Returns:
|
|
Book path if selected, None if navigating or empty
|
|
"""
|
|
if not self.items:
|
|
if self.speechEngine:
|
|
self.speechEngine.speak("No items")
|
|
return None
|
|
|
|
item = self.items[self.currentSelection]
|
|
|
|
if item['isDir']:
|
|
# 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.items:
|
|
self._speak_current_item()
|
|
elif self.speechEngine:
|
|
self.speechEngine.speak("Empty directory")
|
|
|
|
return None
|
|
else:
|
|
# Book selected
|
|
if self.speechEngine:
|
|
self.speechEngine.speak(f"Loading: {item['name']}")
|
|
return str(item['path'])
|
|
|
|
def go_parent_directory(self):
|
|
"""Go to parent directory"""
|
|
parent = self.currentDir.parent
|
|
if parent != self.currentDir:
|
|
self.currentDir = parent
|
|
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"Parent directory: {dirName}")
|
|
|
|
if self.items:
|
|
self._speak_current_item()
|
|
elif self.speechEngine:
|
|
self.speechEngine.speak("Empty directory")
|
|
else:
|
|
if self.speechEngine:
|
|
self.speechEngine.speak("Already at root")
|
|
|
|
def is_in_browser(self):
|
|
"""Check if currently in browser"""
|
|
return self.inBrowser
|
|
|
|
def exit_browser(self):
|
|
"""Exit the browser"""
|
|
self.inBrowser = False
|
|
if self.speechEngine:
|
|
self.speechEngine.speak("Cancelled")
|