Files
GenAudioBookInfo/internal/infrastructure/result_writer.go
Dmitriy Fofanov 402ce7f4f1 Функция: реализованы консольный логгер и презентер для обработки аудиокниг
- Добавлен ConsoleLogger для подробного логирования этапов обработки аудиокниг в консоли.

- Введен ConsolePresenter для форматированного вывода результатов сканирования в консоль.

- Создан ProcessAudioBooksUseCase для обработки полного конвейера обработки аудиокниг, включая сканирование папок, извлечение метаданных, поиск торрентов и запись результатов.

- Реализована проверка LLM для улучшения метаданных.

- Добавлена ​​обработка ошибок и логирование на всех этапах обработки.
2026-02-20 00:35:43 +03:00

571 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}