Добавить тесты репозитория файловой системы и реализовать функциональность журналирования файлов.
- Реализовать тесты для поиска MP3-файлов и переименования/организации папок книг в репозитории файловой системы. - Создать FileLogger для записи сообщений в файл с поддержкой различных уровней журналирования и управления размером файлов. - Разработать репозиторий RuTracker для обработки поиска торрентов, получения метаданных и загрузки торрент-файлов. - Добавить тесты для нормализации URL в репозиторий RuTracker. - Реализовать адаптер логгера TUI для отображения логов в терминальном интерфейсе и, при необходимости, для записи логов в базовый логгер. - Создать менеджер TUI для управления пользовательским интерфейсом приложения, включая главное меню, экран обработки, настройки и отображение результатов.
This commit is contained in:
286
internal/infrastructure/filesystem/repository.go
Normal file
286
internal/infrastructure/filesystem/repository.go
Normal 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
|
||||
}
|
||||
53
internal/infrastructure/filesystem/repository_test.go
Normal file
53
internal/infrastructure/filesystem/repository_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindMP3Files(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
files := []string{"a.mp3", "B.MP3", "c.txt"}
|
||||
for _, f := range files {
|
||||
if err := os.WriteFile(filepath.Join(d, f), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
repo := NewRepository()
|
||||
got, err := repo.findMP3Files(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("ожидалось 2, получено %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameAndOrganize(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
book := filepath.Join(d, "Old")
|
||||
if err := os.MkdirAll(book, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := NewRepository()
|
||||
newPath, err := repo.RenameBookFolder(book, "Автор - Книга :: RuTracker.org")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.Base(newPath) != "Автор - Книга" {
|
||||
t.Fatalf("имя папки: %s", filepath.Base(newPath))
|
||||
}
|
||||
|
||||
organized := filepath.Join(d, "organized")
|
||||
if err := os.MkdirAll(organized, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
moved, err := repo.OrganizeBookFolder(newPath, "Автор Фамилия", organized)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(moved); err != nil {
|
||||
t.Fatalf("папка не перемещена: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user