Функция: реализованы консольный логгер и презентер для обработки аудиокниг
- Добавлен ConsoleLogger для подробного логирования этапов обработки аудиокниг в консоли. - Введен ConsolePresenter для форматированного вывода результатов сканирования в консоль. - Создан ProcessAudioBooksUseCase для обработки полного конвейера обработки аудиокниг, включая сканирование папок, извлечение метаданных, поиск торрентов и запись результатов. - Реализована проверка LLM для улучшения метаданных. - Добавлена обработка ошибок и логирование на всех этапах обработки.
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user