1
1

Добавить тесты репозитория файловой системы и реализовать функциональность журналирования файлов.

- Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы.
- Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов.
- Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов.
- Добавить тесты для нормализации URL в репозиторий RuTracker.
- Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер.
- Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов.
This commit is contained in:
Dmitriy Fofanov
2025-09-29 20:40:05 +03:00
parent 49bea780aa
commit 72a66f1664
32 changed files with 4073 additions and 22 deletions

View File

@@ -0,0 +1,286 @@
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
}