- Добавлен ConsoleLogger для подробного логирования этапов обработки аудиокниг в консоли. - Введен ConsolePresenter для форматированного вывода результатов сканирования в консоль. - Создан ProcessAudioBooksUseCase для обработки полного конвейера обработки аудиокниг, включая сканирование папок, извлечение метаданных, поиск торрентов и запись результатов. - Реализована проверка LLM для улучшения метаданных. - Добавлена обработка ошибок и логирование на всех этапах обработки.
188 lines
5.8 KiB
Go
188 lines
5.8 KiB
Go
// 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
|
||
}
|