- Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы. - Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов. - Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов. - Добавить тесты для нормализации URL в репозиторий RuTracker. - Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер. - Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов.
287 lines
8.4 KiB
Go
287 lines
8.4 KiB
Go
package filesystem
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"mime"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"audio-catalyst/internal/domain/entities"
|
||
)
|
||
|
||
// Repository реализация AudioBookRepository
|
||
type Repository struct{}
|
||
|
||
// NewRepository создает новый репозиторий файловой системы
|
||
func NewRepository() *Repository {
|
||
return &Repository{}
|
||
}
|
||
|
||
// ScanDirectory сканирует директорию на наличие аудиокниг
|
||
func (r *Repository) ScanDirectory(rootDir string) ([]entities.AudioBook, error) {
|
||
var audioBooks []entities.AudioBook
|
||
|
||
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if !info.IsDir() {
|
||
return nil
|
||
}
|
||
|
||
mp3Files, err := r.findMP3Files(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
if len(mp3Files) > 0 {
|
||
book := entities.AudioBook{
|
||
Path: path,
|
||
Title: filepath.Base(path),
|
||
MP3Files: mp3Files,
|
||
CoverFile: r.findCoverFile(path),
|
||
Description: r.findDescriptionFile(path),
|
||
}
|
||
audioBooks = append(audioBooks, book)
|
||
}
|
||
|
||
return nil
|
||
})
|
||
|
||
return audioBooks, err
|
||
}
|
||
|
||
// SaveMetadata сохраняет метаданные аудиокниги
|
||
func (r *Repository) SaveMetadata(bookPath string, metadata *entities.AudioBookMetadata) error {
|
||
metadataFile := filepath.Join(bookPath, "metadata.json")
|
||
|
||
data, err := json.MarshalIndent(metadata, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("ошибка сериализации метаданных: %w", err)
|
||
}
|
||
|
||
if err := os.WriteFile(metadataFile, data, 0644); err != nil {
|
||
return fmt.Errorf("ошибка записи метаданных: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// DownloadCover загружает обложку по URL
|
||
func (r *Repository) DownloadCover(coverURL, bookPath string) error {
|
||
if coverURL == "" {
|
||
return fmt.Errorf("пустой URL обложки")
|
||
}
|
||
|
||
client := &http.Client{Timeout: 30 * time.Second}
|
||
|
||
req, err := http.NewRequest("GET", coverURL, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("ошибка создания запроса: %w", err)
|
||
}
|
||
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||
// Поставим корректный Referer, если URL — с rutracker.org
|
||
if u, err := url.Parse(coverURL); err == nil && strings.Contains(u.Host, "rutracker.org") {
|
||
req.Header.Set("Referer", "https://rutracker.org/forum/")
|
||
}
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("ошибка загрузки обложки: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("HTTP статус: %d", resp.StatusCode)
|
||
}
|
||
|
||
// Определяем расширение файла
|
||
fileExt := ".jpg"
|
||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
||
if exts, _ := mime.ExtensionsByType(ct); len(exts) > 0 {
|
||
fileExt = exts[0]
|
||
} else if strings.Contains(ct, "png") {
|
||
fileExt = ".png"
|
||
} else if strings.Contains(ct, "gif") {
|
||
fileExt = ".gif"
|
||
}
|
||
}
|
||
|
||
// Если в URL есть расширение — доверим ему
|
||
if u, err := url.Parse(coverURL); err == nil {
|
||
if ext := strings.ToLower(filepath.Ext(u.Path)); ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" {
|
||
fileExt = ext
|
||
}
|
||
}
|
||
|
||
coverFile := filepath.Join(bookPath, "cover"+fileExt)
|
||
out, err := os.Create(coverFile)
|
||
if err != nil {
|
||
return fmt.Errorf("ошибка создания файла: %w", err)
|
||
}
|
||
defer out.Close()
|
||
|
||
const maxSize = 10 * 1024 * 1024
|
||
limitedReader := io.LimitReader(resp.Body, maxSize)
|
||
|
||
if _, err = io.Copy(out, limitedReader); err != nil {
|
||
_ = os.Remove(coverFile)
|
||
return fmt.Errorf("ошибка записи файла: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// findMP3Files находит MP3 файлы в директории
|
||
func (r *Repository) findMP3Files(dir string) ([]string, error) {
|
||
var mp3Files []string
|
||
|
||
files, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
for _, file := range files {
|
||
if !file.IsDir() && strings.HasSuffix(strings.ToLower(file.Name()), ".mp3") {
|
||
mp3Files = append(mp3Files, filepath.Join(dir, file.Name()))
|
||
}
|
||
}
|
||
|
||
return mp3Files, nil
|
||
}
|
||
|
||
// findCoverFile находит файл обложки
|
||
func (r *Repository) findCoverFile(dir string) string {
|
||
coverNames := []string{"cover.jpg", "cover.png", "cover.jpeg", "folder.jpg", "folder.png"}
|
||
|
||
files, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
|
||
for _, file := range files {
|
||
if !file.IsDir() {
|
||
fileName := strings.ToLower(file.Name())
|
||
for _, coverName := range coverNames {
|
||
if fileName == coverName {
|
||
return filepath.Join(dir, file.Name())
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// findDescriptionFile находит файл описания
|
||
func (r *Repository) findDescriptionFile(dir string) string {
|
||
descNames := []string{"description.txt", "readme.txt", "info.txt"}
|
||
|
||
files, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
|
||
for _, file := range files {
|
||
if !file.IsDir() {
|
||
fileName := strings.ToLower(file.Name())
|
||
for _, descName := range descNames {
|
||
if fileName == descName {
|
||
content, err := os.ReadFile(filepath.Join(dir, file.Name()))
|
||
if err == nil {
|
||
return string(content)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// RenameBookFolder переименовывает папку аудиокниги и возвращает новый путь
|
||
func (r *Repository) RenameBookFolder(oldPath, newBaseName string) (string, error) {
|
||
if oldPath == "" || newBaseName == "" {
|
||
return oldPath, fmt.Errorf("некорректные параметры переименования")
|
||
}
|
||
parent := filepath.Dir(oldPath)
|
||
// Заменим недопустимые для Windows символы
|
||
safeName := strings.NewReplacer(
|
||
"<", "(",
|
||
">", ")",
|
||
"|", "-",
|
||
"?", "",
|
||
"*", "",
|
||
"\"", "'",
|
||
":", " -",
|
||
"/", "-",
|
||
"\\", "-",
|
||
).Replace(newBaseName)
|
||
// Убираем хвосты RuTracker.org и повторяющиеся разделители в конце
|
||
reSuffix := regexp.MustCompile(`(?i)\s*(?:\s*(?:\||::|:|—|-)\s*)+RuTracker\.?org.*$`)
|
||
safeName = reSuffix.ReplaceAllString(safeName, "")
|
||
safeName = strings.TrimSpace(safeName)
|
||
safeName = regexp.MustCompile(`\s*(?:-\s*){2,}$`).ReplaceAllString(safeName, "") // " - -" в конце
|
||
if safeName == "" {
|
||
return oldPath, fmt.Errorf("пустое имя папки")
|
||
}
|
||
newPath := filepath.Join(parent, safeName)
|
||
if strings.EqualFold(oldPath, newPath) {
|
||
return oldPath, nil
|
||
}
|
||
if _, err := os.Stat(newPath); err == nil {
|
||
return oldPath, fmt.Errorf("папка уже существует: %s", newPath)
|
||
}
|
||
if err := os.Rename(oldPath, newPath); err != nil {
|
||
return oldPath, fmt.Errorf("ошибка переименования: %w", err)
|
||
}
|
||
return newPath, nil
|
||
}
|
||
|
||
// OrganizeBookFolder перемещает папку книги в organized/<Letter>/<Author>
|
||
func (r *Repository) OrganizeBookFolder(bookPath, authorFullName, targetRoot string) (string, error) {
|
||
if bookPath == "" || targetRoot == "" {
|
||
return bookPath, fmt.Errorf("некорректные параметры перемещения")
|
||
}
|
||
// Определяем букву (первая буква фамилии/имени)
|
||
author := strings.TrimSpace(authorFullName)
|
||
if author == "" {
|
||
author = "Unknown"
|
||
}
|
||
// Приводим к формату "Фамилия Имя" (если уже так — оставляем)
|
||
author = regexp.MustCompile(`\s+`).ReplaceAllString(author, " ")
|
||
letter := strings.ToUpper(string([]rune(author)[0]))
|
||
// Безопасные имена
|
||
safe := func(s string) string {
|
||
return strings.NewReplacer(
|
||
"<", "(", ">", ")", "|", "-", "?", "", "*", "", "\"", "'", ":", " -", "/", "-", "\\", "-",
|
||
).Replace(strings.TrimSpace(s))
|
||
}
|
||
letterDir := filepath.Join(targetRoot, safe(letter))
|
||
authorDir := filepath.Join(letterDir, safe(author))
|
||
if err := os.MkdirAll(authorDir, 0755); err != nil {
|
||
return bookPath, fmt.Errorf("ошибка создания каталогов: %w", err)
|
||
}
|
||
|
||
newBookPath := filepath.Join(authorDir, filepath.Base(bookPath))
|
||
if strings.EqualFold(bookPath, newBookPath) {
|
||
return bookPath, nil
|
||
}
|
||
if _, err := os.Stat(newBookPath); err == nil {
|
||
return bookPath, fmt.Errorf("папка уже существует: %s", newBookPath)
|
||
}
|
||
if err := os.Rename(bookPath, newBookPath); err != nil {
|
||
return bookPath, fmt.Errorf("ошибка перемещения: %w", err)
|
||
}
|
||
return newBookPath, nil
|
||
}
|