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// 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 }