package services import ( "context" "fmt" "io" "os" "path/filepath" "regexp" "strings" "github.com/fujin/music-agregator/internal/database" "github.com/google/uuid" "github.com/rs/zerolog/log" ) type ImportResult struct { QueueID string `json:"queue_id"` ArtistName string `json:"artist_name"` AlbumTitle string `json:"album_title"` TargetPath string `json:"target_path"` FilesCopied int `json:"files_copied"` TotalSize int64 `json:"total_size"` Files []string `json:"files"` } func ImportCompletedDownload( ctx context.Context, queueID uuid.UUID, basePath string, db *database.DB, torrentService *TorrentService, ) (*ImportResult, error) { log.Info().Str("queue_id", queueID.String()).Str("base_path", basePath).Msg("[IMPORT] starting import") item, err := db.GetDownloadQueueItem(ctx, queueID) if err != nil { log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[IMPORT] queue item not found") return nil, fmt.Errorf("queue item not found: %w", err) } log.Info().Str("title", item.Title).Str("status", item.Status).Msg("[IMPORT] found queue item") if item.Status != "completed" && item.Status != "seeding" { log.Error().Str("status", item.Status).Msg("[IMPORT] download not completed") return nil, fmt.Errorf("download not completed, status: %s", item.Status) } if item.TorrentHash == nil { log.Error().Msg("[IMPORT] no torrent hash for queue item") return nil, fmt.Errorf("no torrent hash for queue item") } log.Info().Str("hash", *item.TorrentHash).Msg("[IMPORT] fetching torrent info") torrent, err := torrentService.GetTorrent(ctx, *item.TorrentHash) if err != nil { log.Error().Err(err).Str("hash", *item.TorrentHash).Msg("[IMPORT] torrent not found") return nil, fmt.Errorf("torrent not found: %w", err) } log.Info().Str("name", torrent.Name).Str("save_path", torrent.SavePath).Msg("[IMPORT] torrent info retrieved") var artistName, albumTitle string if item.AlbumID != nil { album, err := db.GetAlbumDetailByID(ctx, *item.AlbumID) if err == nil { artistName = album.ArtistName albumTitle = album.Title log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] resolved from database") } } if artistName == "" || albumTitle == "" { parts := strings.SplitN(item.Title, " - ", 2) if len(parts) == 2 { artistName = parts[0] albumTitle = parts[1] } else { artistName = "Unknown Artist" albumTitle = item.Title } log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] parsed from title") } artistName = sanitizePath(artistName) albumTitle = sanitizePath(albumTitle) targetDir := filepath.Join(basePath, artistName, albumTitle) log.Info().Str("target_dir", targetDir).Msg("[IMPORT] creating target directory") if err := os.MkdirAll(targetDir, 0755); err != nil { log.Error().Err(err).Str("target_dir", targetDir).Msg("[IMPORT] failed to create target directory") return nil, fmt.Errorf("failed to create target directory: %w", err) } sourcePath := filepath.Join(torrent.SavePath, torrent.Name) log.Info().Str("source_path", sourcePath).Msg("[IMPORT] checking source path") var filesCopied int var totalSize int64 var copiedFiles []string sourceInfo, err := os.Stat(sourcePath) if err != nil { log.Error().Err(err).Str("source_path", sourcePath).Msg("[IMPORT] source path not found") return nil, fmt.Errorf("source path not found: %w", err) } if sourceInfo.IsDir() { log.Info().Str("source_path", sourcePath).Msg("[IMPORT] source is directory, walking files") err = filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { return nil } if !isAudioFile(info.Name()) { log.Debug().Str("file", info.Name()).Msg("[IMPORT] skipping non-audio file") return nil } relPath, _ := filepath.Rel(sourcePath, path) targetPath := filepath.Join(targetDir, relPath) if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { return err } log.Info().Str("src", path).Str("dst", targetPath).Msg("[IMPORT] copying file") if err := copyFile(path, targetPath); err != nil { log.Warn().Err(err).Str("file", path).Msg("[IMPORT] failed to copy file") return nil } filesCopied++ totalSize += info.Size() copiedFiles = append(copiedFiles, relPath) return nil }) if err != nil { log.Error().Err(err).Msg("[IMPORT] failed to copy files") return nil, fmt.Errorf("failed to copy files: %w", err) } } else { if isAudioFile(sourceInfo.Name()) { targetPath := filepath.Join(targetDir, sourceInfo.Name()) log.Info().Str("src", sourcePath).Str("dst", targetPath).Msg("[IMPORT] copying single file") if err := copyFile(sourcePath, targetPath); err != nil { log.Error().Err(err).Msg("[IMPORT] failed to copy file") return nil, fmt.Errorf("failed to copy file: %w", err) } filesCopied = 1 totalSize = sourceInfo.Size() copiedFiles = append(copiedFiles, sourceInfo.Name()) } } log.Info().Int("files_copied", filesCopied).Int64("total_size", totalSize).Msg("[IMPORT] file copy completed") log.Info().Msg("[IMPORT] updating queue status to imported") if err := db.UpdateDownloadQueueStatus(ctx, queueID, "imported", nil); err != nil { log.Warn().Err(err).Msg("[IMPORT] failed to update queue status to imported") } if item.AlbumID != nil { log.Info().Msg("[IMPORT] removing from wanted albums") db.RemoveFromWantedAlbums(ctx, *item.AlbumID) } log.Info(). Str("artist", artistName). Str("album", albumTitle). Str("target_path", targetDir). Int("files_copied", filesCopied). Msg("[IMPORT] import completed successfully") return &ImportResult{ QueueID: queueID.String(), ArtistName: artistName, AlbumTitle: albumTitle, TargetPath: targetDir, FilesCopied: filesCopied, TotalSize: totalSize, Files: copiedFiles, }, nil } var pathSanitizeRegex = regexp.MustCompile(`[<>:"/\\|?*]`) func sanitizePath(s string) string { s = pathSanitizeRegex.ReplaceAllString(s, "_") s = strings.TrimSpace(s) if s == "" { s = "Unknown" } return s } func isAudioFile(name string) bool { ext := strings.ToLower(filepath.Ext(name)) audioExts := map[string]bool{ ".flac": true, ".mp3": true, ".m4a": true, ".aac": true, ".ogg": true, ".opus": true, ".wav": true, ".wma": true, ".alac": true, } return audioExts[ext] } func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() if _, err := io.Copy(destFile, sourceFile); err != nil { return err } return destFile.Sync() }