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

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

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

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

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

188 lines
5.8 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 реализует порт 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
}