Files
GenAudioBookInfo/internal/infrastructure/metadata_extractor.go
Dmitriy Fofanov 41fb62f62e Добавлены страницы вики для GenAudioBookInfo: Home, Installation, Makefile, OpenRouter, Output Structure, TorrAPI и Sidebar.
Создана структура документации, описывающая функциональность, установку, использование CLI, архитектуру и интеграции с TorrAPI и OpenRouter.
Добавлены примеры конфигурации и метаданных, а также описание структуры выходных данных.
2026-02-23 13:19:39 +03:00

181 lines
5.6 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"
"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
}