// 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 }