Функция: реализованы консольный логгер и презентер для обработки аудиокниг

- Добавлен ConsoleLogger для подробного логирования этапов обработки аудиокниг в консоли.

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

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

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

- Добавлена ​​обработка ошибок и логирование на всех этапах обработки.
This commit is contained in:
Dmitriy Fofanov
2026-02-20 00:35:43 +03:00
parent 7d119927a1
commit 402ce7f4f1
26 changed files with 4323 additions and 0 deletions
@@ -0,0 +1,187 @@
// Package infrastructure реализует порт MetadataExtractor —
// извлечение метаданных из аудиофайлов с помощью библиотеки dhowden/tag.
package infrastructure
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"unicode"
"unicode/utf8"
"github.com/dhowden/tag"
"github.com/fofanov/genaudiobookinfo/internal/domain"
)
// TagMetadataExtractor реализует domain.MetadataExtractor.
type TagMetadataExtractor struct{}
// NewTagMetadataExtractor создаёт новый экземпляр.
func NewTagMetadataExtractor() *TagMetadataExtractor {
return &TagMetadataExtractor{}
}
// Extract находит первый аудиофайл в папке (по алфавиту) и извлекает метаданные.
func (e *TagMetadataExtractor) Extract(ctx context.Context, folderPath string) (*domain.AudioBookInfo, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
entries, err := os.ReadDir(folderPath)
if err != nil {
return nil, fmt.Errorf("ошибка чтения каталога %q: %w", folderPath, err)
}
// Собираем аудиофайлы и сортируем по имени
var audioFiles []string
for _, entry := range entries {
if !entry.IsDir() && isAudioFile(entry.Name()) {
audioFiles = append(audioFiles, entry.Name())
}
}
if len(audioFiles) == 0 {
return nil, fmt.Errorf("в каталоге %q не найдено аудиофайлов", folderPath)
}
sort.Strings(audioFiles)
firstFile := audioFiles[0]
fullPath := filepath.Join(folderPath, firstFile)
// Открываем первый файл для чтения метаданных
f, err := os.Open(fullPath)
if err != nil {
return nil, fmt.Errorf("не удалось открыть файл %q: %w", fullPath, err)
}
defer f.Close()
metadata, err := tag.ReadFrom(f)
if err != nil {
// Файл не содержит тегов — возвращаем базовую информацию
return &domain.AudioBookInfo{
FolderPath: folderPath,
Title: filepath.Base(folderPath),
Format: strings.TrimPrefix(filepath.Ext(firstFile), "."),
SourceFile: firstFile,
FilesCount: len(audioFiles),
}, nil
}
// Исправляем mojibake: UTF-8 байты, прочитанные как Latin-1
artist := fixMojibake(metadata.Artist())
album := fixMojibake(metadata.Album())
title := fixMojibake(metadata.Title())
genre := fixMojibake(metadata.Genre())
comment := fixMojibake(metadata.Comment())
info := &domain.AudioBookInfo{
FolderPath: folderPath,
Title: titleFromTag(title, filepath.Base(folderPath)),
Author: reorderAuthorName(artist),
Album: album,
Genre: genre,
Year: metadata.Year(),
Comment: comment,
Format: string(metadata.Format()),
SourceFile: firstFile,
CoverFound: metadata.Picture() != nil,
FilesCount: len(audioFiles),
}
return info, nil
}
// coalesce возвращает первую непустую строку.
func coalesce(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
// coalesceInt возвращает первое ненулевое число.
func coalesceInt(values ...int) int {
for _, v := range values {
if v != 0 {
return v
}
}
return 0
}
// reorderAuthorName удаляет отчество (если есть) и переставляет:
// "Имя Отчество Фамилия" → "Фамилия Имя", "Имя Фамилия" → "Фамилия Имя".
func reorderAuthorName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return name
}
parts := strings.Fields(name)
if len(parts) >= 3 {
parts = []string{parts[0], parts[len(parts)-1]}
}
if len(parts) == 2 {
return parts[1] + " " + parts[0]
}
return name
}
// titleFromTag возвращает title из MP3 тега, если он содержит кириллицу.
// Если title из тега пуст или не содержит кириллических символов —
// используется имя папки как fallback.
func titleFromTag(tagTitle, folderName string) string {
tagTitle = strings.TrimSpace(tagTitle)
if tagTitle != "" && containsCyrillic(tagTitle) {
return tagTitle
}
return folderName
}
// containsCyrillic проверяет, содержит ли строка хотя бы один кириллический символ.
func containsCyrillic(s string) bool {
for _, r := range s {
if unicode.Is(unicode.Cyrillic, r) {
return true
}
}
return false
}
// isMojibake проверяет, является ли строка результатом неправильной интерпретации
// UTF-8 байтов как Latin-1 (типичная проблема с ID3-тегами кириллических аудиокниг).
// Пример: "Полина Линк" → "Ðолина Ðинк".
func isMojibake(s string) bool {
if s == "" || containsCyrillic(s) {
return false
}
fixed := fixMojibake(s)
return fixed != s
}
// fixMojibake пытается исправить кодировку: интерпретирует символы строки
// (каждый как байт Latin-1) и декодирует результат как UTF-8.
// Если получается валидная кириллица — возвращает исправленный текст.
func fixMojibake(s string) string {
if s == "" || containsCyrillic(s) {
return s
}
raw := make([]byte, 0, len(s))
for _, r := range s {
if r > 255 {
return s // символ за пределами Latin-1 — не mojibake
}
raw = append(raw, byte(r))
}
result := string(raw)
if utf8.ValidString(result) && containsCyrillic(result) {
return result
}
return s
}