2430 lines
123 KiB
Python
2430 lines
123 KiB
Python
"""
|
||
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())
|