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

398 lines
12 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 реализует порт ResultWriter —
// создание структуры папок, metadata.json и перенос аудиофайлов.
package infrastructure
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/fofanov/genaudiobookinfo/internal/domain"
"github.com/fofanov/genaudiobookinfo/internal/nameparser"
"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"
}
// Для папки предпочитаем полный заголовок из detail (серия + название без года).
// metadata.Title может содержать только подзаголовок (после ":"), тогда как
// в папке нужно полное «Серия: Подзаголовок».
if detail != nil && detail.Name != "" {
if fullTitle := nameparser.ExtractFullTitleFromDetailName(detail.Name); fullTitle != "" && containsCyrillic(fullTitle) {
titleForFolder = sanitizePath(fullTitle)
}
}
// --- Fallback: если автор/название — кракозябры, используем данные трекера ---
if detail != nil && detail.Name != "" {
if !containsCyrillic(authorForFolder) || isMojibake(authorForFolder) {
if detailAuthor := nameparser.ExtractAuthorFromDetailName(detail.Name); detailAuthor != "" && containsCyrillic(detailAuthor) {
authorForFolder = sanitizePath(detailAuthor)
}
}
if !containsCyrillic(titleForFolder) || isMojibake(titleForFolder) {
if detailTitle := nameparser.ExtractTitleFromDetailName(detail.Name); detailTitle != "" && containsCyrillic(detailTitle) {
titleForFolder = sanitizePath(detailTitle)
}
}
}
// Первая буква фамилии автора (заглавная)
firstLetter := "0"
if authorForFolder != "" {
r, _ := utf8.DecodeRuneInString(authorForFolder)
firstLetter = strings.ToUpper(string(r))
}
// Имя папки книги: если есть detail — используем нормализованное название раздачи целиком
// (сохраняет оригинальную структуру, включая скобки [...])
var bookFolderName string
if detail != nil && detail.Name != "" {
if normalized := nameparser.NormalizeDetailNameWithAuthorReorder(detail.Name); normalized != "" && containsCyrillic(normalized) {
// Заменяем разделители до sanitizePath (который стрипает '/' и ':')
normalized = strings.ReplaceAll(normalized, " // ", ". ")
normalized = strings.ReplaceAll(normalized, ": ", ". ")
bookFolderName = sanitizePath(normalized)
}
}
if bookFolderName == "" {
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))
// --- Проверяем дубликат: целевая папка уже существует ---
// Если да — переносим в DUPLICATE/ с той же структурой подпапок.
// Повторный дубликат получает числовой суффикс (_2, _3, …).
if _, err := os.Stat(destDir); err == nil {
baseBookFolder := sanitizePath(bookFolderName)
dupBase := filepath.Join(resultRoot, "DUPLICATE", firstLetter, authorFolder, baseBookFolder)
dupDir := dupBase
for suffix := 2; ; suffix++ {
if _, statErr := os.Stat(dupDir); os.IsNotExist(statErr) {
break
}
dupDir = fmt.Sprintf("%s_%d", dupBase, suffix)
}
destDir = dupDir
}
// --- Создаём целевой каталог только после успешной подготовки данных ---
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 = nameparser.NormalizeDetailNameWithAuthorReorder(detail.Name)
if meta.Subtitle == "" {
meta.Subtitle = detail.Name
}
if titleFromDetail := nameparser.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
} else if extractedYear := nameparser.ExtractYearFromDetailName(detail.Name); extractedYear > 0 {
meta.PublishedYear = strconv.Itoa(extractedYear)
}
meta.Narrators = nameparser.ExtractNarrators(detail)
meta.Series = nameparser.ExtractSeries(detail.Name)
}
return meta, nil
}
// buildChaptersFromMP3 строит список глав из MP3-файлов в папке.
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
}
// 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
}