package service import ( "context" "encoding/json" "fmt" "io/fs" "log/slog" "os" "path/filepath" "strings" "time" "github.com/TopherMayor/unified-media-manager/internal/db" "github.com/TopherMayor/unified-media-manager/internal/download" ) type ImportResult struct { MediaID int64 `json:"media_id"` MediaType string `json:"media_type"` SourcePath string `json:"source_path"` DestPath string `json:"dest_path"` FileSize int64 `json:"file_size"` Quality string `json:"quality"` Status string `json:"status"` } type ImportReport struct { Imported int `json:"imported"` Skipped int `json:"skipped"` Errors int `json:"errors"` Results []ImportResult `json:"results"` } type ImportService struct { db *db.DB downloadClientSvc *DownloadClientService namingSvc *NamingService matcherSvc *MatcherService mediaSvc *MediaService parser *ReleaseParser downloadDir string subtitleSvc *SubtitleService activitySvc *ActivityService } func NewImportService(database *db.DB, dcSvc *DownloadClientService, nSvc *NamingService, mSvc *MatcherService, mediaSvc *MediaService, downloadDir string, subtitleSvc *SubtitleService, activitySvc *ActivityService) *ImportService { return &ImportService{ db: database, downloadClientSvc: dcSvc, namingSvc: nSvc, matcherSvc: mSvc, mediaSvc: mediaSvc, parser: NewReleaseParser(), downloadDir: downloadDir, subtitleSvc: subtitleSvc, activitySvc: activitySvc, } } var mediaExts = map[string]bool{ ".mkv": true, ".mp4": true, ".avi": true, ".wmv": true, ".flv": true, ".webm": true, ".mp3": true, ".flac": true, ".m4a": true, ".m4b": true, ".ogg": true, ".opus": true, ".epub": true, ".pdf": true, ".mobi": true, ".azw3": true, } func (s *ImportService) ProcessCompleted(ctx context.Context) (*ImportReport, error) { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() report := &ImportReport{} nzbClients, err := s.downloadClientSvc.GetAllEnabled(ctx, "nzb") if err != nil { slog.Error("failed to get nzb clients", "error", err) } torrentClients, err := s.downloadClientSvc.GetAllEnabled(ctx, "torrent") if err != nil { slog.Error("failed to get torrent clients", "error", err) } allClients := append(nzbClients, torrentClients...) for _, client := range allClients { completed, err := client.Client.GetCompleted(ctx) if err != nil { slog.Error("failed to get completed downloads", "error", err, "client", client.Config.Name) continue } for _, dl := range completed { s.processDownload(ctx, dl, client, report) } } return report, nil } func (s *ImportService) processDownload(ctx context.Context, dl download.CompletedDownload, client DownloadClientWithInfo, report *ImportReport) { var exists bool err := s.db.Pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM media_files WHERE original_path = $1 AND deleted_at IS NULL)", dl.OutputPath).Scan(&exists) if err != nil { slog.Error("failed to check existing import", "error", err, "path", dl.OutputPath) report.Errors++ return } if exists { report.Skipped++ return } files, err := s.findMediaFiles(dl.Name) if err != nil { slog.Error("failed to find media files", "error", err, "download", dl.Name) report.Errors++ return } if len(files) == 0 { slog.Warn("no media files found for download", "download", dl.Name) report.Skipped++ return } for _, filePath := range files { s.processFile(ctx, filePath, dl, client, report) } } func (s *ImportService) processFile(ctx context.Context, sourcePath string, dl download.CompletedDownload, client DownloadClientWithInfo, report *ImportReport) { fileCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() releaseInfo := s.parser.Parse(filepath.Base(sourcePath)) mediaType := "movie" if _, _, hasSE := parseSeasonEpisode(filepath.Base(sourcePath)); hasSE { mediaType = "series" } match, err := s.matcherSvc.Match(fileCtx, dl.Name, mediaType) if err != nil { slog.Error("failed to match release to media", "error", err, "release", dl.Name) report.Errors++ return } if match.Confidence == "none" { slog.Warn("no media match for release", "release", dl.Name, "path", sourcePath) report.Skipped++ return } result, err := s.importFile(fileCtx, sourcePath, match, releaseInfo, dl, client) if err != nil { slog.Error("failed to import file", "error", err, "source", sourcePath) report.Errors++ return } report.Imported++ report.Results = append(report.Results, *result) } func (s *ImportService) importFile(ctx context.Context, sourcePath string, match *MatchResult, releaseInfo ReleaseInfo, completed download.CompletedDownload, client DownloadClientWithInfo) (*ImportResult, error) { status := "importing" err := s.mediaSvc.Update(ctx, match.MediaID, match.MediaType, UpdateMediaRequest{ Status: &status, }) if err != nil { return nil, fmt.Errorf("update media status to importing: %w", err) } qualityTier := s.parser.MatchQuality(releaseInfo) qualityJSON, _ := json.Marshal(qualityTier) year := 0 if match.Year != nil { year = *match.Year } season := 0 if match.Season != nil { season = *match.Season } episode := 0 if match.Episode != nil { episode = *match.Episode } namingData := NamingData{ Title: match.Title, Year: year, Season: season, Episode: episode, Quality: qualityTier.Name, Ext: ExtractExt(filepath.Base(sourcePath)), ReleaseGroup: releaseInfo.ReleaseGroup, Resolution: releaseInfo.Resolution, Source: releaseInfo.Source, Codec: releaseInfo.VideoCodec, } relativePath, err := s.namingSvc.Render(ctx, match.MediaType, namingData) if err != nil { s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed") s.logImportError(match.MediaID, match.MediaType, fmt.Sprintf("Naming template failed for media %d: %v", match.MediaID, err)) return nil, fmt.Errorf("render naming template: %w", err) } targetPath := filepath.Join(match.RootFolder, relativePath) if !strings.HasPrefix(filepath.Clean(targetPath), filepath.Clean(match.RootFolder)) { s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed") return nil, fmt.Errorf("path traversal detected: target path escapes root folder") } targetDir := filepath.Dir(targetPath) if err := os.MkdirAll(targetDir, 0755); err != nil { s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed") return nil, fmt.Errorf("create target directory: %w", err) } if err := os.Link(sourcePath, targetPath); err != nil { s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed") s.logImportError(match.MediaID, match.MediaType, fmt.Sprintf("Hardlink failed for media %d: %v", match.MediaID, err)) return nil, fmt.Errorf("hardlink file: %w", err) } srcInfo, err := os.Stat(sourcePath) if err != nil { s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed") return nil, fmt.Errorf("stat source file: %w", err) } dstInfo, err := os.Stat(targetPath) if err != nil { s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed") return nil, fmt.Errorf("stat target file: %w", err) } if !os.SameFile(srcInfo, dstInfo) { s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed") return nil, fmt.Errorf("hardlink verification failed: files are not the same inode") } fileSize := dstInfo.Size() if s.subtitleSvc != nil { extractCtx, extractCancel := context.WithTimeout(ctx, 30*time.Second) baseName := s.buildImportSubtitleBaseName(match, releaseInfo) extracted, err := s.subtitleSvc.ExtractSubtitles(extractCtx, targetPath, filepath.Dir(targetPath), baseName) if err != nil { slog.Error("failed to extract subtitles", "error", err, "path", targetPath) } if len(extracted) > 0 { slog.Info("extracted subtitles", "count", len(extracted), "media_id", match.MediaID) } extractCancel() } _, err = s.db.Pool.Exec(ctx, `INSERT INTO media_files (media_id, media_type, path, original_path, file_name, file_size, quality, codec, resolution, source, is_hardlinked) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, match.MediaID, match.MediaType, targetPath, sourcePath, filepath.Base(targetPath), fileSize, qualityJSON, ptrStr(releaseInfo.VideoCodec), ptrStr(releaseInfo.Resolution), ptrStr(releaseInfo.Source), true) if err != nil { s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed") return nil, fmt.Errorf("insert media file record: %w", err) } availableStatus := "available" err = s.mediaSvc.Update(ctx, match.MediaID, match.MediaType, UpdateMediaRequest{ Status: &availableStatus, CurrentQuality: qualityJSON, }) if err != nil { slog.Error("failed to update media status to available", "error", err, "media_id", match.MediaID) } if _, err := s.db.Pool.Exec(ctx, `UPDATE media SET has_files = true WHERE id = $1`, match.MediaID); err != nil { slog.Error("failed to update has_files", "error", err, "media_id", match.MediaID) } // Log successful import activity if s.activitySvc != nil { s.activitySvc.LogAsync(LogEntry{ EventType: "import", MediaID: &match.MediaID, MediaType: &match.MediaType, Title: fmt.Sprintf("Imported %s", filepath.Base(sourcePath)), Data: json.RawMessage(fmt.Sprintf(`{"source":"%s","dest":"%s","quality":"%s","size":%d}`, sourcePath, targetPath, qualityTier.Name, fileSize)), }) } _, err = s.db.Pool.Exec(ctx, `UPDATE download_queue SET status = 'imported', completed_at = NOW() WHERE media_id = $1 AND release_title = $2 AND status IN ('downloading', 'pending')`, match.MediaID, completed.Name) if err != nil { slog.Error("failed to update download queue", "error", err, "media_id", match.MediaID) } if err := client.Client.Remove(ctx, completed.ID); err != nil { slog.Warn("failed to remove download client entry", "error", err, "id", completed.ID) } return &ImportResult{ MediaID: match.MediaID, MediaType: match.MediaType, SourcePath: sourcePath, DestPath: targetPath, FileSize: fileSize, Quality: qualityTier.Name, Status: "imported", }, nil } func (s *ImportService) rollbackStatus(ctx context.Context, mediaID int64, mediaType string, status string) { if err := s.mediaSvc.Update(ctx, mediaID, mediaType, UpdateMediaRequest{Status: &status}); err != nil { slog.Error("failed to rollback media status", "error", err, "media_id", mediaID) } } func (s *ImportService) findMediaFiles(downloadName string) ([]string, error) { downloadPath := filepath.Join(s.downloadDir, downloadName) cleanBase := filepath.Clean(s.downloadDir) info, err := os.Stat(downloadPath) if err != nil { entries, err := os.ReadDir(s.downloadDir) if err != nil { return nil, fmt.Errorf("read download directory: %w", err) } for _, entry := range entries { candidate := filepath.Join(s.downloadDir, entry.Name()) if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(downloadName)) { if entry.IsDir() { return s.walkMediaDir(candidate, cleanBase) } if mediaExts[filepath.Ext(entry.Name())] { return []string{candidate}, nil } } } return nil, nil } if !strings.HasPrefix(filepath.Clean(downloadPath), cleanBase) { return nil, fmt.Errorf("path traversal detected: download path escapes download dir") } if info.IsDir() { return s.walkMediaDir(downloadPath, cleanBase) } if mediaExts[filepath.Ext(downloadPath)] { return []string{downloadPath}, nil } return nil, nil } func (s *ImportService) walkMediaDir(dir string, cleanBase string) ([]string, error) { var files []string err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if !strings.HasPrefix(filepath.Clean(path), cleanBase) { return fmt.Errorf("path traversal detected: walked path escapes download dir") } if d.IsDir() { return nil } if mediaExts[filepath.Ext(path)] { files = append(files, path) } return nil }) if err != nil { return nil, fmt.Errorf("walk download directory: %w", err) } return files, nil } func (s *ImportService) buildImportSubtitleBaseName(match *MatchResult, info ReleaseInfo) string { parts := []string{sanitize(match.Title)} if match.Year != nil { parts = append(parts, fmt.Sprintf("%d", *match.Year)) } if match.Season != nil && match.Episode != nil { parts = append(parts, fmt.Sprintf("S%02dE%02d", *match.Season, *match.Episode)) } return strings.Join(parts, ".") } func ptrStr(s string) *string { if s == "" { return nil } return &s } func (s *ImportService) logImportError(mediaID int64, mediaType string, msg string) { if s.activitySvc != nil { s.activitySvc.LogAsync(LogEntry{ EventType: "error", Title: msg, MediaID: &mediaID, MediaType: &mediaType, }) } }