Files

2430 lines
123 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Audiobook Organizer - инструмент для автоматизированной организации и каталогизации аудиокниг из различных источников.
Система работает с метаданными аудиокниг, транскриптами, описаниями и обложками. Основной функционал включает извлечение
и обработку информации об аудиокнигах, их автоматическую категоризацию и создание структурированных каталогов.
Инструмент предназначен для систематизации библиотек аудиокниг, улучшения поиска по коллекции и автоматического
создания метаданных для аудиофайлов.
Автор: Dmitry Fofanov
Дата создания: 10.08.2025
"""
import asyncio
import json
import logging
import os
import re
import requests
import sys
import tempfile
import time
import unicodedata
import random
import shutil
from datetime import datetime
from pathlib import Path
# Кроссплатформенная поддержка чтения клавиатуры
# Устанавливаем флаги по умолчанию
HAS_MSVCRT = False
HAS_UNIX_INPUT = False
try:
import msvcrt # Windows
HAS_MSVCRT = True
except Exception:
pass
try:
import select # Unix/Linux
HAS_UNIX_INPUT = True
except Exception:
pass
# Добавляем импорт типов для аннотаций
from typing import Any, Dict, List, Optional, Tuple
from dotenv import load_dotenv
from PyPDF2 import PdfReader
from docx import Document
from rich.console import Console
from rich.logging import RichHandler
from rich.progress import Progress, TextColumn, BarColumn, TimeElapsedColumn, TimeRemainingColumn
from rich.live import Live
from rich.layout import Layout
from rich.panel import Panel
from rich.text import Text
from rich.table import Table
# Опциональная зависимость для красивых меню. Падает обратно на числовой ввод при отсутствии.
try:
from InquirerPy import inquirer # type: ignore
HAS_INQUIRERPY = True
except Exception:
inquirer = None # type: ignore
HAS_INQUIRERPY = False
# Импорты для работы с ИИ
from openai import OpenAI
# Импорт для работы с метаданными аудиофайлов
try:
from mutagen import File as MutagenFile
from mutagen.id3 import ID3
from mutagen.mp3 import MP3
from mutagen.mp4 import MP4
from mutagen.flac import FLAC
from mutagen.oggvorbis import OggVorbis
HAS_MUTAGEN = True
except ImportError:
HAS_MUTAGEN = False
# Загрузка констант из переменных окружения
load_dotenv()
SUPPORTED_AUDIO_EXTENSIONS = set(os.getenv('SUPPORTED_AUDIO_EXTENSIONS', 'mp3,m4a,m4b,opus,ogg,flac').split(','))
SUPPORTED_TEXT_EXTENSIONS = set(os.getenv('SUPPORTED_TEXT_EXTENSIONS', 'txt,pdf,epub,mobi').split(','))
class ConfigurationManager:
"""Менеджер конфигурации для загрузки переменных окружения и настроек"""
def __init__(self, ui_manager=None):
load_dotenv()
self.console = Console()
self.ui_manager = ui_manager
self._setup_logging()
def _setup_logging(self) -> None:
"""Настройка системы логирования с Rich"""
if self.ui_manager:
# Если есть UI менеджер, используем его для логирования
class UILogHandler(logging.Handler):
def __init__(self, ui_manager):
super().__init__()
self.ui_manager = ui_manager
def emit(self, record):
message = self.format(record)
if record.levelno >= logging.ERROR:
self.ui_manager.log_error(message.replace("❌ ", ""))
elif record.levelno >= logging.WARNING:
self.ui_manager.log_warning(message.replace("⚠️ ", ""))
else:
self.ui_manager.log_info(message, "white")
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
handlers=[UILogHandler(self.ui_manager)]
)
else:
# Стандартное логирование для случаев без UI
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
datefmt="[%X]",
handlers=[RichHandler(console=self.console, rich_tracebacks=True)]
)
@property
def audiobook_library_config(self) -> Dict[str, str]:
"""Конфигурация для библиотеки аудиокниг"""
return {
'library_path': os.getenv('AUDIOBOOK_LIBRARY_PATH', './audiobooks'),
'scan_path': os.getenv('AUDIOBOOK_SCAN_PATH', './scan'),
'metadata_format': os.getenv('METADATA_FORMAT', 'json'),
'auto_organize': os.getenv('AUTO_ORGANIZE', 'true').lower() == 'true'
}
@property
def audiobook_sources_config(self) -> Dict[str, str]:
"""Конфигурация для источников аудиокниг"""
return {
'api_key': os.getenv('AUDIOBOOK_SOURCE_API_KEY', ''),
'base_url': os.getenv('AUDIOBOOK_SOURCE_BASE_URL', ''),
'preferred_quality': os.getenv('PREFERRED_AUDIO_QUALITY', 'high')
}
@property
def openrouter_config(self) -> Dict[str, str]:
"""Конфигурация для OpenRouter API"""
return {
'base_url': os.getenv('OPENROUTER_BASE_URL', 'https://openrouter.ai/api/v1'),
'api_key': os.getenv('OPENROUTER_API_KEY', ''),
'model_name': os.getenv('OPENROUTER_MODEL', 'z-ai/glm-4.5-air:free'),
# Рекомендуемые заголовки для OpenRouter (не обязательны, но полезны для квот)
'referer': os.getenv('OPENROUTER_REFERER', 'https://github.com/dfofanov/repo'),
'app_title': os.getenv('OPENROUTER_APP_TITLE', 'BookFlow Curator')
}
@property
def audiobook_output_path(self) -> str:
"""Путь к выходной папке для организованных аудиокниг"""
return os.getenv('AUDIOBOOK_OUTPUT_PATH', './output')
@property
def rkf_org_config(self) -> Dict[str, str]:
"""Конфигурация для парсинга источников аудиокниг"""
return {
'base_url': os.getenv('AUDIOBOOK_SOURCE_BASE_URL', 'https://audiobooks.example.com'),
'timeout': int(os.getenv('AUDIOBOOK_SOURCE_TIMEOUT', '20')),
'user_agent': os.getenv('AUDIOBOOK_SOURCE_USER_AGENT', 'Mozilla/5.0 (compatible; BookFlowCuratorBot/1.0)')
}
class SettingsManager:
"""Менеджер настроек с интерактивным интерфейсом"""
def __init__(self):
self.console = Console()
self.env_file_path = Path('.env')
# Схема параметров для удобного вывода и валидации
self.schema = [
{"key": "AUDIOBOOK_SCAN_PATH", "title": "Audiobook Scan Path", "default": "./scan", "secret": False},
{"key": "AUDIOBOOK_OUTPUT_PATH", "title": "Audiobook Output Path", "default": "./output", "secret": False},
{"key": "OPENROUTER_API_KEY", "title": "OpenRouter API Key", "default": "", "secret": True},
{"key": "OPENROUTER_BASE_URL", "title": "OpenRouter Base URL", "default": "https://openrouter.ai/api/v1", "secret": False},
{"key": "OPENROUTER_MODEL", "title": "OpenRouter Model", "default": "deepseek/deepseek-chat-v3-0324:free", "secret": False},
{"key": "SUPPORTED_AUDIO_EXTENSIONS", "title": "Supported Audio Extensions", "default": "mp3,m4a,m4b,opus,ogg,flac", "secret": False},
]
self.settings = self._load_settings()
def _load_settings(self) -> Dict[str, str]:
"""Загрузка настроек из окружения с учетом значений по умолчанию"""
load_dotenv(override=True)
data: Dict[str, str] = {}
for item in self.schema:
key = item["key"]
data[key] = os.getenv(key, item.get("default", ""))
return data
def _save_settings(self) -> None:
"""Сохранение настроек в .env (в порядке self.schema)"""
lines: List[str] = []
for item in self.schema:
key = item["key"]
val = str(self.settings.get(key, item.get("default", "")))
# Экранируем переносы строк
val = val.replace('\n', '\\n')
lines.append(f"{key}={val}")
self.env_file_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
# Перезагружаем в окружение
load_dotenv(override=True)
def _mask(self, value: str) -> str:
if not value:
return ""
if len(value) <= 4:
return "*" * len(value)
return "*" * (len(value) - 4) + value[-4:]
def _validate_settings(self) -> Tuple[bool, List[str]]:
"""Минимальная валидация обязательных полей"""
errors: List[str] = []
required = [
"OPENROUTER_API_KEY",
]
for key in required:
if not str(self.settings.get(key, "")).strip():
errors.append(f"Параметр {key} обязателен и не должен быть пустым")
return (len(errors) == 0, errors)
def _create_settings_table(self) -> Table:
table = Table(title="Текущие настройки", show_lines=False)
table.add_column("#", style="cyan", justify="right")
table.add_column("Параметр", style="white")
table.add_column("Значение", style="green")
table.add_column("Статус", style="yellow")
is_valid, errors = self._validate_settings()
error_keys = set()
for e in errors:
# извлекаем ключ из текста ошибки
m = re.search(r"(OPENROUTER_API_KEY)", e)
if m:
error_keys.add(m.group(1))
for idx, item in enumerate(self.schema, start=1):
key = item["key"]
val = self.settings.get(key, item.get("default", ""))
display = self._mask(val) if item.get("secret") else str(val)
status = "⚠️" if key in error_keys else "✅"
table.add_row(str(idx), item.get("title", key), display, status)
return table
def _edit_parameter_by_number(self, number: int) -> None:
if number < 1 or number > len(self.schema):
return
item = self.schema[number - 1]
key = item["key"]
title = item.get("title", key)
current = self.settings.get(key, item.get("default", ""))
self.console.print(f"Изменить [{number}] {title} ({key})", style="bold white")
self.console.print(f"Текущее значение: {self._mask(current) if item.get('secret') else current}")
try:
new_val = input("Новое значение (пусто — без изменений): ").strip()
except KeyboardInterrupt:
return
if new_val:
self.settings[key] = new_val
def show_settings_interface(self) -> bool:
"""Простой интерфейс настроек: просмотр/редактирование/сохранение"""
while True:
self.console.clear()
header = Panel(Text("🎧 Audiobook Organizer — Настройка конфигурации", style="bold white", justify="center"), style="blue")
self.console.print(header)
self.console.print(self._create_settings_table())
ok, errors = self._validate_settings()
if errors:
self.console.print(Panel("\n".join(f"❌ {e}" for e in errors), title="Проблемы", style="red"))
tips = Text()
tips.append("Введите номер для редактирования, ", style="white")
tips.append("S", style="yellow"); tips.append(" — сохранить, ", style="white")
tips.append("R", style="yellow"); tips.append(" — перезагрузить из .env, ", style="white")
tips.append("Enter", style="green"); tips.append(" — продолжить, ", style="white")
tips.append("Q", style="red"); tips.append(" — выйти", style="white")
self.console.print(Panel(tips, title="Навигация", style="yellow"))
try:
cmd = input("Ваш выбор: ").strip()
except KeyboardInterrupt:
return False
if cmd == "":
# Продолжаем только если конфигурация валидна
if ok:
return True
else:
self.console.print("Конфигурация некорректна. Исправьте ошибки.", style="red")
time.sleep(1.2)
continue
if cmd.lower() == 'q':
return False
if cmd.lower() == 's':
self._save_settings()
self.console.print("Настройки сохранены в .env", style="green")
time.sleep(0.8)
continue
if cmd.lower() == 'r':
self.settings = self._load_settings()
self.console.print("Настройки перезагружены из .env", style="green")
time.sleep(0.8)
continue
if cmd.isdigit():
self._edit_parameter_by_number(int(cmd))
continue
# Непонятная команда — игнор
self.console.print("Неизвестная команда", style="yellow")
time.sleep(0.8)
class SettingsInteractiveMenu:
"""Интерактивное меню для настроек конфигурации (без управления стрелками)"""
def __init__(self, console: Console, settings_manager):
self.console = console
self.settings_manager = settings_manager
self.selected_index = 0
self._build_menu_options()
def _build_menu_options(self):
"""Построение опций служебного меню"""
is_valid, _ = self.settings_manager._validate_settings()
self.options = []
if is_valid:
self.options.append({
"title": "Запустить обработку",
"description": "Сохранить настройки и перейти к главному меню",
"icon": "▶️",
"value": "start"
})
self.options.append({
"title": "Выход",
"description": "Завершение работы программы",
"icon": "🚪",
"value": "exit"
})
def show(self) -> str:
"""Показать меню настроек: цифры мгновенно выбирают пункт (через InquirerPy select с шорткатами), иначе простой числовой ввод."""
if HAS_INQUIRERPY:
try:
choices = [
{
"name": f"{opt['icon']} {opt['title']}\n {opt['description']}",
"value": opt["value"],
"shortcut_key": str(i),
}
for i, opt in enumerate(self.options, start=1)
]
try:
result = inquirer.select(
message="Выберите действие:",
choices=choices,
default=self.options[0]["value"],
pointer="❯",
qmark="🧭",
instruction="Нажмите цифру 1..N для мгновенного выбора, Esc — выход",
cycle=True,
border=True,
use_shortcuts=True,
).execute()
except KeyboardInterrupt:
return "exit"
return result or "exit"
except Exception:
pass
# Простой числовой выбор (fallback)
while True:
self._build_menu_options()
self.console.clear()
header = Panel(Text("🎧 Audiobook Organizer - Настройка конфигурации", style="bold white", justify="center"), style="blue", padding=(1, 2))
self.console.print(header)
self.console.print(self.settings_manager._create_settings_table())
self.console.print()
self.console.print("📋 Служебные команды:")
for i, opt in enumerate(self.options, start=1):
self.console.print(f" {i}) {opt['icon']} {opt['title']} — {opt['description']}")
choice = input("Ваш выбор (номер, q — выход): ").strip()
if choice.lower() in ("q", "й"):
return "exit"
if choice.isdigit():
n = int(choice)
if 1 <= n <= len(self.options):
return self.options[n - 1]["value"]
self.console.print("Некорректный выбор", style="yellow")
time.sleep(0.8)
class InteractiveMenu:
"""Главное меню без алгоритма передвижения стрелками. Использует InquirerPy, иначе простой числовой ввод."""
def __init__(self, console: Console):
self.console = console
self.options: List[Dict[str, str]] = [
{
"title": "Организация библиотеки аудиокниг",
"description": "Сканирование и автоматическая организация файлов аудиокниг",
"icon": "📚",
"value": "organize_library",
},
{
"title": "Выход",
"description": "Завершение работы программы",
"icon": "🚪",
"value": "exit",
},
]
def show(self) -> str:
"""Показ меню: цифры 1..N мгновенно запускают соответствующую задачу (InquirerPy с шорткатами), иначе — числовой ввод."""
try:
choices = [
{"name": f"{opt['icon']} {opt['title']} — {opt['description']}", "value": opt["value"], "shortcut_key": str(i)}
for i, opt in enumerate(self.options, start=1)
]
result = inquirer.select(
message="Выберите действие:",
choices=choices,
default=self.options[0]["value"],
pointer="❯",
qmark="🧭",
instruction="Нажмите цифру 1..N для мгновенного выбора, Esc — выход",
cycle=True,
border=True,
use_shortcuts=True,
).execute()
return result or "exit"
except Exception:
# Простой числовой ввод
while True:
self.console.clear()
header = Panel(
Text("🎧 Audiobook Organizer - Система организации аудиокниг", style="bold white", justify="center"),
style="blue",
padding=(1, 2),
)
self.console.print(header)
self.console.print()
self.console.print("Выберите действие:")
for i, opt in enumerate(self.options, start=1):
self.console.print(f" {i}) {opt['icon']} {opt['title']} — {opt['description']}")
choice = input("Ваш выбор (номер, q — выход): ").strip()
if choice.lower() in ("q", "й"):
return "exit"
if choice.isdigit():
n = int(choice)
if 1 <= n <= len(self.options):
return self.options[n - 1]["value"]
self.console.print("Некорректный выбор", style="yellow")
time.sleep(0.8)
class TextProcessingUtils:
"""Утилиты для обработки текста"""
@staticmethod
def clean_html_text(text: str) -> str:
if not text:
return ""
text = re.sub(r'<[^>]+>', '', text)
text = text.replace('\n', ' ').replace('\r', ' ')
text = re.sub(r'\s+', ' ', text).strip()
return text
@staticmethod
def transliterate_for_url(text: str) -> str:
translit_map = {
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'e',
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'i', 'к': 'k', 'л': 'l', 'м': 'm',
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'shch',
'ъ': 'ie', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'iu', 'я': 'ia'
}
result = text.lower()
for cyrillic, latin in translit_map.items():
result = result.replace(cyrillic, latin)
result = result.replace(cyrillic.upper(), latin.capitalize() if latin else '')
result = unicodedata.normalize('NFKD', result)
result = ''.join(c for c in result if not unicodedata.combining(c))
result = re.sub(r'[—№«»":(),./]', '', result)
result = re.sub(r'\s+', '-', result)
return result.lower()
@staticmethod
def extract_file_extension(filename: str) -> str:
if not filename:
return ""
return os.path.splitext(filename)[-1].lower().lstrip('.')
class AITextProcessor:
"""Процессор для обработки текста с помощью ИИ"""
def __init__(self, config: Dict[str, str], ui_manager=None):
self.config = config
self.ui_manager = ui_manager
self.client = OpenAI(
base_url=config['base_url'],
api_key=config['api_key'],
timeout=60.0 # Глобальный таймаут для всех запросов
)
def _log(self, message: str):
if self.ui_manager:
self.ui_manager.log_info(message)
else:
print(message)
def generate_audiobook_description(self, metadata: Dict[str, Any]) -> Optional[str]:
"""Генерация описания аудиокниги на основе метаданных"""
prompt = (
"На основе предоставленных метаданных аудиокниги создай краткое и информативное описание. "
"Включи основную информацию о содержании, жанре, авторе и других важных деталях. "
"Описание должно быть полезным для каталогизации и поиска."
)
metadata_text = json.dumps(metadata, ensure_ascii=False, indent=2)
return self._generate_response(prompt, metadata_text)
def _openrouter_headers(self) -> Dict[str, str]:
"""Доп. заголовки для OpenRouter (улучшают идентификацию приложения)."""
headers = {}
referer = self.config.get('referer')
app_title = self.config.get('app_title')
if referer:
headers["HTTP-Referer"] = referer
if app_title:
headers["X-Title"] = app_title
return headers
def verify_connection(self) -> bool:
"""Быстрая проверка доступности LLM (минимальный запрос)."""
try:
probe = self.client.chat.completions.create(
model=self.config['model_name'],
messages=[{"role": "user", "content": "ping"}],
max_tokens=1,
temperature=0,
extra_headers=self._openrouter_headers()
)
ok = bool(getattr(probe, 'choices', None))
if ok:
self._log("✅ Подключение к OpenRouter успешно")
else:
self._log("⚠️ Подключение к OpenRouter не подтвердилось")
return ok
except Exception as e:
logging.warning(f"Проверка OpenRouter не удалась: {e}")
return False
def _generate_response(self, system_prompt: str, user_text: str) -> Optional[str]:
retry_count = 0
max_retries = 3
# Ограничиваем размер запроса, чтобы не превысить лимиты
max_chars = int(os.getenv('OPENROUTER_MAX_INPUT_CHARS', '12000'))
if len(user_text) > max_chars:
user_text = user_text[:max_chars] + "\n... (truncated)"
while retry_count < max_retries:
try:
self._log(f"🤖 Отправляем запрос к ИИ (попытка {retry_count + 1})...")
self._log(f"📡 Модель: {self.config['model_name']}")
self._log(f"📏 Размер запроса: {len(user_text)} символов")
response = self.client.chat.completions.create(
model=self.config['model_name'],
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_text}
],
temperature=0.7,
top_p=0.9,
max_tokens=1024,
timeout=60,
extra_headers=self._openrouter_headers()
)
self._log("✅ Получен ответ от ИИ")
if response and hasattr(response, 'choices') and response.choices:
content = response.choices[0].message.content
if content:
return content
else:
self._log(f"Получен пустой контент в ответе, попытка {retry_count + 1}")
else:
self._log(f"Получен пустой response.choices, попытка {retry_count + 1}")
retry_count += 1
if retry_count < max_retries:
backoff = 5 * (2 ** (retry_count - 1)) + random.uniform(0, 1)
self._log(f"Ожидание {backoff:.1f} сек. перед повторной попыткой...")
time.sleep(backoff)
except Exception as e:
retry_count += 1
# Обработка rate limit / сетевых ошибок
msg = str(e)
if '429' in msg or 'Rate' in msg:
self._log(f"⏳ Лимит запросов. Повтор через немного времени... ({msg})")
else:
self._log(f"Ошибка генерации ответа ИИ (попытка {retry_count}): {e}")
if retry_count < max_retries:
backoff = 5 * (2 ** (retry_count - 1)) + random.uniform(0, 1)
time.sleep(backoff)
else:
logging.error(f"{e}. Максимальное количество попыток достигнуто.")
logging.error(f"Не удалось получить ответ от ИИ после {max_retries} попыток")
return None
def process_audiobook_metadata_with_ai(self, folder_metadata: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Обработка метаданных аудиокниги с помощью ИИ для получения структурированной информации"""
if not folder_metadata:
self._log("❌ Нет метаданных для обработки ИИ")
return None
self._log("🤖 Начинаем обработку метаданных через ИИ...")
# Формируем промпт для ИИ
system_prompt = """Ты - эксперт по аудиокнигам. Проанализируй предоставленные метаданные из всех аудиофайлов книги и верни структурированную информацию в формате JSON.
ВАЖНО: Отвечай ТОЛЬКО валидным JSON без дополнительного текста!
Формат ответа:
{
"formatted_title": "Фамилия Имя автора - Название серии (если есть) Номер книги в серии (формат 01, 02, 10). Название книги [Фамилия Имя чтеца (если есть)] (Год)",
"tags": [],
"chapters": [
{
"id": 0,
"start": 0,
"end": 879.501333,
"title": "Название главы"
}
],
"title": "Название книги",
"subtitle": null,
"authors": ["Фамилия Имя автора"],
"narrators": ["Фамилия Имя чтеца"],
"series": ["Название серии"],
"genres": ["Жанр"],
"publishedYear": 2024,
"publishedDate": null,
"publisher": "Издательство",
"description": "Описание книги",
"isbn": null,
"asin": null,
"language": "ru",
"explicit": false,
"abridged": false
}
Правила:
1. formatted_title должен строго следовать указанному формату
2. chapters создавай на основе количества файлов, рассчитывая время начала и конца
3. Если информация отсутствует, используй null
4. Жанры определяй на основе названия и контекста
5. Язык определяй автоматически (ru для русских книг)
"""
# Подготавливаем данные для отправки в ИИ
user_content = {
"directory_name": folder_metadata.get('directory_name', ''),
"total_files": folder_metadata.get('total_files', 0),
"total_duration": folder_metadata.get('total_duration_formatted', ''),
"files_summary": []
}
# Добавляем краткую сводку по каждому файлу
for file_info in folder_metadata.get('files_metadata', []):
user_content["files_summary"].append({
"filename": file_info.get('filename', ''),
"file_number": file_info.get('file_number', 0),
"duration": file_info.get('metadata', {}).get('duration', ''),
"title": file_info.get('metadata', {}).get('title', ''),
"author": file_info.get('metadata', {}).get('author', ''),
"album": file_info.get('metadata', {}).get('album', ''),
"narrator": file_info.get('metadata', {}).get('narrator', ''),
"genre": file_info.get('metadata', {}).get('genre', ''),
"year": file_info.get('metadata', {}).get('year', ''),
"track": file_info.get('metadata', {}).get('track', ''),
})
# Отправляем только название папки (directory_name) в user_text
user_text = str(user_content.get('directory_name', ''))
self._log(f"🤖 Отправляем в ИИ только название папки: {user_text}")
ai_response = self._generate_response(system_prompt, user_text)
if not ai_response:
self._log("❌ Не удалось получить ответ от ИИ")
return None
try:
# Удаляем возможные ограды ```json ... ``` и пробелы
cleaned = ai_response.strip()
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.IGNORECASE)
cleaned = re.sub(r"\s*```$", "", cleaned)
structured_data = json.loads(cleaned)
self._log("✅ Получен структурированный ответ от ИИ")
self._log_structured_ai_response(structured_data)
return structured_data
except json.JSONDecodeError as e:
self._log(f"❌ Ошибка парсинга JSON ответа от ИИ: {e}")
self._log(f"Ответ ИИ: {ai_response[:500]}...")
return None
class UIManager:
"""Менеджер пользовательского интерфейса с логами и прогрессом"""
def __init__(self):
self.console = Console()
self.layout = Layout()
self.progress = None
self.log_messages: List[Tuple[str, str]] = []
self.max_log_lines = 50
self.live = None
self.setup_layout()
def setup_layout(self):
self.layout.split_column(
Layout(name="header", size=3),
Layout(name="main", ratio=1),
Layout(name="progress", size=5)
)
self.layout["header"].update(
Panel(Text("🎧 Audiobook Organizer - Система организации аудиокниг", style="bold white on blue", justify="center"), style="blue")
)
self.layout["main"].update(Panel("", title="📋 Логи обработки", border_style="green"))
self.layout["progress"].update(Panel("Готов к запуску...", title="📊 Прогресс", border_style="yellow"))
def start_live_display(self):
if not self.live:
self.progress = Progress(
TextColumn("[bold blue]{task.description}"),
BarColumn(bar_width=None),
"[progress.percentage]{task.percentage:>3.0f}%",
"•",
TimeElapsedColumn(),
"•",
TimeRemainingColumn(),
transient=False
)
self.layout["progress"].update(Panel(self.progress, title="📊 Прогресс", border_style="yellow"))
self.live = Live(self.layout, console=self.console, refresh_per_second=10, screen=True, auto_refresh=True)
self.live.start()
def stop_live_display(self):
if self.live:
self.live.stop()
self.live = None
self.progress = None
def _update_logs_panel(self):
recent_logs = self.log_messages[-self.max_log_lines:] if len(self.log_messages) > self.max_log_lines else self.log_messages
log_text = Text()
for message, style in recent_logs:
log_text.append(f"{message}\n", style=style)
self.layout["main"].update(Panel(log_text, title="📋 Логи обработки", border_style="green"))
def log_info(self, message: str, style: str = "white"):
timestamp = datetime.now().strftime('%H:%M:%S')
formatted_message = f"[{timestamp}] {message}"
self.log_messages.append((formatted_message, style))
self._update_logs_panel()
def log_success(self, message: str):
self.log_info(f"✅ {message}", "green")
def log_warning(self, message: str):
self.log_info(f"⚠️ {message}", "yellow")
def log_error(self, message: str):
self.log_info(f"❌ {message}", "red")
def log_processing(self, message: str):
self.log_info(f"🔄 {message}", "cyan")
def poll_quit(self) -> bool:
"""
Кроссплатформенная неблокирующая проверка: нажата ли клавиша 'q'/'Q'.
Возвращает True при запросе остановки.
"""
try:
if HAS_MSVCRT:
if msvcrt.kbhit():
ch = msvcrt.getwch()
if ch in ('q', 'Q'):
self.log_warning("Получена команда остановки: 'Q' → прерывание текущей обработки")
return True
elif HAS_UNIX_INPUT:
if select.select([sys.stdin], [], [], 0.0)[0]:
ch = sys.stdin.read(1)
if ch in ('q', 'Q'):
self.log_warning("Получена команда остановки: 'Q' → прерывание текущей обработки")
return True
except Exception:
pass
return False
def poll_pause(self) -> bool:
"""
Кроссплатформенная неблокирующая проверка: нажата ли клавиша пробел для паузы.
Возвращает True при запросе паузы.
"""
try:
if HAS_MSVCRT:
if msvcrt.kbhit():
ch = msvcrt.getwch()
if ch == ' ': # Пробел
return True
elif ch in ('q', 'Q'):
self.log_warning("Получена команда остановки: 'Q' → прерывание текущей обработки")
return False # Возвращаем False, но устанавливаем флаг остановки отдельно
elif HAS_UNIX_INPUT:
if select.select([sys.stdin], [], [], 0.0)[0]:
ch = sys.stdin.read(1)
if ch == ' ': # Пробел
return True
elif ch in ('q', 'Q'):
self.log_warning("Получена команда остановки: 'Q' → прерывание текущей обработки")
return False
except Exception:
pass
return False
def wait_for_resume(self) -> bool:
"""
Ожидание нажатия пробела для возобновления или 'q' для выхода.
Возвращает True для возобновления, False для выхода.
"""
self.log_info("⏸️ ПАУЗА - Нажмите пробел для продолжения или 'q' для выхода", "yellow")
while True:
try:
if HAS_MSVCRT:
if msvcrt.kbhit():
ch = msvcrt.getwch()
if ch == ' ': # Пробел - возобновить
self.log_info("▶️ Процесс возобновлен", "green")
return True
elif ch in ('q', 'Q'): # Выход
self.log_warning("Получена команда остановки: 'Q' → прерывание текущей обработки")
return False
elif HAS_UNIX_INPUT:
if select.select([sys.stdin], [], [], 0.1)[0]: # Таймаут 0.1 сек
ch = sys.stdin.read(1)
if ch == ' ': # Пробел - возобновить
self.log_info("▶️ Процесс возобновлен", "green")
return True
elif ch in ('q', 'Q'): # Выход
self.log_warning("Получена команда остановки: 'Q' → прерывание текущей обработки")
return False
else:
input("Нажмите Enter для продолжения или Ctrl+C для выхода...")
self.log_info("▶️ Процесс возобновлен", "green")
return True
time.sleep(0.05)
except KeyboardInterrupt:
self.log_warning("Получена команда остановки: Ctrl+C → прерывание текущей обработки")
return False
except Exception:
time.sleep(0.1)
def has_quit_support(self) -> bool:
"""
Проверяет, поддерживается ли функция отмены по клавише Q на текущей платформе.
"""
return HAS_MSVCRT or HAS_UNIX_INPUT
class AudiobookScanner:
"""Сканер для поиска аудиокниг в папках и подпапках"""
def __init__(self, ui_manager=None, output_path=None):
self.ui_manager = ui_manager
self.output_path = output_path
self.supported_extensions = SUPPORTED_AUDIO_EXTENSIONS
self.metadata_extractor = AudiobookMetadataExtractor(ui_manager)
def _log(self, message: str):
if self.ui_manager:
self.ui_manager.log_info(message)
else:
print(message)
def _log_processing(self, message: str):
if self.ui_manager:
self.ui_manager.log_processing(message)
else:
print(f"🔄 {message}")
def scan_directory(self, scan_path: str) -> List[Dict[str, Any]]:
"""Сканирование директории на наличие аудиокниг"""
self._log(f"Начинаем сканирование директории: {scan_path}")
if not os.path.exists(scan_path):
self._log(f"❌ Путь не существует: {scan_path}")
return []
if not os.path.isdir(scan_path):
self._log(f"❌ Указанный путь не является директорией: {scan_path}")
return []
found_audiobooks: List[Dict[str, Any]] = []
total_files = 0
audio_files = 0
try:
for root, dirs, files in os.walk(scan_path):
self._log_processing(f"Сканируем папку: {root}")
for file in files:
total_files += 1
file_path = os.path.join(root, file)
ext = self._get_file_extension(file)
if ext in self.supported_extensions:
audio_files += 1
info = self._analyze_audiobook_file(file_path, root, file)
if info:
found_audiobooks.append(info)
self._log(f"✅ Найден аудиофайл: {file}")
self._log(f"Сканирование завершено. Всего файлов: {total_files}, аудиофайлов: {audio_files}")
self._log(f"Найдено потенциальных аудиокниг: {len(found_audiobooks)}")
except Exception as e:
self._log(f"❌ Ошибка при сканировании: {e}")
return []
return found_audiobooks
def _get_file_extension(self, filename: str) -> str:
"""Получение расширения файла"""
return os.path.splitext(filename)[-1].lower().lstrip('.')
def _analyze_audiobook_file(self, file_path: str, directory: str, filename: str) -> Optional[Dict[str, Any]]:
"""Анализ аудиофайла и извлечение базовой информации"""
try:
file_stats = os.stat(file_path)
file_size = file_stats.st_size
modification_time = datetime.fromtimestamp(file_stats.st_mtime)
# Попытка определить структуру аудиокниги по пути
relative_path = os.path.relpath(directory, os.path.dirname(file_path))
path_parts = relative_path.split(os.sep) if relative_path != '.' else []
# Предположительное определение автора и названия из структуры папок
author = None
title = None
if len(path_parts) >= 2:
author = path_parts[0]
title = path_parts[1]
elif len(path_parts) == 1:
# Возможно, название книги в папке
title = path_parts[0]
if not title:
title = os.path.splitext(filename)[0]
audiobook_info = {
'file_path': file_path,
'filename': filename,
'directory': directory,
'file_size': file_size,
'file_size_mb': round(file_size / (1024 * 1024), 2),
'modification_time': modification_time.isoformat(),
'extension': self._get_file_extension(filename),
'relative_path': relative_path,
'estimated_author': author,
'estimated_title': title,
'path_parts': path_parts
}
return audiobook_info
except Exception as e:
self._log(f"❌ Ошибка анализа файла {file_path}: {e}")
return None
def group_audiobooks_by_directory(self, audiobooks: List[Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]:
"""Группировка аудиофайлов по директориям (предположительно по книгам)"""
grouped: Dict[str, List[Dict[str, Any]]] = {}
for audiobook in audiobooks:
directory = audiobook['directory']
if directory not in grouped:
grouped[directory] = []
grouped[directory].append(audiobook)
for directory in grouped:
grouped[directory].sort(key=lambda x: x['filename'])
return grouped
def get_scan_summary(self, audiobooks: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Получение сводки результатов сканирования"""
if not audiobooks:
return {
'total_files': 0,
'total_size_mb': 0,
'unique_directories': 0,
'extensions': {},
'estimated_books': 0
}
total_size = sum(book['file_size'] for book in audiobooks)
unique_dirs = len(set(book['directory'] for book in audiobooks))
# Подсчет расширений
extensions = {}
for book in audiobooks:
ext = book['extension']
extensions[ext] = extensions.get(ext, 0) + 1
# Оценка количества книг по уникальным директориям
estimated_books = unique_dirs
return {
'total_files': len(audiobooks),
'total_size_mb': round(total_size / (1024 * 1024), 2),
'unique_directories': unique_dirs,
'extensions': extensions,
'estimated_books': estimated_books
}
def scan_directory_tree(self, scan_path: str) -> Dict[str, Any]:
"""Сканирование директории и построение дерева с метаданными"""
self._log(f"Начинаем сканирование дерева каталогов: {scan_path}")
self._log("💡 Управление: Пробел - пауза/продолжение, Q - выход")
if not os.path.exists(scan_path):
self._log(f"❌ Путь не существует: {scan_path}")
return {}
if not os.path.isdir(scan_path):
self._log(f"❌ Указанный путь не является директорией: {scan_path}")
return {}
directory_tree: Dict[str, Any] = {}
total_books = 0
processed_folders = 0
try:
# Сначала подсчитываем общее количество папок для обработки
total_folders = 0
for root, dirs, files in os.walk(scan_path):
audio_files = [f for f in files if self._get_file_extension(f) in self.supported_extensions]
if audio_files:
total_folders += 1
self._log(f"Найдено папок с аудиофайлами: {total_folders}")
# Основной цикл обработки
for root, dirs, files in os.walk(scan_path):
# Проверяем паузу и остановку в начале каждой итерации
if self.ui_manager and hasattr(self.ui_manager, 'poll_pause'):
if self.ui_manager.poll_pause():
if not self.ui_manager.wait_for_resume():
self._log("🛑 Сканирование прервано пользователем")
return directory_tree
elif self.ui_manager.poll_quit():
self._log("🛑 Сканирование прервано пользователем")
return directory_tree
# Получаем аудиофайлы в текущей папке
audio_files = [f for f in files if self._get_file_extension(f) in self.supported_extensions]
if audio_files: # Если в папке есть аудиофайлы
processed_folders += 1
relative_path = os.path.relpath(root, scan_path)
self._log_processing(f"[{processed_folders}/{total_folders}] Обрабатываем: {relative_path}")
# Извлекаем метаданные из первого файла
first_file_path = os.path.join(root, audio_files[0])
metadata = self.metadata_extractor.extract_audiobook_metadata(first_file_path)
# Дополнительно извлекаем полные метаданные из всех файлов и обрабатываем через ИИ
enhanced_metadata = None
if hasattr(self, 'ai_processor') and self.ai_processor:
self._log_processing(f"🤖 Анализируем папку через ИИ: {relative_path}")
folder_metadata = self.metadata_extractor.extract_full_audiobook_metadata(root)
if folder_metadata:
enhanced_metadata = self.ai_processor.process_audiobook_metadata_with_ai(folder_metadata)
# Создаем metadata.json файл для каждой книги
self._create_metadata_json(root, enhanced_metadata, folder_metadata)
# Организуем файлы в структурированные папки
if enhanced_metadata and hasattr(self, 'output_path') and self.output_path:
organized_path = self._organize_audiobook_files(root, enhanced_metadata, folder_metadata)
if metadata:
# Форматируем автора (Фамилия Имя)
author = self._format_author_name(metadata.get('author', ''))
# Используем данные от ИИ если они есть, иначе базовые метаданные
if enhanced_metadata:
title = enhanced_metadata.get('title') or metadata.get('title') or os.path.basename(root)
formatted_title = enhanced_metadata.get('formatted_title') or title
if enhanced_metadata.get('authors'):
author = self._format_author_name(enhanced_metadata['authors'][0])
narrator = (enhanced_metadata.get('narrators') or [metadata.get('narrator', '')])[0] if (enhanced_metadata.get('narrators') or []) else metadata.get('narrator','')
genre = (enhanced_metadata.get('genres') or [metadata.get('genre', '')])[0] if (enhanced_metadata.get('genres') or []) else metadata.get('genre','')
year = enhanced_metadata.get('publishedYear') or metadata.get('year', '')
else:
title = metadata.get('title') or os.path.basename(root)
formatted_title = self._format_book_title({
'author': author,
'title': title,
'album': metadata.get('album', ''),
'track_number': metadata.get('track', ''),
'narrator': metadata.get('narrator', ''),
'relative_path': relative_path
})
narrator = metadata.get('narrator', '')
genre = metadata.get('genre', '')
year = metadata.get('year', '')
directory_info = {
'path': root,
'relative_path': relative_path,
'audio_files_count': len(audio_files),
'title': title,
'formatted_title': formatted_title,
'author': author,
'narrator': narrator,
'track_number': metadata.get('track', ''),
'duration': metadata.get('duration', ''),
'genre': genre,
'year': str(year) if year else '',
'first_file': audio_files[0],
'ai_enhanced': enhanced_metadata is not None,
'ai_metadata': enhanced_metadata
}
directory_tree[relative_path] = directory_info
total_books += 1
# Небольшая пауза между обработкой папок для возможности реагировать на команды
time.sleep(0.1)
# Финальная проверка паузы перед выводом результатов
if self.ui_manager and hasattr(self.ui_manager, 'poll_pause'):
if self.ui_manager.poll_pause():
if not self.ui_manager.wait_for_resume():
return directory_tree
# Вывод дерева каталогов
self._display_directory_tree(directory_tree, scan_path)
self._log(f"Найдено каталогов с аудиокнигами: {total_books}")
return directory_tree
except Exception as e:
self._log(f"❌ Ошибка при сканировании дерева: {e}")
return {}
def _organize_audiobook_files(self, source_directory: str, ai_metadata: Dict[str, Any],
raw_metadata: Dict[str, Any]) -> str:
"""Организует файлы аудиокниги в выходную папку"""
try:
if hasattr(self, 'metadata_extractor') and self.metadata_extractor:
return self.metadata_extractor.organize_audiobook_files(
source_directory, ai_metadata, raw_metadata, self.output_path
)
return ""
except Exception as e:
self._log_processing(f"❌ Ошибка организации файлов: {str(e)}")
return ""
def _format_author_name(self, author: str) -> str:
"""Форматирование имени автора в формате 'Фамилия Имя'"""
if not author:
return ""
# Убираем лишние пробелы и разделяем по пробелам
parts = author.strip().split()
if len(parts) >= 2:
# Если есть несколько частей, переставляем их
# Обычно формат: "Имя Фамилия" -> "Фамилия Имя"
return f"{parts[-1]} {' '.join(parts[:-1])}"
elif len(parts) == 1:
return parts[0]
return author
def _display_directory_tree(self, directory_tree: Dict[str, Any], base_path: str) -> None:
"""Отображение дерева каталогов с метаданными в структурированном формате"""
if not directory_tree:
self._log("Каталоги с аудиокнигами не найдены")
return
self._log("📁 Структурированный каталог аудиокниг:")
self._log(f"🏠 {base_path}")
# Группируем книги по структуре: Жанр -> Первая буква фамилии -> Автор -> Книга
structured_catalog = self._build_structured_catalog(directory_tree)
# Отображаем структурированный каталог
for genre in sorted(structured_catalog.keys()):
self._log(f"├── 📚 {genre}")
author_letters = structured_catalog[genre]
genre_letters = sorted(author_letters.keys())
for i, letter in enumerate(genre_letters):
is_last_letter = i == len(genre_letters) - 1
letter_prefix = " └──" if is_last_letter else " ├──"
self._log(f"{letter_prefix} 🔤 {letter}")
authors = author_letters[letter]
author_names = sorted(authors.keys())
for j, author in enumerate(author_names):
# Выводим книги автора
is_last_author = j == len(author_names) - 1
author_prefix = " └──" if is_last_author else " ├──"
self._log(f"{author_prefix} ✍️ {author} ({len(authors[author])})")
# Можно дополнительно выводить названия книг
for book in authors[author]:
self._log(f" • {book.get('formatted_title') or book.get('title') or book.get('relative_path')}")
def _build_structured_catalog(self, directory_tree: Dict[str, Any]) -> Dict[str, Dict[str, Dict[str, List[Dict[str, Any]]]]]:
"""Построение структурированного каталога: Жанр -> Первая буква -> Автор -> Книги"""
structured = {}
for relative_path, info in directory_tree.items():
# Определяем жанр (по умолчанию или из метаданных)
genre = info.get('genre', '').strip()
if not genre:
genre = "Неизвестный жанр"
# Определяем автора и первую букву его фамилии
author = info.get('author', '').strip()
if not author:
# Пытаемся извлечь автора из пути папки
path_parts = relative_path.split(os.sep)
if len(path_parts) >= 2:
author = path_parts[-2]
else:
author = "Неизвестный автор"
# Получаем первую букву фамилии автора
first_letter = self._get_author_first_letter(author)
# Создаем структуру если её нет
if genre not in structured:
structured[genre] = {}
if first_letter not in structured[genre]:
structured[genre][first_letter] = {}
if author not in structured[genre][first_letter]:
structured[genre][first_letter][author] = []
# Добавляем книгу
structured[genre][first_letter][author].append(info)
return structured
def _get_author_first_letter(self, author: str) -> str:
"""Получение первой буквы фамилии автора"""
if not author:
return "?"
parts = author.strip().split()
if parts:
first_word = parts[0]
if first_word:
first_char = first_word[0].upper()
if 'А' <= first_char <= 'Я' or first_char == 'Ё':
return first_char
if 'A' <= first_char <= 'Z':
return first_char
if first_char.isdigit():
return '#'
return "?"
def _format_book_title(self, book_info: Dict[str, Any]) -> str:
"""Формирование названия книги по заданному формату"""
# Фамилия Имя автора - Название серии (если есть) номер серии. (если есть) Название книги [Фамилия Имя чтеца (если есть)]
author = book_info.get('author', '').strip()
title = book_info.get('title', '').strip()
album = book_info.get('album', '').strip() # Может содержать название серии
track_number = book_info.get('track_number', '').strip()
narrator = book_info.get('narrator', '').strip()
# Если нет названия, используем название альбома или папки
if not title:
title = album or os.path.basename(book_info.get('relative_path', ''))
# Начинаем с автора
formatted_title = author if author else "Неизвестный автор"
# Добавляем разделитель если есть автор
if author:
formatted_title += " - "
# Определяем серию и номер
series_info = ""
if album and album != title:
# Альбом может содержать информацию о серии
series_info = album
if track_number:
if track_number.isdigit():
series_info += f" {int(track_number):02d}."
else:
series_info += f" ({track_number})"
elif track_number:
if track_number.isdigit():
series_info = f"Книга {int(track_number):02d}."
else:
series_info = f"({track_number})"
# Добавляем серию если есть
if series_info:
formatted_title += series_info + " "
# Добавляем название книги
formatted_title += title
# Добавляем чтеца если есть
if narrator:
formatted_title += f" [{narrator}]"
return formatted_title
class AudiobookMetadataExtractor:
"""Экстрактор для извлечения метаданных из аудиокниг и текстовых файлов"""
def __init__(self, ui_manager=None):
self.ui_manager = ui_manager
def _log(self, message: str):
if self.ui_manager:
self.ui_manager.log_info(message)
else:
print(message)
def _extract_text_from_file(self, file_path: str, file_extension: str) -> str:
"""Простое извлечение текста из PDF/DOCX/TXT."""
try:
if file_extension == 'pdf':
text = []
with open(file_path, 'rb') as f:
reader = PdfReader(f)
for page in reader.pages:
try:
text.append(page.extract_text() or "")
except Exception:
pass
return "\n".join(text).strip()
elif file_extension in ['docx']:
doc = Document(file_path)
return "\n".join(p.text for p in doc.paragraphs).strip()
else:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
return f.read()
except Exception as e:
self._log(f"Ошибка извлечения текста из файла {file_path}: {e}")
return ""
def extract_audiobook_metadata(self, file_path: str) -> Optional[Dict[str, Any]]:
"""Минимальное извлечение метаданных из аудиофайла (через mutagen при наличии)."""
try:
if not HAS_MUTAGEN:
return {
'title': os.path.splitext(os.path.basename(file_path))[0],
'author': '', 'narrator': '', 'album': '', 'track': '',
'duration': '', 'genre': '', 'year': '', 'description': ''
}
audio_file = MutagenFile(file_path)
if audio_file is None:
return None
md = {'title': '', 'author': '', 'narrator': '', 'album': '', 'track': '',
'duration': '', 'genre': '', 'year': '', 'description': ''}
# duration
if hasattr(audio_file, 'info') and getattr(audio_file.info, 'length', None):
duration_seconds = int(audio_file.info.length)
h, r = divmod(duration_seconds, 3600); m, s = divmod(r, 60)
md['duration'] = f"{h:02d}:{m:02d}:{s:02d}"
# generic tags
tags = getattr(audio_file, 'tags', {}) or {}
def _val(v):
if v is None:
return ''
if isinstance(v, list) and v:
return str(v[0]).strip()
return str(v).strip()
for k, v in tags.items():
lk = str(k).lower()
val = _val(v)
if not val:
continue
if 'title' in lk or 'tit2' in lk or 'nam' in lk:
md['title'] = val
elif 'artist' in lk or 'tpe1' in lk or 'art' in lk:
md['author'] = val
elif 'album' in lk or 'talb' in lk or 'alb' in lk:
md['album'] = val
elif 'performer' in lk or 'tpe3' in lk or 'narrator' in lk:
md['narrator'] = val
elif 'track' in lk or 'trck' in lk:
md['track'] = re.sub(r"\D", "", val) or val
elif 'genre' in lk or 'tcon' in lk or 'gen' in lk:
md['genre'] = val
elif 'date' in lk or 'year' in lk or 'tdrc' in lk:
md['year'] = re.sub(r"\D", "", val) or val
return md
except Exception as e:
if self.ui_manager:
self.ui_manager.log_error(f"Ошибка извлечения метаданных из файла {file_path}: {e}")
else:
logging.error(f"Ошибка извлечения метаданных из файла {file_path}: {e}")
return None
def _fix_encoding_in_metadata(self, metadata: Dict[str, Any]) -> Dict[str, Any]:
"""Исправление кодировки метаданных, особенно для кириллических символов"""
fixed_metadata = {}
encoding_fixes_applied = 0
for key, value in metadata.items():
if isinstance(value, str) and value:
try:
# Пытаемся определить и исправить проблемы с кодировкой
fixed_value = self._fix_string_encoding(value)
fixed_metadata[key] = fixed_value
# Считаем количество исправлений, но не логируем каждое
if fixed_value != value:
encoding_fixes_applied += 1
except Exception as e:
# Если не удается исправить кодировку, оставляем как есть
# Не логируем каждую ошибку
fixed_metadata[key] = value
else:
# Не строковые значения оставляем без изменений
fixed_metadata[key] = value
# Логируем только общее количество исправлений, если они были
if encoding_fixes_applied > 0:
self._log(f"🔧 Исправлено кодировок: {encoding_fixes_applied}")
return fixed_metadata
def _fix_string_encoding(self, text: str) -> str:
"""Исправление кодировки строки"""
if not text:
return text
try:
# Проверяем, есть ли в строке некорректные символы
# Часто встречается ситуация, когда кириллица закодирована как latin-1, а читается как utf-8
# Пытаемся декодировать как latin-1 и перекодировать в UTF-8
if any(ord(char) > 127 for char in text):
try:
# Попытка исправления распространенной проблемы с кодировкой
# Когда UTF-8 байты интерпретируются как latin-1
bytes_data = text.encode('latin-1')
fixed_text = bytes_data.decode('utf-8')
# Проверяем, что результат выглядит корректно
if self._is_valid_text(fixed_text):
return fixed_text
except (UnicodeDecodeError, UnicodeEncodeError):
pass
try:
# Попытка исправления через cp1251 (Windows-1251)
bytes_data = text.encode('latin-1')
fixed_text = bytes_data.decode('cp1251')
if self._is_valid_text(fixed_text):
return fixed_text
except (UnicodeDecodeError, UnicodeEncodeError):
pass
try:
# Попытка исправления через cp866 (DOS кодировка)
bytes_data = text.encode('latin-1')
fixed_text = bytes_data.decode('cp866')
if self._is_valid_text(fixed_text):
return fixed_text
except (UnicodeDecodeError, UnicodeEncodeError):
pass
# Если ничего не помогло, возвращаем оригинальный текст
return text
except Exception:
# В случае любой ошибки возвращаем оригинальный текст
return text
def _is_valid_text(self, text: str) -> bool:
"""Проверка, что текст выглядит корректно"""
if not text:
return True
# Проверяем наличие кириллических символов (признак корректной кодировки)
cyrillic_count = sum(1 for char in text if 'а' <= char.lower() <= 'я' or char.lower() in 'ёЁ')
# Проверяем наличие странных символов (признак некорректной кодировки)
strange_chars = sum(1 for char in text if ord(char) > 255 and char not in 'абвгдеёжзийклмнопрстуфхцчшщъыьэюя')
# Если есть кириллица и мало странных символов - считаем текст корректным
if cyrillic_count > 0 and strange_chars < len(text) * 0.1:
return True
# Для не-кириллического текста проверяем просто отсутствие странных символов
if cyrillic_count == 0 and strange_chars == 0:
return True
return False
def _log_metadata_structure(self, metadata: Dict[str, Any], file_path: str) -> None:
"""Логирование метаданных в структурном виде"""
if not metadata:
self._log(f"📄 Метаданные не извлечены: {os.path.basename(file_path)}")
return
# Название файла
filename = os.path.basename(file_path)
self._log(f"📄 Метаданные извлечены: {filename}")
# Основная информация
if metadata.get('title'):
self._log(f" 📖 Название: {metadata['title']}")
if metadata.get('author'):
self._log(f" ✍️ Автор: {metadata['author']}")
if metadata.get('album'):
self._log(f" 💿 Альбом: {metadata['album']}")
if metadata.get('narrator'):
self._log(f" 🎙️ Чтец: {metadata['narrator']}")
# Техническая информация
if metadata.get('track'):
self._log(f" 📊 Трек: {metadata['track']}")
if metadata.get('duration'):
self._log(f" ⏱️ Длительность: {metadata['duration']}")
if metadata.get('genre'):
self._log(f" 🎭 Жанр: {metadata['genre']}")
if metadata.get('year'):
self._log(f" 📅 Год: {metadata['year']}")
# Проверяем наличие кириллицы в метаданных
has_cyrillic = any(
isinstance(v, str) and any('а' <= ch.lower() <= 'я' or ch.lower() in 'ёЁ' for ch in v)
for v in metadata.values()
)
if has_cyrillic:
self._log(" 🔤 Кодировка: Обнаружена кириллица - перекодировка применена")
# Подсчет заполненных полей
filled_fields = sum(1 for v in metadata.values() if v and str(v).strip())
total_fields = len(metadata)
self._log(f" 📈 Заполнено полей: {filled_fields}/{total_fields}")
# Разделитель для читаемости
self._log(" " + "─" * 50)
def _extract_mp3_tags(self, audio_file, metadata: Dict[str, Any]) -> None:
"""Извлечение тегов из MP3 файла"""
if not HAS_MUTAGEN:
return
tags = audio_file.tags
if tags:
metadata['title'] = self._safe_extract_tag(tags.get('TIT2', ['']))
metadata['author'] = self._safe_extract_tag(tags.get('TPE1', ['']))
metadata['album'] = self._safe_extract_tag(tags.get('TALB', ['']))
metadata['narrator'] = self._safe_extract_tag(tags.get('TPE3', ['']))
metadata['track'] = self._safe_extract_tag(tags.get('TRCK', ['']))
metadata['genre'] = self._safe_extract_tag(tags.get('TCON', ['']))
metadata['year'] = self._safe_extract_tag(tags.get('TDRC', ['']))
def _extract_mp4_tags(self, audio_file, metadata: Dict[str, Any]) -> None:
"""Извлечение тегов из MP4/M4A файла"""
if not HAS_MUTAGEN:
return
tags = audio_file.tags
if tags:
metadata['title'] = self._safe_extract_tag(tags.get('\xa9nam', ['']))
metadata['author'] = self._safe_extract_tag(tags.get('\xa9ART', ['']))
metadata['album'] = self._safe_extract_tag(tags.get('\xa9alb', ['']))
metadata['narrator'] = self._safe_extract_tag(tags.get('\xa9wrt', ['']))
# Для trkn нужна специальная обработка, так как это tuple
track_info = tags.get('trkn', [])
if track_info and len(track_info) > 0 and isinstance(track_info[0], tuple):
metadata['track'] = str(track_info[0][0]) if track_info[0][0] else ''
else:
metadata['track'] = self._safe_extract_tag(track_info)
metadata['genre'] = self._safe_extract_tag(tags.get('\xa9gen', ['']))
metadata['year'] = self._safe_extract_tag(tags.get('\xa9day', ['']))
def _extract_flac_tags(self, audio_file, metadata: Dict[str, Any]) -> None:
"""Извлечение тегов из FLAC файла"""
if not HAS_MUTAGEN:
return
tags = audio_file.tags
if tags:
metadata['title'] = self._safe_extract_tag(tags.get('TITLE', ['']))
metadata['author'] = self._safe_extract_tag(tags.get('ARTIST', ['']))
metadata['album'] = self._safe_extract_tag(tags.get('ALBUM', ['']))
metadata['narrator'] = self._safe_extract_tag(tags.get('PERFORMER', ['']))
metadata['track'] = self._safe_extract_tag(tags.get('TRACKNUMBER', ['']))
metadata['genre'] = self._safe_extract_tag(tags.get('GENRE', ['']))
metadata['year'] = self._safe_extract_tag(tags.get('DATE', ['']))
def _extract_ogg_tags(self, audio_file, metadata: Dict[str, Any]) -> None:
"""Извлечение тегов из OGG файла"""
if not HAS_MUTAGEN:
return
tags = audio_file.tags
if tags:
metadata['title'] = self._safe_extract_tag(tags.get('TITLE', ['']))
metadata['author'] = self._safe_extract_tag(tags.get('ARTIST', ['']))
metadata['album'] = self._safe_extract_tag(tags.get('ALBUM', ['']))
metadata['narrator'] = self._safe_extract_tag(tags.get('PERFORMER', ['']))
metadata['track'] = self._safe_extract_tag(tags.get('TRACKNUMBER', ['']))
metadata['genre'] = self._safe_extract_tag(tags.get('GENRE', ['']))
metadata['year'] = self._safe_extract_tag(tags.get('DATE', ['']))
def _safe_extract_tag(self, tag_value) -> str:
"""Безопасное извлечение значения тега"""
if not tag_value:
return ''
# Если это список, берем первый элемент
if isinstance(tag_value, list):
if len(tag_value) > 0:
value = tag_value[0]
else:
return ''
else:
value = tag_value
# Преобразуем в строку
try:
return str(value).strip()
except Exception:
return ''
def _extract_generic_tags(self, audio_file, metadata: Dict[str, Any]) -> None:
"""Извлечение универсальных тегов"""
tags = getattr(audio_file, 'tags', None)
if not tags:
return
# Попытка извлечь основные теги из любого формата
for key, value in tags.items():
processed_value = self._safe_extract_tag(value)
if not processed_value:
continue
key_lower = str(key).lower()
if 'title' in key_lower or 'nam' in key_lower:
metadata['title'] = processed_value
elif 'artist' in key_lower or 'art' in key_lower:
metadata['author'] = processed_value
elif 'album' in key_lower or 'alb' in key_lower:
metadata['album'] = processed_value
elif 'performer' in key_lower or 'narrator' in key_lower:
metadata['narrator'] = processed_value
elif 'track' in key_lower:
metadata['track'] = processed_value
elif 'genre' in key_lower or 'gen' in key_lower:
metadata['genre'] = processed_value
elif 'date' in key_lower or 'year' in key_lower:
metadata['year'] = processed_value
async def extract_text_from_file(self, file_url: str, file_name: str) -> Optional[str]:
try:
response = requests.get(file_url, timeout=30)
response.raise_for_status()
file_extension = TextProcessingUtils.extract_file_extension(file_name)
if file_extension not in SUPPORTED_TEXT_EXTENSIONS:
if self.ui_manager:
self.ui_manager.log_warning(f"Неподдерживаемое расширение файла: {file_extension}")
else:
logging.warning(f"Неподдерживаемое расширение файла: {file_extension}")
return None
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{file_extension}") as temp_file:
temp_file.write(response.content)
temp_path = temp_file.name
try:
extracted_text = self._extract_text_from_file(temp_path, file_extension)
return extracted_text
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception as e:
if self.ui_manager:
self.ui_manager.log_error(f"Ошибка извлечения текста из файла {file_name}: {e}")
else:
logging.error(f"Ошибка извлечения текста из файла {file_name}: {e}")
return None
def extract_full_audiobook_metadata(self, directory_path: str) -> Optional[Dict[str, Any]]:
"""Извлечение полных метаданных из всех аудиофайлов в папке"""
if not os.path.exists(directory_path) or not os.path.isdir(directory_path):
self._log(f"❌ Папка не существует: {directory_path}")
return None
# Получаем все аудиофайлы в папке
audio_files = []
for file in os.listdir(directory_path):
if self._get_file_extension(file) in SUPPORTED_AUDIO_EXTENSIONS:
audio_files.append(file)
if not audio_files:
self._log(f"❌ Аудиофайлы не найдены в папке: {directory_path}")
return None
# Сортируем файлы по имени
audio_files.sort()
self._log(f"📁 Анализируем папку: {os.path.basename(directory_path)}")
self._log(f"🎵 Найдено аудиофайлов: {len(audio_files)}")
# Собираем метаданные из всех файлов
all_metadata = []
total_duration_seconds = 0
chapters = []
current_start_time = 0
for i, filename in enumerate(audio_files, 1):
file_path = os.path.join(directory_path, filename)
# Убираем детальное логирование по каждому файлу
# self._log(f" [{i}/{len(audio_files)}] Обрабатываем: {filename}")
metadata = self.extract_audiobook_metadata(file_path)
if metadata:
# Добавляем информацию о файле
file_info = {
'filename': filename,
'file_number': i,
'metadata': metadata
}
# Парсим длительность для подсчета общего времени и создания глав
duration_str = metadata.get('duration', '')
file_duration_seconds = 0
if duration_str and ':' in duration_str:
try:
parts = duration_str.split(':')
if len(parts) == 3: # HH:MM:SS
hours, minutes, seconds = map(int, parts)
file_duration_seconds = hours * 3600 + minutes * 60 + seconds
total_duration_seconds += file_duration_seconds
file_info['duration_seconds'] = file_duration_seconds
except ValueError:
pass
# Создаем главу для этого файла
chapter_title = self._generate_chapter_title(filename, metadata, i)
chapter = {
"id": i - 1, # ID начинается с 0
"start": current_start_time,
"end": current_start_time + file_duration_seconds,
"title": chapter_title
}
chapters.append(chapter)
# Обновляем время начала для следующей главы
current_start_time += file_duration_seconds
all_metadata.append(file_info)
if not all_metadata:
self._log(f"❌ Не удалось извлечь метаданные из файлов в папке: {directory_path}")
return None
# Формируем общую информацию о папке
folder_info = {
'directory_path': directory_path,
'directory_name': os.path.basename(directory_path),
'total_files': len(audio_files),
'total_duration_seconds': total_duration_seconds,
'total_duration_formatted': self._format_duration(total_duration_seconds),
'chapters': chapters,
'files_metadata': all_metadata
}
self._log(f"✅ Собраны метаданные из {len(all_metadata)} файлов")
self._log(f"📖 Создано глав: {len(chapters)}")
self._log(f"⏱️ Общая длительность: {folder_info['total_duration_formatted']}")
# Логируем краткую сводку по первому файлу для понимания содержимого
if all_metadata:
first_file_metadata = all_metadata[0]['metadata']
self._log("📋 Краткая информация о книге:")
self._log(f"🔍 DEBUG: Метаданные первого файла: {first_file_metadata}")
if first_file_metadata.get('title'):
self._log(f" 📖 Название: {first_file_metadata['title']}")
if first_file_metadata.get('author'):
self._log(f" ✍️ Автор: {first_file_metadata['author']}")
if first_file_metadata.get('album') and first_file_metadata.get('album').strip():
self._log(f" 💿 Альбом: {first_file_metadata['album']}")
if first_file_metadata.get('narrator') and first_file_metadata.get('narrator').strip():
self._log(f" 🎙️ Чтец: {first_file_metadata['narrator']}")
if first_file_metadata.get('genre') and first_file_metadata.get('genre').strip():
self._log(f" 🎭 Жанр: {first_file_metadata['genre']}")
if first_file_metadata.get('year') and first_file_metadata.get('year').strip():
self._log(f" 📅 Год: {first_file_metadata['year']}")
# Создаем файл metadata.json в папке с аудиокнигой
# Исправим формирование title/series/subtitle для metadata.json
def _extract_series_title_subtitle(meta):
"""
Возвращает (series, title, subtitle) по метаданным.
Пример: album = 'Архимаг, который живёт в подвале, Книга 2.'
-> series = 'Архимаг, который живёт в подвале.'
-> subtitle = 'Книга 2'
-> title = meta['title'] если оно != серии
"""
album = (meta.get('album') or '').strip()
orig_title = (meta.get('title') or '').strip()
# Ищем "Книга N" или "Том N" или "Book N" в album
m = re.search(r'(.*?)[,\s]*([Кк]нига|[Тт]ом|[Bb]ook)\s*(\d+)[. ]*$', album)
if m:
series = m.group(1).strip(' ,.')
subtitle = f"{m.group(2).capitalize()} {m.group(3)}"
# Если title совпадает с album, то title = series
if orig_title and orig_title != album:
title = orig_title
else:
title = series
return series, title, subtitle
# Если нет паттерна, но album есть — series=album
if album:
return album, orig_title if orig_title and orig_title != album else album, None
# Если нет album — series пусто
return '', orig_title, None
# Получаем корректные series, title, subtitle
series, title, subtitle = _extract_series_title_subtitle(first_file_metadata)
# genres, publishedYear, description — обязательно ищем в AI (или метаданных)
ai_metadata = {}
# ...existing code...
self._create_metadata_json(directory_path, {
'series': [series] if series else [],
'title': title,
'subtitle': subtitle,
'genres': ai_metadata.get('genres', [first_file_metadata.get('genre', '')] if first_file_metadata.get('genre') else []),
'publishedYear': ai_metadata.get('publishedYear', first_file_metadata.get('year', '')),
'description': ai_metadata.get('description', ''),
# остальные поля пусть как есть
}, folder_info)
# Переименовываем папку по новому формату
new_folder_name = self._generate_formatted_folder_name(first_file_metadata)
self._log(f"📝 Сформировано новое название папки: {new_folder_name}")
rename_success = self._rename_audiobook_folder(directory_path, new_folder_name)
# Обновляем путь в folder_info если переименование прошло успешно
if rename_success:
parent_dir = os.path.dirname(directory_path)
new_path = os.path.join(parent_dir, new_folder_name)
if os.path.exists(new_path):
folder_info['directory_path'] = new_path
folder_info['directory_name'] = new_folder_name
# --- Новый функционал: переносим папку в структуру по букве и автору ---
# Получаем фамилию и первую букву фамилии автора
author = first_file_metadata.get('author', '').strip()
if not author:
author = 'Неизвестный автор'
# Фамилия - первая часть (до первого пробела), если есть
author_parts = author.split()
if len(author_parts) > 1:
surname = author_parts[0]
author_folder = f"{author_parts[0]} {' '.join(author_parts[1:])}"
else:
surname = author
author_folder = author
first_letter = surname[0].upper() if surname else 'U'
# Путь к папке вывода
AUDIOBOOK_OUTPUT_PATH = os.environ.get('AUDIOBOOK_OUTPUT_PATH', 'AudiobookOutput')
output_letter_dir = os.path.join(AUDIOBOOK_OUTPUT_PATH, first_letter)
output_author_dir = os.path.join(output_letter_dir, author_folder)
# Создаем папки, если их нет
os.makedirs(output_author_dir, exist_ok=True)
# Перемещаем переименованную папку внутрь папки автора
import shutil, fnmatch
dest_path = os.path.join(output_author_dir, new_folder_name)
# Если целевая папка уже существует, удаляем её (или можно реализовать логику слияния)
if os.path.exists(dest_path):
shutil.rmtree(dest_path)
# Копируем все, кроме *.nfo
def ignore_nfo(dir, files):
return [f for f in files if fnmatch.fnmatch(f, '*.nfo')]
shutil.copytree(new_path, dest_path, ignore=ignore_nfo)
# Удаляем исходную папку
shutil.rmtree(new_path)
# Создаем metadata.json в целевой папке
self._create_metadata_json(dest_path, {}, folder_info)
# Обновляем путь в folder_info
folder_info['directory_path'] = dest_path
folder_info['directory_name'] = os.path.basename(dest_path)
return folder_info
def _generate_chapter_title(self, filename: str, metadata: Dict[str, Any], chapter_number: int) -> str:
"""Генерация названия главы на основе имени файла и метаданных"""
# Убираем расширение из имени файла
base_name = os.path.splitext(filename)[0]
# Пытаемся извлечь название главы из метаданных
title_from_metadata = metadata.get('title', '')
# Если в метаданных есть название и оно отличается от имени файла, используем его
if title_from_metadata and title_from_metadata.lower() != base_name.lower():
return title_from_metadata
# Иначе пытаемся извлечь название главы из имени файла
# Удаляем номера в начале (01., 02., 001-, и т.п.)
clean_name = re.sub(r'^[\d\-\.\s]+', '', base_name).strip()
if clean_name:
return clean_name
# Если ничего не получилось, генерируем стандартное название
if chapter_number == 1:
return "Пролог"
else:
return f"Глава {chapter_number - 1}"
def _create_metadata_json(self, directory_path: str, ai_metadata: Dict[str, Any], raw_metadata: Dict[str, Any]) -> None:
"""Создает metadata.json файл в папке с аудиокнигой"""
try:
# Извлекаем базовую информацию из первого файла если AI метаданные пусты
first_file_metadata = {}
if raw_metadata.get('files_metadata') and len(raw_metadata['files_metadata']) > 0:
first_file_metadata = raw_metadata['files_metadata'][0]['metadata']
# Определяем название книги и подзаголовок из альбома
title = ai_metadata.get('title', first_file_metadata.get('title', ''))
album = first_file_metadata.get('album', '')
subtitle = None
# Пытаемся извлечь номер книги из альбома или трека
if album and album != title:
# Проверяем, есть ли в альбоме номер книги
book_match = re.search(r'[Кк]нига\s*(\d+)|[Bb]ook\s*(\d+)|Том\s*(\d+)', album)
if book_match:
book_num = book_match.group(1) or book_match.group(2) or book_match.group(3)
subtitle = f"Книга {book_num}"
# Получаем название серии из альбома (убираем номер книги если есть)
series_name = ""
if album:
series_name = re.sub(r'\s*[Кк]нига\s*\d+|\s*[Bb]ook\s*\d+|\s*Том\s*\d+', '', album).strip()
# Формируем полную структуру метаданных в требуемом формате
metadata = {
"tags": ai_metadata.get('tags', []),
"chapters": raw_metadata.get('chapters', []),
"title": title or raw_metadata.get('directory_name', ''),
"subtitle": subtitle,
"authors": ai_metadata.get('authors', [first_file_metadata.get('author', '')] if first_file_metadata.get('author') else []),
"narrators": ai_metadata.get('narrators', [first_file_metadata.get('narrator', '')] if first_file_metadata.get('narrator') else []),
"series": [series_name] if series_name else ai_metadata.get('series', []),
"genres": ai_metadata.get('genres', [first_file_metadata.get('genre', '')] if first_file_metadata.get('genre') else []),
"publishedYear": ai_metadata.get('publishedYear', first_file_metadata.get('year', '')),
"publishedDate": ai_metadata.get('publishedDate', None),
"publisher": ai_metadata.get('publisher', ''),
"description": ai_metadata.get('description', ''),
"isbn": ai_metadata.get('isbn', None),
"asin": ai_metadata.get('asin', None),
"language": ai_metadata.get('language', None),
"explicit": ai_metadata.get('explicit', False),
"abridged": ai_metadata.get('abridged', False)
}
# Путь к файлу метаданных
metadata_file_path = os.path.join(directory_path, "metadata.json")
# Записываем метаданные в JSON файл
with open(metadata_file_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
self._log(f"📄 Создан файл metadata.json: {metadata_file_path}")
except Exception as e:
self._log(f"❌ Ошибка при создании metadata.json: {str(e)}")
def organize_audiobook_files(self, source_directory: str, ai_metadata: Dict[str, Any],
raw_metadata: Dict[str, Any], output_base_path: str) -> str:
"""Организует файлы аудиокниги в структурированную папку по автору и названию"""
try:
# Получаем данные для организации
authors = ai_metadata.get('authors', [])
title = ai_metadata.get('title', raw_metadata.get('directory_name', 'Unknown_Book'))
if not authors:
# Если авторов нет, используем "Unknown_Author"
author_name = "Unknown_Author"
first_letter = "U"
else:
author_name = authors[0] # Берем первого автора
# Получаем первую букву для группировки
first_letter = self._get_first_letter_for_grouping(author_name)
# Создаем структуру папок: Output/A/Автор Фамилия/Название книги/
author_folder = self._sanitize_folder_name(author_name)
title_folder = self._sanitize_folder_name(title)
target_path = os.path.join(output_base_path, first_letter, author_folder, title_folder)
# Создаем все необходимые папки
os.makedirs(target_path, exist_ok=True)
# Получаем список MP3 файлов из исходной папки
mp3_files = []
for filename in os.listdir(source_directory):
if filename.lower().endswith(('.mp3', '.m4a', '.m4b', '.opus', '.ogg', '.flac')):
mp3_files.append(filename)
# Копируем MP3 файлы в целевую папку
copied_files = []
for filename in mp3_files:
source_file = os.path.join(source_directory, filename)
target_file = os.path.join(target_path, filename)
try:
import shutil
shutil.copy2(source_file, target_file)
copied_files.append(filename)
self._log(f"📁 Скопирован файл: {filename}")
except Exception as e:
self._log(f"❌ Ошибка копирования {filename}: {str(e)}")
# Создаем metadata.json в целевой папке
self._create_metadata_json_in_target(target_path, ai_metadata, raw_metadata, copied_files)
self._log(f"✅ Организованы файлы в: {target_path}")
self._log(f"📂 Скопировано файлов: {len(copied_files)}")
return target_path
except Exception as e:
self._log(f"❌ Ошибка при организации файлов: {str(e)}")
return ""
def _get_first_letter_for_grouping(self, author_name: str) -> str:
"""Получает первую букву автора для группировки папок"""
if not author_name:
return "U" # Unknown
# Берем первую букву и приводим к верхнему регистру
first_char = author_name[0].upper()
# Если это кириллица, оставляем как есть
if 'А' <= first_char <= 'Я':
return first_char
# Если это латиница, оставляем как есть
elif 'A' <= first_char <= 'Z':
return first_char
# Если это цифра или символ, помещаем в папку "#"
else:
return "#"
def _sanitize_folder_name(self, name: str) -> str:
"""Очищает название от недопустимых символов для имени папки"""
if not name:
return "Unknown"
# Заменяем недопустимые символы на подчеркивания
sanitized = re.sub(r'[<>:"/\\|?*]', '_', name)
# Убираем лишние пробелы и точки в конце
sanitized = sanitized.strip('. ')
# Ограничиваем длину (Windows имеет ограничение в 255 символов)
if len(sanitized) > 100:
sanitized = sanitized[:100]
return sanitized
def _create_metadata_json_in_target(self, target_path: str, ai_metadata: Dict[str, Any],
raw_metadata: Dict[str, Any], copied_files: list) -> None:
"""Создает metadata.json в целевой папке с информацией о скопированных файлах"""
try:
# Извлекаем базовую информацию из первого файла если AI метаданные пусты
first_file_metadata = {}
if raw_metadata.get('files_metadata') and len(raw_metadata['files_metadata']) > 0:
first_file_metadata = raw_metadata['files_metadata'][0]['metadata']
# Определяем название книги и подзаголовок из альбома
title = ai_metadata.get('title', first_file_metadata.get('title', ''))
album = first_file_metadata.get('album', '')
subtitle = None
# Пытаемся извлечь номер книги из альбома или трека
if album and album != title:
# Проверяем, есть ли в альбоме номер книги
book_match = re.search(r'[Кк]нига\s*(\d+)|[Bb]ook\s*(\d+)|Том\s*(\d+)', album)
if book_match:
book_num = book_match.group(1) or book_match.group(2) or book_match.group(3)
subtitle = f"Книга {book_num}"
# Получаем название серии из альбома (убираем номер книги если есть)
series_name = ""
if album:
series_name = re.sub(r'\s*[Кк]нига\s*\d+|\s*[Bb]ook\s*\d+|\s*Том\s*\d+', '', album).strip()
# Формируем метаданные для целевой папки в требуемом формате
metadata = {
"tags": ai_metadata.get('tags', []),
"chapters": raw_metadata.get('chapters', []),
"title": title or raw_metadata.get('directory_name', ''),
"subtitle": subtitle,
"authors": ai_metadata.get('authors', [first_file_metadata.get('author', '')] if first_file_metadata.get('author') else []),
"narrators": ai_metadata.get('narrators', [first_file_metadata.get('narrator', '')] if first_file_metadata.get('narrator') else []),
"series": [series_name] if series_name else ai_metadata.get('series', []),
"genres": ai_metadata.get('genres', [first_file_metadata.get('genre', '')] if first_file_metadata.get('genre') else []),
"publishedYear": ai_metadata.get('publishedYear', first_file_metadata.get('year', '')),
"publishedDate": ai_metadata.get('publishedDate', None),
"publisher": ai_metadata.get('publisher', ''),
"description": ai_metadata.get('description', ''),
"isbn": ai_metadata.get('isbn', None),
"asin": ai_metadata.get('asin', None),
"language": ai_metadata.get('language', None),
"explicit": ai_metadata.get('explicit', False),
"abridged": ai_metadata.get('abridged', False)
}
# Путь к файлу метаданных в целевой папке
metadata_file_path = os.path.join(target_path, "metadata.json")
# Записываем метаданные в JSON файл
with open(metadata_file_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
self._log(f"📄 Создан metadata.json в целевой папке: {metadata_file_path}")
except Exception as e:
self._log(f"❌ Ошибка при создании metadata.json в целевой папке: {str(e)}")
def _get_file_extension(self, filename: str) -> str:
"""Получение расширения файла"""
return os.path.splitext(filename)[-1].lower().lstrip('.')
def _format_duration(self, total_seconds: int) -> str:
"""Форматирование длительности в читаемый вид"""
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def _generate_formatted_folder_name(self, metadata: Dict[str, Any]) -> str:
"""Генерирует отформатированное название папки по указанному формату"""
# Фамилия Имя автора - Название серии (если есть) номер книги (если есть). Название книги (Год если есть) [Фамилия Имя чтеца]
author = metadata.get('author', '').strip()
title = metadata.get('title', '').strip()
album = metadata.get('album', '').strip()
narrator = metadata.get('narrator', '').strip()
year = metadata.get('year', '').strip()
# Начинаем с автора
if not author:
author = "Неизвестный автор"
formatted_name = author
# Обрабатываем серию и номер книги из альбома
series_part = ""
book_number = ""
series_name = ""
if album and album != title:
# Пытаемся извлечь номер книги
book_match = re.search(r'[Кк]нига\s*(\d+)|[Bb]ook\s*(\d+)|Том\s*(\d+)', album)
if book_match:
book_number = book_match.group(1) or book_match.group(2) or book_match.group(3)
# Убираем номер книги из названия серии
series_name = re.sub(r'\s*[Кк]нига\s*\d+|\s*[Bb]ook\s*\d+|\s*Том\s*\d+', '', album).strip()
else:
# Весь альбом - это название серии
series_name = album
# Формируем часть с серией
if series_name or book_number:
series_part = " -"
if series_name:
series_part += f" {series_name}"
if book_number:
series_part += f" {int(book_number):02d}."
formatted_name += series_part
# Добавляем название книги
if title:
if series_part:
formatted_name += f" {title}"
else:
formatted_name += f" - {title}"
else:
if not series_part:
formatted_name += " - Неизвестное название"
# Добавляем год если есть
if year:
formatted_name += f" ({year})"
# Добавляем чтеца если есть
if narrator:
formatted_name += f" [{narrator}]"
# Очищаем название от недопустимых символов
return self._sanitize_folder_name(formatted_name)
def _rename_audiobook_folder(self, current_path: str, new_folder_name: str) -> bool:
"""Переименовывает папку с аудиокнигой"""
try:
parent_dir = os.path.dirname(current_path)
new_path = os.path.join(parent_dir, new_folder_name)
# Проверяем, что новое имя отличается от текущего
current_folder_name = os.path.basename(current_path)
if current_folder_name == new_folder_name:
self._log(f"✅ Папка уже имеет правильное название: {new_folder_name}")
return True
# Проверяем, что новая папка не существует
if os.path.exists(new_path):
self._log(f"⚠️ Папка с именем '{new_folder_name}' уже существует")
return False
# Переименовываем папку
os.rename(current_path, new_path)
self._log(f"✅ Папка успешно переименована:")
self._log(f" Было: {current_folder_name}")
self._log(f" Стало: {new_folder_name}")
return True
except Exception as e:
self._log(f"❌ Ошибка при переименовании папки: {str(e)}")
return False
# Заменяем заглушку оркестратора полной реализацией
class BookFlowCurator:
"""Основной оркестратор процесса организации аудиокниг"""
def __init__(self):
self.ui_manager = UIManager()
self.config_manager = ConfigurationManager(self.ui_manager)
# Инициализация компонентов с обработкой ошибок
self.ai_processor = None
self.audiobook_sources = None
try:
self.ai_processor = AITextProcessor(self.config_manager.openrouter_config, self.ui_manager)
except Exception as e:
self.ui_manager.log_warning(f"OpenRouter недоступен: {e}. ИИ обработка отключена.")
try:
# Создаем заглушку для AudiobookSourcesClient
self.audiobook_sources = type('AudiobookSourcesClient', (), {
'__init__': lambda self, config, ui: None
})(self.config_manager.audiobook_sources_config, self.ui_manager)
except Exception as e:
self.ui_manager.log_warning(f"Источники аудиокниг недоступны: {e}")
# Создаем заглушку для AudiobookSourceParser
self.source_parser = type('AudiobookSourceParser', (), {
'__init__': lambda self, config, ui: None
})(self.config_manager.rkf_org_config, self.ui_manager)
self.scanner = AudiobookScanner(self.ui_manager, self.config_manager.audiobook_output_path)
self.scanner.ai_processor = self.ai_processor # Передаем AI процессор в сканер
self.metadata_extractor = AudiobookMetadataExtractor(self.ui_manager)
self.text_utils = TextProcessingUtils()
self.json_output_file = None
self._cancel_requested = False # Флаг пользовательской отмены
def _log(self, message: str):
if self.ui_manager:
self.ui_manager.log_info(message)
else:
print(message)
def _should_cancel(self) -> bool:
"""
Централизованная проверка запроса остановки.
"""
if self._cancel_requested:
return True
if self.ui_manager and self.ui_manager.poll_quit():
self._cancel_requested = True
return True
return False
def _check_pause_and_quit(self) -> bool:
"""
Проверка паузы и остановки процесса.
Возвращает True если нужно остановить процесс, False если продолжить.
"""
if self._cancel_requested:
return True
if self.ui_manager:
# Проверяем запрос на выход
if self.ui_manager.poll_quit():
self._cancel_requested = True
return True
# Проверяем запрос на паузу
if self.ui_manager.poll_pause():
# Входим в режим паузы
if not self.ui_manager.wait_for_resume():
# Пользователь выбрал выход из паузы
self._cancel_requested = True
return True
# Пользователь выбрал продолжение, возвращаемся к работе
return False
async def organize_library(self):
"""Организация библиотеки аудиокниг"""
self.ui_manager.start_live_display()
try:
# Получаем путь для сканирования из конфигурации
library_config = self.config_manager.audiobook_library_config
scan_path = library_config.get('scan_path', './scan')
library_path = library_config.get('library_path', './audiobooks')
self.ui_manager.log_info(f"Начинаем организацию библиотеки аудиокниг")
self.ui_manager.log_info(f"Папка для сканирования: {scan_path}")
self.ui_manager.log_info(f"Папка библиотеки: {library_path}")
# Проверяем на паузу/остановку перед началом
if self._check_pause_and_quit():
self.ui_manager.log_warning("Организация библиотеки прервана пользователем")
return
# Создаем папку библиотеки если её нет
os.makedirs(library_path, exist_ok=True)
# Сканирование дерева каталогов с метаданными
directory_tree = self.scanner.scan_directory_tree(scan_path)
# Проверяем на паузу/остановку после сканирования
if self._check_pause_and_quit():
self.ui_manager.log_warning("Организация библиотеки прервана пользователем")
return
if not directory_tree:
self.ui_manager.log_warning("Каталоги с аудиокнигами не найдены в указанной папке")
return
# Подсчет общей статистики
total_files = sum(info['audio_files_count'] for info in directory_tree.values())
total_books = len(directory_tree)
self.ui_manager.log_success(f"Найдено каталогов с аудиокнигами: {total_books}")
self.ui_manager.log_success(f"Общее количество аудиофайлов: {total_files}")
# Проверяем на паузу/остановку перед сохранением
if self._check_pause_and_quit():
self.ui_manager.log_warning("Организация библиотеки прервана пользователем")
return
# Сохранение результатов в JSON
output_file = os.path.join(library_path, f"directory_tree_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
tree_results = {
'scan_timestamp': datetime.now().isoformat(),
'scan_path': scan_path,
'library_path': library_path,
'total_books': total_books,
'total_files': total_files,
'directory_tree': directory_tree
}
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(tree_results, f, ensure_ascii=False, indent=2)
self.ui_manager.log_success(f"Результаты сохранены в: {output_file}")
self.ui_manager.log_info("Организация библиотеки завершена!")
except Exception as e:
self.ui_manager.log_error(f"Ошибка при организации библиотеки: {e}")
logging.error(f"Критическая ошибка в organize_library: {e}")
finally:
self.ui_manager.stop_live_display()
async def main():
"""Главная функция приложения"""
try:
# Показ интерфейса настроек
settings_manager = SettingsManager()
if not settings_manager.show_settings_interface():
print("Выход из программы по запросу пользователя.")
return
# Перезагрузка переменных окружения после изменений
load_dotenv(override=True)
# Перезагрузка констант из обновленных переменных окружения
global SUPPORTED_AUDIO_EXTENSIONS, SUPPORTED_TEXT_EXTENSIONS
SUPPORTED_AUDIO_EXTENSIONS = set(os.getenv('SUPPORTED_AUDIO_EXTENSIONS', 'mp3,m4a,m4b,opus,ogg,flac').split(','))
SUPPORTED_TEXT_EXTENSIONS = set(os.getenv('SUPPORTED_TEXT_EXTENSIONS', 'txt,pdf,epub,mobi').split(','))
# Создание оркестратора
orchestrator = BookFlowCurator()
# Создание консоли и интерактивного меню
console = Console()
menu = InteractiveMenu(console)
# Показ интерактивного меню
try:
choice = menu.show()
except KeyboardInterrupt:
console.print("\n[yellow]Меню прервано пользователем.[/yellow]")
return
# Обработка выбора
if choice == "organize_library":
console.clear()
await orchestrator.organize_library()
elif choice == "exit":
console.print("[yellow]Программа завершена пользователем.[/yellow]")
else:
console.print("❌ Неизвестный выбор", style="red")
except KeyboardInterrupt:
print("\nПрограмма прервана пользователем.")
except Exception as e:
logging.error(f"Критическая ошибка: {e}")
raise
if __name__ == "__main__":
# Запуск главной функции в асинхронном режиме
asyncio.run(main())