Создана структура документации, описывающая функциональность, установку, использование CLI, архитектуру и интеграции с TorrAPI и OpenRouter. Добавлены примеры конфигурации и метаданных, а также описание структуры выходных данных.
181 lines
5.6 KiB
Go
181 lines
5.6 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"
|
||
"github.com/fofanov/genaudiobookinfo/internal/nameparser"
|
||
)
|
||
|
||
// 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: nameparser.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
|
||
}
|
||
|
||
// titleFromTag возвращает title из MP3 тега, если он содержит хотя бы один буквенный символ.
|
||
// Если title из тега пуст — используется имя папки как fallback.
|
||
func titleFromTag(tagTitle, folderName string) string {
|
||
tagTitle = strings.TrimSpace(tagTitle)
|
||
if tagTitle != "" && hasPrintableText(tagTitle) {
|
||
return tagTitle
|
||
}
|
||
return folderName
|
||
}
|
||
|
||
// hasPrintableText проверяет, содержит ли строка хотя бы одну букву или цифру.
|
||
func hasPrintableText(s string) bool {
|
||
for _, r := range s {
|
||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 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
|
||
}
|