// Package infrastructure реализует порт ResultWriter — // создание структуры папок, metadata.json и перенос аудиофайлов. package infrastructure import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "unicode/utf8" "github.com/fofanov/genaudiobookinfo/internal/domain" "github.com/tcolgate/mp3" ) // FSResultWriter реализует domain.ResultWriter. type FSResultWriter struct{} // NewFSResultWriter создаёт новый экземпляр. func NewFSResultWriter() *FSResultWriter { return &FSResultWriter{} } // WriteResult создаёт: result/<Буква>/<Автор>/<Автор - Альбом [Год]>/metadata.json // и переносит аудиофайлы из исходной папки. func (w *FSResultWriter) WriteResult(ctx context.Context, book *domain.EnrichedBookInfo, resultRoot string) (string, error) { select { case <-ctx.Done(): return "", ctx.Err() default: } info := book.AudioBook detail := book.Detail author := sanitizePath(info.Author) album := sanitizePath(info.Album) if album == "" { album = sanitizePath(info.Title) } // --- Формируем metadata.json (до создания папки) --- metadata, err := buildMetadata(ctx, info, detail) if err != nil { return "", fmt.Errorf("ошибка формирования metadata: %w", err) } metaBytes, err := json.MarshalIndent(metadata, "", " ") if err != nil { return "", fmt.Errorf("ошибка сериализации metadata.json: %w", err) } // Для имени папки используем уже нормализованные поля metadata authorForFolder := author if len(metadata.Authors) > 0 { metaAuthor := strings.TrimSpace(metadata.Authors[0]) if metaAuthor != "" { authorForFolder = sanitizePath(metaAuthor) } } if authorForFolder == "" { authorForFolder = "Unknown" } titleForFolder := album if strings.TrimSpace(metadata.Title) != "" { titleForFolder = sanitizePath(metadata.Title) } if titleForFolder == "" { titleForFolder = "Unknown" } // --- Fallback: если автор/название — кракозябры, используем данные трекера --- if detail != nil && detail.Name != "" { if !containsCyrillic(authorForFolder) || isMojibake(authorForFolder) { if detailAuthor := extractAuthorFromDetailName(detail.Name); detailAuthor != "" && containsCyrillic(detailAuthor) { authorForFolder = sanitizePath(detailAuthor) } } if !containsCyrillic(titleForFolder) || isMojibake(titleForFolder) { if detailTitle := extractTitleFromDetailName(detail.Name); detailTitle != "" && containsCyrillic(detailTitle) { titleForFolder = sanitizePath(detailTitle) } } } // Первая буква фамилии автора (заглавная) firstLetter := "0" if authorForFolder != "" { r, _ := utf8.DecodeRuneInString(authorForFolder) firstLetter = strings.ToUpper(string(r)) } // Имя папки книги: Автор - Альбом [Год] bookFolderName := fmt.Sprintf("%s - %s", authorForFolder, titleForFolder) if metadata.PublishedYear != "" { bookFolderName = fmt.Sprintf("%s [%s]", bookFolderName, metadata.PublishedYear) } authorFolder := sanitizePath(authorForFolder) if authorFolder == "" { authorFolder = "Unknown" } destDir := filepath.Join(resultRoot, firstLetter, authorFolder, sanitizePath(bookFolderName)) // --- Создаём целевой каталог только после успешной подготовки данных --- if err := os.MkdirAll(destDir, 0755); err != nil { return "", fmt.Errorf("не удалось создать каталог %q: %w", destDir, err) } metaPath := filepath.Join(destDir, "metadata.json") if err := os.WriteFile(metaPath, metaBytes, 0644); err != nil { return "", fmt.Errorf("ошибка записи metadata.json: %w", err) } // --- Переносим аудиофайлы --- if err := moveAudioFiles(ctx, info.FolderPath, destDir); err != nil { return destDir, fmt.Errorf("ошибка переноса аудиофайлов: %w", err) } // --- Удаляем исходную папку после успешного переноса --- if err := os.RemoveAll(info.FolderPath); err != nil { // Не критично, но логируем return destDir, fmt.Errorf("файлы перенесены, но не удалось удалить исходную папку %q: %w", info.FolderPath, err) } return destDir, nil } // buildMetadata формирует структуру BookMetadata из локальных тегов и данных трекера. func buildMetadata(ctx context.Context, info *domain.AudioBookInfo, detail *domain.TorrentDetail) (domain.BookMetadata, error) { chapters, chaptersErr := buildChaptersFromMP3(ctx, info.FolderPath) if chaptersErr != nil { return domain.BookMetadata{}, chaptersErr } publishedYear := "" if info.Year > 0 { publishedYear = strconv.Itoa(info.Year) } meta := domain.BookMetadata{ Tags: []string{}, Chapters: chapters, Title: coalesce(info.Album, info.Title), Subtitle: "", Authors: splitClean(info.Author), Narrators: []string{}, Series: []string{}, Genres: splitClean(info.Genre), PublishedYear: publishedYear, PublishedDate: "", Publisher: "", Description: info.Comment, Language: "ru", ISBN: "", ASIN: "", Explicit: false, Abridged: false, } if detail != nil { meta.Subtitle = detail.Name if titleFromDetail := extractTitleFromDetailName(detail.Name); titleFromDetail != "" { meta.Title = titleFromDetail } if detail.Description != "" { meta.Description = detail.Description } if detail.Type != "" { meta.Genres = splitClean(detail.Type) } if detail.Year != "" { meta.PublishedYear = detail.Year } meta.Narrators = extractNarrators(detail) meta.Series = extractSeries(detail.Name) } return meta, nil } // extractAuthorFromDetailName извлекает автора из detail.Name. // Поддерживает формат: "Автор - Название [Год]" → "Автор". func extractAuthorFromDetailName(name string) string { name = strings.TrimSpace(name) if name == "" { return "" } if idx := strings.Index(name, " - "); idx > 0 { return strings.TrimSpace(name[:idx]) } return "" } // extractTitleFromDetailName извлекает название книги из detail.Name. // Поддерживает кейс: "Автор - Серия. Книга N [...]" -> "Серия". func extractTitleFromDetailName(name string) string { name = strings.TrimSpace(name) if name == "" { return "" } if idx := strings.Index(name, " - "); idx >= 0 { name = strings.TrimSpace(name[idx+3:]) } if idx := strings.Index(name, "["); idx >= 0 { name = strings.TrimSpace(name[:idx]) } name = strings.TrimSpace(name) if name == "" { return "" } // Кейс: "Исекай взрослого человека 03, Вы призвали... Кого надо! #3" // -> "Исекай взрослого человека" // Кейс: "Я попал 02, Горячее лето 42-го" -> "Горячее лето 42-го" if idx := strings.Index(name, ","); idx > 0 { firstPart := strings.TrimSpace(name[:idx]) secondPart := strings.TrimSpace(name[idx+1:]) reNumAtEnd := regexp.MustCompile(`^(.+?)\s+\d+$`) // Если вторая часть имеет суффикс #N — это серия, // а название книги берём из первой части без номера. reSeriesNumSuffix := regexp.MustCompile(`^(.+?)\s*#\s*\d+\s*$`) if m := reSeriesNumSuffix.FindStringSubmatch(secondPart); len(m) == 2 { if mm := reNumAtEnd.FindStringSubmatch(firstPart); len(mm) == 2 { title := strings.TrimSpace(mm[1]) if title != "" { return title } } } // Если первая часть заканчивается номером, а вторая часть обычная, // то вторая часть — название книги. if reNumAtEnd.MatchString(firstPart) && secondPart != "" { return secondPart } if m := reNumAtEnd.FindStringSubmatch(firstPart); len(m) == 2 { title := strings.TrimSpace(m[1]) if title != "" { return title } } } // Кейс: "Клан для Антиквара. Книга 1" -> "Клан для Антиквара" reSeriesBook := regexp.MustCompile(`(?i)^(.+?)\.\s*(?:книга\s*\d+|\d+\s*книга)\s*$`) if m := reSeriesBook.FindStringSubmatch(name); len(m) == 2 { title := strings.TrimSpace(m[1]) if title != "" { return title } } return "" } func buildChaptersFromMP3(ctx context.Context, srcDir string) ([]domain.ChapterInfo, error) { entries, err := os.ReadDir(srcDir) if err != nil { return nil, fmt.Errorf("ошибка чтения каталога для chapters: %w", err) } var mp3Files []string for _, entry := range entries { if entry.IsDir() || !isMP3File(entry.Name()) { continue } mp3Files = append(mp3Files, entry.Name()) } sort.Strings(mp3Files) chapters := make([]domain.ChapterInfo, 0, len(mp3Files)) cursor := 0.0 for i, fileName := range mp3Files { select { case <-ctx.Done(): return nil, ctx.Err() default: } fullPath := filepath.Join(srcDir, fileName) durationSeconds, err := readMP3DurationSeconds(fullPath) if err != nil { return nil, fmt.Errorf("ошибка чтения длительности %q: %w", fullPath, err) } start := cursor end := cursor + durationSeconds cursor = end chapters = append(chapters, domain.ChapterInfo{ ID: i, Start: start, End: end, Title: fileName, }) } return chapters, nil } func readMP3DurationSeconds(filePath string) (float64, error) { f, err := os.Open(filePath) if err != nil { return 0, err } defer f.Close() decoder := mp3.NewDecoder(f) var frame mp3.Frame skipped := 0 total := time.Duration(0) for { err = decoder.Decode(&frame, &skipped) if err == io.EOF { break } if errors.Is(err, io.ErrUnexpectedEOF) { break } if err != nil { return 0, err } total += frame.Duration() } return total.Seconds(), nil } func extractNarrators(detail *domain.TorrentDetail) []string { result := make([]string, 0, 2) seen := map[string]bool{} if detail == nil { return result } name := strings.TrimSpace(detail.Name) if name != "" { if narrator := narratorFromNameBracket(name); narrator != "" { if !seen[narrator] { result = append(result, narrator) seen[narrator] = true } } } languageField := strings.TrimSpace(detail.Language) if languageField != "" { if !seen[languageField] { result = append(result, languageField) seen[languageField] = true } } return result } func narratorFromNameBracket(name string) string { open := strings.LastIndex(name, "[") close := strings.LastIndex(name, "]") if open < 0 || close <= open { return "" } inside := strings.TrimSpace(name[open+1 : close]) if inside == "" { return "" } parts := strings.Split(inside, ",") if len(parts) == 0 { return "" } first := strings.TrimSpace(parts[0]) if first == "" { return "" } // Отсекаем очевидно не имя чтеца lower := strings.ToLower(first) if strings.Contains(lower, "kbps") || strings.Contains(lower, "mp3") || strings.Contains(lower, "год") { return "" } return first } func extractSeries(name string) []string { name = strings.TrimSpace(name) if name == "" { return []string{} } // Берём часть после автора: "Автор - Серия 7, Альбом [...]" if idx := strings.Index(name, " - "); idx >= 0 { name = strings.TrimSpace(name[idx+3:]) } if idx := strings.Index(name, "["); idx >= 0 { name = strings.TrimSpace(name[:idx]) } name = strings.TrimSpace(name) if name == "" { return []string{} } // Кейс: "Исекай взрослого человека 03, Вы призвали... Кого надо! #3" // -> series: "Вы призвали... Кого надо!" if idx := strings.Index(name, ","); idx > 0 { secondPart := strings.TrimSpace(name[idx+1:]) reSeriesNumSuffix := regexp.MustCompile(`^(.+?)\s*#\s*\d+\s*$`) if m := reSeriesNumSuffix.FindStringSubmatch(secondPart); len(m) == 2 { series := strings.TrimSpace(m[1]) if series != "" { return []string{series} } } } firstPart := name if idx := strings.Index(firstPart, ","); idx >= 0 { firstPart = strings.TrimSpace(firstPart[:idx]) } // Кейс: "Клан для Антиквара. Книга 1" -> "Клан для Антиквара" reBookSuffix := regexp.MustCompile(`(?i)^(.+?)\s*(?:\(\s*\d+\s*книга\s*\)|[\.,]?\s*книга\s*\d+|[\.,]?\s*\d+\s*книга)\s*$`) if m := reBookSuffix.FindStringSubmatch(firstPart); len(m) == 2 { series := strings.TrimSpace(m[1]) series = strings.TrimRight(series, ".,:- ") if idx := strings.Index(series, ":"); idx >= 0 { series = strings.TrimSpace(series[:idx]) } if series != "" { return []string{series} } } // Кейс: "Первый рыцарь 7, Первый альянс" -> "Первый рыцарь" reNumAtEnd := regexp.MustCompile(`^(.+?)\s+\d+$`) if m := reNumAtEnd.FindStringSubmatch(firstPart); len(m) == 2 { series := strings.TrimSpace(m[1]) if series != "" { return []string{series} } } // Кейс: "Звёздные беглецы. Миссия Маяк" -> "Звёздные беглецы" // (серия перед первой точкой, после точки — название конкретной книги) if dot := strings.Index(name, "."); dot > 0 { series := strings.TrimSpace(name[:dot]) bookTitle := strings.TrimSpace(name[dot+1:]) if series != "" && bookTitle != "" { return []string{series} } } return []string{} } // moveAudioFiles переносит аудиофайлы из srcDir в destDir. func moveAudioFiles(ctx context.Context, srcDir, destDir string) error { entries, err := os.ReadDir(srcDir) if err != nil { return err } // Сортируем для предсказуемого порядка sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) for _, entry := range entries { select { case <-ctx.Done(): return ctx.Err() default: } if entry.IsDir() || !isAudioFile(entry.Name()) { continue } srcPath := filepath.Join(srcDir, entry.Name()) destPath := filepath.Join(destDir, entry.Name()) // Пробуем os.Rename (быстрый перенос на том же диске) if err := os.Rename(srcPath, destPath); err != nil { // Если разные диски — копируем + удаляем if copyErr := copyFile(srcPath, destPath); copyErr != nil { return fmt.Errorf("не удалось перенести %q: %w", entry.Name(), copyErr) } os.Remove(srcPath) } } return nil } // copyFile побайтово копирует файл. func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() if _, err := io.Copy(out, in); err != nil { return err } return out.Close() } // sanitizePath убирает недопустимые символы из имени папки/файла. func sanitizePath(s string) string { s = strings.TrimSpace(s) replacer := strings.NewReplacer( "<", "", ">", "", ":", "", "\"", "", "/", "", "\\", "", "|", "", "?", "", "*", "", ) return replacer.Replace(s) } // splitClean разделяет строку по запятой и убирает пробелы. func splitClean(s string) []string { if s == "" { return []string{} } parts := strings.Split(s, ",") var result []string for _, p := range parts { p = strings.TrimSpace(p) if p != "" { result = append(result, p) } } if len(result) == 0 { return []string{s} } return result } // reorderAuthors применяет reorderAuthorName к каждому автору в списке. func reorderAuthors(authors []string) []string { result := make([]string, len(authors)) for i, a := range authors { result[i] = reorderAuthorName(a) } return result }