- Добавлен ConsoleLogger для подробного логирования этапов обработки аудиокниг в консоли. - Введен ConsolePresenter для форматированного вывода результатов сканирования в консоль. - Создан ProcessAudioBooksUseCase для обработки полного конвейера обработки аудиокниг, включая сканирование папок, извлечение метаданных, поиск торрентов и запись результатов. - Реализована проверка LLM для улучшения метаданных. - Добавлена обработка ошибок и логирование на всех этапах обработки.
571 lines
16 KiB
Go
571 lines
16 KiB
Go
// Package infrastructure реализует порт ResultWriter —
|
||
// создание структуры папок, metadata.json и перенос аудиофайлов.
|
||
package infrastructure
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/fofanov/genaudiobookinfo/internal/domain"
|
||
"github.com/tcolgate/mp3"
|
||
)
|
||
|
||
// FSResultWriter реализует domain.ResultWriter.
|
||
type FSResultWriter struct{}
|
||
|
||
// NewFSResultWriter создаёт новый экземпляр.
|
||
func NewFSResultWriter() *FSResultWriter {
|
||
return &FSResultWriter{}
|
||
}
|
||
|
||
// WriteResult создаёт: result/<Буква>/<Автор>/<Автор - Альбом [Год]>/metadata.json
|
||
// и переносит аудиофайлы из исходной папки.
|
||
func (w *FSResultWriter) WriteResult(ctx context.Context, book *domain.EnrichedBookInfo, resultRoot string) (string, error) {
|
||
select {
|
||
case <-ctx.Done():
|
||
return "", ctx.Err()
|
||
default:
|
||
}
|
||
|
||
info := book.AudioBook
|
||
detail := book.Detail
|
||
|
||
author := sanitizePath(info.Author)
|
||
album := sanitizePath(info.Album)
|
||
if album == "" {
|
||
album = sanitizePath(info.Title)
|
||
}
|
||
|
||
// --- Формируем metadata.json (до создания папки) ---
|
||
metadata, err := buildMetadata(ctx, info, detail)
|
||
if err != nil {
|
||
return "", fmt.Errorf("ошибка формирования metadata: %w", err)
|
||
}
|
||
metaBytes, err := json.MarshalIndent(metadata, "", " ")
|
||
if err != nil {
|
||
return "", fmt.Errorf("ошибка сериализации metadata.json: %w", err)
|
||
}
|
||
|
||
// Для имени папки используем уже нормализованные поля metadata
|
||
authorForFolder := author
|
||
if len(metadata.Authors) > 0 {
|
||
metaAuthor := strings.TrimSpace(metadata.Authors[0])
|
||
if metaAuthor != "" {
|
||
authorForFolder = sanitizePath(metaAuthor)
|
||
}
|
||
}
|
||
if authorForFolder == "" {
|
||
authorForFolder = "Unknown"
|
||
}
|
||
|
||
titleForFolder := album
|
||
if strings.TrimSpace(metadata.Title) != "" {
|
||
titleForFolder = sanitizePath(metadata.Title)
|
||
}
|
||
if titleForFolder == "" {
|
||
titleForFolder = "Unknown"
|
||
}
|
||
|
||
// --- Fallback: если автор/название — кракозябры, используем данные трекера ---
|
||
if detail != nil && detail.Name != "" {
|
||
if !containsCyrillic(authorForFolder) || isMojibake(authorForFolder) {
|
||
if detailAuthor := extractAuthorFromDetailName(detail.Name); detailAuthor != "" && containsCyrillic(detailAuthor) {
|
||
authorForFolder = sanitizePath(detailAuthor)
|
||
}
|
||
}
|
||
if !containsCyrillic(titleForFolder) || isMojibake(titleForFolder) {
|
||
if detailTitle := extractTitleFromDetailName(detail.Name); detailTitle != "" && containsCyrillic(detailTitle) {
|
||
titleForFolder = sanitizePath(detailTitle)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Первая буква фамилии автора (заглавная)
|
||
firstLetter := "0"
|
||
if authorForFolder != "" {
|
||
r, _ := utf8.DecodeRuneInString(authorForFolder)
|
||
firstLetter = strings.ToUpper(string(r))
|
||
}
|
||
|
||
// Имя папки книги: Автор - Альбом [Год]
|
||
bookFolderName := fmt.Sprintf("%s - %s", authorForFolder, titleForFolder)
|
||
if metadata.PublishedYear != "" {
|
||
bookFolderName = fmt.Sprintf("%s [%s]", bookFolderName, metadata.PublishedYear)
|
||
}
|
||
|
||
authorFolder := sanitizePath(authorForFolder)
|
||
if authorFolder == "" {
|
||
authorFolder = "Unknown"
|
||
}
|
||
|
||
destDir := filepath.Join(resultRoot, firstLetter, authorFolder, sanitizePath(bookFolderName))
|
||
|
||
// --- Создаём целевой каталог только после успешной подготовки данных ---
|
||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||
return "", fmt.Errorf("не удалось создать каталог %q: %w", destDir, err)
|
||
}
|
||
|
||
metaPath := filepath.Join(destDir, "metadata.json")
|
||
if err := os.WriteFile(metaPath, metaBytes, 0644); err != nil {
|
||
return "", fmt.Errorf("ошибка записи metadata.json: %w", err)
|
||
}
|
||
|
||
// --- Переносим аудиофайлы ---
|
||
if err := moveAudioFiles(ctx, info.FolderPath, destDir); err != nil {
|
||
return destDir, fmt.Errorf("ошибка переноса аудиофайлов: %w", err)
|
||
}
|
||
|
||
// --- Удаляем исходную папку после успешного переноса ---
|
||
if err := os.RemoveAll(info.FolderPath); err != nil {
|
||
// Не критично, но логируем
|
||
return destDir, fmt.Errorf("файлы перенесены, но не удалось удалить исходную папку %q: %w", info.FolderPath, err)
|
||
}
|
||
|
||
return destDir, nil
|
||
}
|
||
|
||
// buildMetadata формирует структуру BookMetadata из локальных тегов и данных трекера.
|
||
func buildMetadata(ctx context.Context, info *domain.AudioBookInfo, detail *domain.TorrentDetail) (domain.BookMetadata, error) {
|
||
chapters, chaptersErr := buildChaptersFromMP3(ctx, info.FolderPath)
|
||
if chaptersErr != nil {
|
||
return domain.BookMetadata{}, chaptersErr
|
||
}
|
||
|
||
publishedYear := ""
|
||
if info.Year > 0 {
|
||
publishedYear = strconv.Itoa(info.Year)
|
||
}
|
||
|
||
meta := domain.BookMetadata{
|
||
Tags: []string{},
|
||
Chapters: chapters,
|
||
Title: coalesce(info.Album, info.Title),
|
||
Subtitle: "",
|
||
Authors: splitClean(info.Author),
|
||
Narrators: []string{},
|
||
Series: []string{},
|
||
Genres: splitClean(info.Genre),
|
||
PublishedYear: publishedYear,
|
||
PublishedDate: "",
|
||
Publisher: "",
|
||
Description: info.Comment,
|
||
Language: "ru",
|
||
ISBN: "",
|
||
ASIN: "",
|
||
Explicit: false,
|
||
Abridged: false,
|
||
}
|
||
|
||
if detail != nil {
|
||
meta.Subtitle = detail.Name
|
||
if titleFromDetail := extractTitleFromDetailName(detail.Name); titleFromDetail != "" {
|
||
meta.Title = titleFromDetail
|
||
}
|
||
|
||
if detail.Description != "" {
|
||
meta.Description = detail.Description
|
||
}
|
||
if detail.Type != "" {
|
||
meta.Genres = splitClean(detail.Type)
|
||
}
|
||
if detail.Year != "" {
|
||
meta.PublishedYear = detail.Year
|
||
}
|
||
meta.Narrators = extractNarrators(detail)
|
||
meta.Series = extractSeries(detail.Name)
|
||
}
|
||
|
||
return meta, nil
|
||
}
|
||
|
||
// extractAuthorFromDetailName извлекает автора из detail.Name.
|
||
// Поддерживает формат: "Автор - Название [Год]" → "Автор".
|
||
func extractAuthorFromDetailName(name string) string {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return ""
|
||
}
|
||
if idx := strings.Index(name, " - "); idx > 0 {
|
||
return strings.TrimSpace(name[:idx])
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// extractTitleFromDetailName извлекает название книги из detail.Name.
|
||
// Поддерживает кейс: "Автор - Серия. Книга N [...]" -> "Серия".
|
||
func extractTitleFromDetailName(name string) string {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return ""
|
||
}
|
||
|
||
if idx := strings.Index(name, " - "); idx >= 0 {
|
||
name = strings.TrimSpace(name[idx+3:])
|
||
}
|
||
if idx := strings.Index(name, "["); idx >= 0 {
|
||
name = strings.TrimSpace(name[:idx])
|
||
}
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return ""
|
||
}
|
||
|
||
// Кейс: "Исекай взрослого человека 03, Вы призвали... Кого надо! #3"
|
||
// -> "Исекай взрослого человека"
|
||
// Кейс: "Я попал 02, Горячее лето 42-го" -> "Горячее лето 42-го"
|
||
if idx := strings.Index(name, ","); idx > 0 {
|
||
firstPart := strings.TrimSpace(name[:idx])
|
||
secondPart := strings.TrimSpace(name[idx+1:])
|
||
reNumAtEnd := regexp.MustCompile(`^(.+?)\s+\d+$`)
|
||
|
||
// Если вторая часть имеет суффикс #N — это серия,
|
||
// а название книги берём из первой части без номера.
|
||
reSeriesNumSuffix := regexp.MustCompile(`^(.+?)\s*#\s*\d+\s*$`)
|
||
if m := reSeriesNumSuffix.FindStringSubmatch(secondPart); len(m) == 2 {
|
||
if mm := reNumAtEnd.FindStringSubmatch(firstPart); len(mm) == 2 {
|
||
title := strings.TrimSpace(mm[1])
|
||
if title != "" {
|
||
return title
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если первая часть заканчивается номером, а вторая часть обычная,
|
||
// то вторая часть — название книги.
|
||
if reNumAtEnd.MatchString(firstPart) && secondPart != "" {
|
||
return secondPart
|
||
}
|
||
|
||
if m := reNumAtEnd.FindStringSubmatch(firstPart); len(m) == 2 {
|
||
title := strings.TrimSpace(m[1])
|
||
if title != "" {
|
||
return title
|
||
}
|
||
}
|
||
}
|
||
|
||
// Кейс: "Клан для Антиквара. Книга 1" -> "Клан для Антиквара"
|
||
reSeriesBook := regexp.MustCompile(`(?i)^(.+?)\.\s*(?:книга\s*\d+|\d+\s*книга)\s*$`)
|
||
if m := reSeriesBook.FindStringSubmatch(name); len(m) == 2 {
|
||
title := strings.TrimSpace(m[1])
|
||
if title != "" {
|
||
return title
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
func buildChaptersFromMP3(ctx context.Context, srcDir string) ([]domain.ChapterInfo, error) {
|
||
entries, err := os.ReadDir(srcDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка чтения каталога для chapters: %w", err)
|
||
}
|
||
|
||
var mp3Files []string
|
||
for _, entry := range entries {
|
||
if entry.IsDir() || !isMP3File(entry.Name()) {
|
||
continue
|
||
}
|
||
mp3Files = append(mp3Files, entry.Name())
|
||
}
|
||
|
||
sort.Strings(mp3Files)
|
||
chapters := make([]domain.ChapterInfo, 0, len(mp3Files))
|
||
cursor := 0.0
|
||
|
||
for i, fileName := range mp3Files {
|
||
select {
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
default:
|
||
}
|
||
|
||
fullPath := filepath.Join(srcDir, fileName)
|
||
durationSeconds, err := readMP3DurationSeconds(fullPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка чтения длительности %q: %w", fullPath, err)
|
||
}
|
||
|
||
start := cursor
|
||
end := cursor + durationSeconds
|
||
cursor = end
|
||
|
||
chapters = append(chapters, domain.ChapterInfo{
|
||
ID: i,
|
||
Start: start,
|
||
End: end,
|
||
Title: fileName,
|
||
})
|
||
}
|
||
|
||
return chapters, nil
|
||
}
|
||
|
||
func readMP3DurationSeconds(filePath string) (float64, error) {
|
||
f, err := os.Open(filePath)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer f.Close()
|
||
|
||
decoder := mp3.NewDecoder(f)
|
||
var frame mp3.Frame
|
||
skipped := 0
|
||
total := time.Duration(0)
|
||
|
||
for {
|
||
err = decoder.Decode(&frame, &skipped)
|
||
if err == io.EOF {
|
||
break
|
||
}
|
||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||
break
|
||
}
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
total += frame.Duration()
|
||
}
|
||
|
||
return total.Seconds(), nil
|
||
}
|
||
|
||
func extractNarrators(detail *domain.TorrentDetail) []string {
|
||
result := make([]string, 0, 2)
|
||
seen := map[string]bool{}
|
||
|
||
if detail == nil {
|
||
return result
|
||
}
|
||
|
||
name := strings.TrimSpace(detail.Name)
|
||
if name != "" {
|
||
if narrator := narratorFromNameBracket(name); narrator != "" {
|
||
if !seen[narrator] {
|
||
result = append(result, narrator)
|
||
seen[narrator] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
languageField := strings.TrimSpace(detail.Language)
|
||
if languageField != "" {
|
||
if !seen[languageField] {
|
||
result = append(result, languageField)
|
||
seen[languageField] = true
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
func narratorFromNameBracket(name string) string {
|
||
open := strings.LastIndex(name, "[")
|
||
close := strings.LastIndex(name, "]")
|
||
if open < 0 || close <= open {
|
||
return ""
|
||
}
|
||
inside := strings.TrimSpace(name[open+1 : close])
|
||
if inside == "" {
|
||
return ""
|
||
}
|
||
|
||
parts := strings.Split(inside, ",")
|
||
if len(parts) == 0 {
|
||
return ""
|
||
}
|
||
first := strings.TrimSpace(parts[0])
|
||
if first == "" {
|
||
return ""
|
||
}
|
||
|
||
// Отсекаем очевидно не имя чтеца
|
||
lower := strings.ToLower(first)
|
||
if strings.Contains(lower, "kbps") || strings.Contains(lower, "mp3") || strings.Contains(lower, "год") {
|
||
return ""
|
||
}
|
||
|
||
return first
|
||
}
|
||
|
||
func extractSeries(name string) []string {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return []string{}
|
||
}
|
||
|
||
// Берём часть после автора: "Автор - Серия 7, Альбом [...]"
|
||
if idx := strings.Index(name, " - "); idx >= 0 {
|
||
name = strings.TrimSpace(name[idx+3:])
|
||
}
|
||
if idx := strings.Index(name, "["); idx >= 0 {
|
||
name = strings.TrimSpace(name[:idx])
|
||
}
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return []string{}
|
||
}
|
||
|
||
// Кейс: "Исекай взрослого человека 03, Вы призвали... Кого надо! #3"
|
||
// -> series: "Вы призвали... Кого надо!"
|
||
if idx := strings.Index(name, ","); idx > 0 {
|
||
secondPart := strings.TrimSpace(name[idx+1:])
|
||
reSeriesNumSuffix := regexp.MustCompile(`^(.+?)\s*#\s*\d+\s*$`)
|
||
if m := reSeriesNumSuffix.FindStringSubmatch(secondPart); len(m) == 2 {
|
||
series := strings.TrimSpace(m[1])
|
||
if series != "" {
|
||
return []string{series}
|
||
}
|
||
}
|
||
}
|
||
|
||
firstPart := name
|
||
if idx := strings.Index(firstPart, ","); idx >= 0 {
|
||
firstPart = strings.TrimSpace(firstPart[:idx])
|
||
}
|
||
|
||
// Кейс: "Клан для Антиквара. Книга 1" -> "Клан для Антиквара"
|
||
reBookSuffix := regexp.MustCompile(`(?i)^(.+?)\s*(?:\(\s*\d+\s*книга\s*\)|[\.,]?\s*книга\s*\d+|[\.,]?\s*\d+\s*книга)\s*$`)
|
||
if m := reBookSuffix.FindStringSubmatch(firstPart); len(m) == 2 {
|
||
series := strings.TrimSpace(m[1])
|
||
series = strings.TrimRight(series, ".,:- ")
|
||
if idx := strings.Index(series, ":"); idx >= 0 {
|
||
series = strings.TrimSpace(series[:idx])
|
||
}
|
||
if series != "" {
|
||
return []string{series}
|
||
}
|
||
}
|
||
|
||
// Кейс: "Первый рыцарь 7, Первый альянс" -> "Первый рыцарь"
|
||
reNumAtEnd := regexp.MustCompile(`^(.+?)\s+\d+$`)
|
||
if m := reNumAtEnd.FindStringSubmatch(firstPart); len(m) == 2 {
|
||
series := strings.TrimSpace(m[1])
|
||
if series != "" {
|
||
return []string{series}
|
||
}
|
||
}
|
||
|
||
// Кейс: "Звёздные беглецы. Миссия Маяк" -> "Звёздные беглецы"
|
||
// (серия перед первой точкой, после точки — название конкретной книги)
|
||
if dot := strings.Index(name, "."); dot > 0 {
|
||
series := strings.TrimSpace(name[:dot])
|
||
bookTitle := strings.TrimSpace(name[dot+1:])
|
||
if series != "" && bookTitle != "" {
|
||
return []string{series}
|
||
}
|
||
}
|
||
|
||
return []string{}
|
||
}
|
||
|
||
// moveAudioFiles переносит аудиофайлы из srcDir в destDir.
|
||
func moveAudioFiles(ctx context.Context, srcDir, destDir string) error {
|
||
entries, err := os.ReadDir(srcDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// Сортируем для предсказуемого порядка
|
||
sort.Slice(entries, func(i, j int) bool {
|
||
return entries[i].Name() < entries[j].Name()
|
||
})
|
||
|
||
for _, entry := range entries {
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
default:
|
||
}
|
||
|
||
if entry.IsDir() || !isAudioFile(entry.Name()) {
|
||
continue
|
||
}
|
||
|
||
srcPath := filepath.Join(srcDir, entry.Name())
|
||
destPath := filepath.Join(destDir, entry.Name())
|
||
|
||
// Пробуем os.Rename (быстрый перенос на том же диске)
|
||
if err := os.Rename(srcPath, destPath); err != nil {
|
||
// Если разные диски — копируем + удаляем
|
||
if copyErr := copyFile(srcPath, destPath); copyErr != nil {
|
||
return fmt.Errorf("не удалось перенести %q: %w", entry.Name(), copyErr)
|
||
}
|
||
os.Remove(srcPath)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// copyFile побайтово копирует файл.
|
||
func copyFile(src, dst string) error {
|
||
in, err := os.Open(src)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer in.Close()
|
||
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer out.Close()
|
||
|
||
if _, err := io.Copy(out, in); err != nil {
|
||
return err
|
||
}
|
||
|
||
return out.Close()
|
||
}
|
||
|
||
// sanitizePath убирает недопустимые символы из имени папки/файла.
|
||
func sanitizePath(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
replacer := strings.NewReplacer(
|
||
"<", "", ">", "", ":", "", "\"", "",
|
||
"/", "", "\\", "", "|", "", "?", "", "*", "",
|
||
)
|
||
return replacer.Replace(s)
|
||
}
|
||
|
||
// splitClean разделяет строку по запятой и убирает пробелы.
|
||
func splitClean(s string) []string {
|
||
if s == "" {
|
||
return []string{}
|
||
}
|
||
parts := strings.Split(s, ",")
|
||
var result []string
|
||
for _, p := range parts {
|
||
p = strings.TrimSpace(p)
|
||
if p != "" {
|
||
result = append(result, p)
|
||
}
|
||
}
|
||
if len(result) == 0 {
|
||
return []string{s}
|
||
}
|
||
return result
|
||
}
|
||
|
||
// reorderAuthors применяет reorderAuthorName к каждому автору в списке.
|
||
func reorderAuthors(authors []string) []string {
|
||
result := make([]string, len(authors))
|
||
for i, a := range authors {
|
||
result[i] = reorderAuthorName(a)
|
||
}
|
||
return result
|
||
}
|