Создана структура документации, описывающая функциональность, установку, использование CLI, архитектуру и интеграции с TorrAPI и OpenRouter. Добавлены примеры конфигурации и метаданных, а также описание структуры выходных данных.
398 lines
12 KiB
Go
398 lines
12 KiB
Go
// 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
|
||
}
|