diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-06 14:21:55 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-06 14:21:55 +0300 |
| commit | bfe6e791fd959d963d7ff99870ab9ddc8cdc7eb8 (patch) | |
| tree | f7c3eee68c5cf7c0ddd80a32f0eb640cbfd53225 /internal | |
| parent | 76a9fa783b098d46e1352fb19731a2f6a6ab7e53 (diff) | |
split DownloadEpisode into smaller helpers (x0)
Refactor ~112-line DownloadEpisode into four focused helpers:
- resolveEpisodeAndSet: permission + path building
- buildEpisodePath: filename logic
- downloadEnclosure: HTTP fetch to disk
- persistDownloadedEpisode: DB + probe + cleanup
Also: pass ctx through to HTTP request via NewRequestWithContext.
Added tests for happy path, 404, and missing episode/feed/set.
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/service/podcast.go | 106 | ||||
| -rw-r--r-- | internal/service/podcast_test.go | 160 |
2 files changed, 229 insertions, 37 deletions
diff --git a/internal/service/podcast.go b/internal/service/podcast.go index d609935..634c087 100644 --- a/internal/service/podcast.go +++ b/internal/service/podcast.go @@ -303,44 +303,71 @@ func (s *podcastService) ListEpisodes(ctx context.Context, setID, userID int64, } func (s *podcastService) DownloadEpisode(ctx context.Context, episodeID, userID int64) (*model.Media, error) { - // Fetch episode. + episode, set, path, err := s.resolveEpisodeAndSet(ctx, episodeID, userID) + if err != nil { + return nil, err + } + + n, err := s.downloadEnclosure(ctx, episode, path) + if err != nil { + return nil, err + } + + media, cleanup, err := s.persistDownloadedEpisode(ctx, episode, set, path, n) + if err != nil { + return nil, err + } + + // Post-persistence failure: link episode to media row. + if err := s.store.UpdateEpisodeMedia(ctx, episode.ID, media.ID, filepath.Base(path)); err != nil { + cleanup() + return nil, fmt.Errorf("update episode media: %w", err) + } + + return media, nil +} + +// resolveEpisodeAndSet fetches the episode, feed, and set, verifies user +// permission, and returns the unique target file path on disk. +func (s *podcastService) resolveEpisodeAndSet(ctx context.Context, episodeID, userID int64) (*model.PodcastEpisode, *model.Set, string, error) { episode, err := s.store.GetEpisodeByID(ctx, episodeID) if err != nil { - return nil, fmt.Errorf("get episode: %w", err) + return nil, nil, "", fmt.Errorf("get episode: %w", err) } if episode == nil { - return nil, ErrNotFound + return nil, nil, "", ErrNotFound } - // Fetch feed for set path. feed, err := s.store.GetFeedByID(ctx, episode.FeedID) if err != nil { - return nil, fmt.Errorf("get feed: %w", err) + return nil, nil, "", fmt.Errorf("get feed: %w", err) } if feed == nil { - return nil, ErrNotFound + return nil, nil, "", ErrNotFound } - // Verify permission. if err := s.helper.checkSetPermission(ctx, feed.SetID, userID, ""); err != nil { - return nil, err + return nil, nil, "", err } - // Get set for root path. set, err := s.store.GetSetByID(ctx, feed.SetID) if err != nil { - return nil, fmt.Errorf("get set: %w", err) + return nil, nil, "", fmt.Errorf("get set: %w", err) } if set == nil { - return nil, ErrNotFound + return nil, nil, "", ErrNotFound } - // Determine target filename: YYYY-MM-DD - sanitized-title.ext - var dateStr string + setPath := filepath.Join(s.mediaRoot, set.RootPath) + path := buildEpisodePath(setPath, episode, s.clock.Now()) + return episode, set, path, nil +} + +// buildEpisodePath builds a unique local path for the episode enclosure. +func buildEpisodePath(setPath string, episode *model.PodcastEpisode, now time.Time) string { + dateStr := now.Format("2006-01-02") if episode.PublishedAt != nil { dateStr = episode.PublishedAt.Format("2006-01-02") - } else { - dateStr = s.clock.Now().Format("2006-01-02") } ext := filepath.Ext(episode.EpisodeURL) @@ -352,37 +379,47 @@ func (s *podcastService) DownloadEpisode(ctx context.Context, episodeID, userID cleanTitle = fmt.Sprintf("episode-%d", episode.ID) } filename := fmt.Sprintf("%s - %s%s", dateStr, cleanTitle, ext) + return uniqueFilename(setPath, filename) +} - setPath := filepath.Join(s.mediaRoot, set.RootPath) - path := uniqueFilename(setPath, filename) - - // Download enclosure. - resp, err := s.httpClient.Get(episode.EpisodeURL) +// downloadEnclosure performs the HTTP GET, writes the body to path, and +// returns the number of bytes written. +func (s *podcastService) downloadEnclosure(ctx context.Context, episode *model.PodcastEpisode, path string) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, episode.EpisodeURL, nil) if err != nil { - return nil, fmt.Errorf("download episode: %w", err) + return 0, fmt.Errorf("build download request: %w", err) + } + resp, err := s.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("download episode: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("download episode: status %d", resp.StatusCode) + return 0, fmt.Errorf("download episode: status %d", resp.StatusCode) } f, err := os.Create(path) if err != nil { - return nil, fmt.Errorf("create file: %w", err) + return 0, fmt.Errorf("create file: %w", err) } n, err := io.Copy(f, resp.Body) if err != nil { f.Close() os.Remove(path) - return nil, fmt.Errorf("write file: %w", err) + return 0, fmt.Errorf("write file: %w", err) } if err := f.Close(); err != nil { os.Remove(path) - return nil, fmt.Errorf("close file: %w", err) + return 0, fmt.Errorf("close file: %w", err) } - // Create media row. + return n, nil +} + +// persistDownloadedEpisode records the downloaded file in the database, +// probes it, and returns a cleanup function to undo work on failure. +func (s *podcastService) persistDownloadedEpisode(ctx context.Context, episode *model.PodcastEpisode, set *model.Set, path string, n int64) (*model.Media, func(), error) { media := &model.Media{ SetID: set.ID, RelPath: filepath.Base(path), @@ -395,25 +432,21 @@ func (s *podcastService) DownloadEpisode(ctx context.Context, episodeID, userID mediaID, err := s.store.CreateMedia(ctx, media) if err != nil { os.Remove(path) - return nil, fmt.Errorf("create media: %w", err) + return nil, nil, fmt.Errorf("create media: %w", err) } media.ID = mediaID - // Probe, thumbnail, and update metadata using shared helper. - if err := ImportMediaFile(ctx, s.store, media, s.prober, s.thumbGen); err != nil { + cleanup := func() { os.Remove(path) _ = s.store.HardDeleteMedia(ctx, media.ID) - return nil, err } - // Link episode to media row. - if err := s.store.UpdateEpisodeMedia(ctx, episode.ID, media.ID, filepath.Base(path)); err != nil { - os.Remove(path) - _ = s.store.HardDeleteMedia(ctx, media.ID) - return nil, fmt.Errorf("update episode media: %w", err) + if err := ImportMediaFile(ctx, s.store, media, s.prober, s.thumbGen); err != nil { + cleanup() + return nil, nil, err } - return media, nil + return media, cleanup, nil } func (s *podcastService) ToggleEpisodeComplete(ctx context.Context, episodeID, userID int64) error { @@ -587,4 +620,3 @@ func sanitizeFilename(name string) string { name = strings.TrimSpace(name) return name } - diff --git a/internal/service/podcast_test.go b/internal/service/podcast_test.go index f28a326..de61d5e 100644 --- a/internal/service/podcast_test.go +++ b/internal/service/podcast_test.go @@ -4,8 +4,10 @@ import ( "context" "errors" "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -395,6 +397,164 @@ func TestPodcastService_DownloadEpisode_NonAdmin(t *testing.T) { } } +func TestPodcastService_DownloadEpisode_Success(t *testing.T) { + ctx := context.Background() + svc, store := setupPodcastService(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("fake mp3 content")) + })) + defer server.Close() + svc.httpClient = server.Client() + + setDir := filepath.Join(svc.mediaRoot, "podcast-set") + if err := os.MkdirAll(setDir, 0o755); err != nil { + t.Fatalf("mkdir set dir: %v", err) + } + + pub := time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC) + store.PodcastRepo = repository.MockPodcastRepo{ + GetEpisodeByIDFunc: func(ctx context.Context, id int64) (*model.PodcastEpisode, error) { + return &model.PodcastEpisode{ID: id, FeedID: 1, Title: "Ep 1", EpisodeURL: server.URL + "/ep1.mp3", PublishedAt: &pub}, nil + }, + GetFeedByIDFunc: func(ctx context.Context, id int64) (*model.PodcastFeed, error) { + return &model.PodcastFeed{ID: id, SetID: 1}, nil + }, + UpdateEpisodeMediaFunc: func(ctx context.Context, episodeID, mediaID int64, fileName string) error { + return nil + }, + } + store.UserRepo = repository.MockUserRepo{ + GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) { + return &model.User{ID: id, IsAdmin: true}, nil + }, + } + store.SetRepo = repository.MockSetRepo{ + GetSetByIDFunc: func(ctx context.Context, id int64) (*model.Set, error) { + return &model.Set{ID: id, RootPath: "podcast-set"}, nil + }, + } + store.MediaRepo = repository.MockMediaRepo{ + CreateMediaFunc: func(ctx context.Context, media *model.Media) (int64, error) { + return 42, nil + }, + } + + media, err := svc.DownloadEpisode(ctx, 1, 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if media == nil { + t.Fatal("expected media, got nil") + } + if media.ID != 42 { + t.Errorf("media.ID = %d, want 42", media.ID) + } + if media.FileSizeBytes != int64(len("fake mp3 content")) { + t.Errorf("media.FileSizeBytes = %d, want %d", media.FileSizeBytes, len("fake mp3 content")) + } + if _, err := os.Stat(media.AbsPath); os.IsNotExist(err) { + t.Error("expected downloaded file to exist on disk") + } +} + +func TestPodcastService_DownloadEpisode_HTTPError(t *testing.T) { + ctx := context.Background() + svc, store := setupPodcastService(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + svc.httpClient = server.Client() + + store.PodcastRepo = repository.MockPodcastRepo{ + GetEpisodeByIDFunc: func(ctx context.Context, id int64) (*model.PodcastEpisode, error) { + return &model.PodcastEpisode{ID: id, FeedID: 1, Title: "Ep 1", EpisodeURL: server.URL + "/ep1.mp3"}, nil + }, + GetFeedByIDFunc: func(ctx context.Context, id int64) (*model.PodcastFeed, error) { + return &model.PodcastFeed{ID: id, SetID: 1}, nil + }, + } + store.UserRepo = repository.MockUserRepo{ + GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) { + return &model.User{ID: id, IsAdmin: true}, nil + }, + } + store.SetRepo = repository.MockSetRepo{ + GetSetByIDFunc: func(ctx context.Context, id int64) (*model.Set, error) { + return &model.Set{ID: id, RootPath: "podcast-set"}, nil + }, + } + + _, err := svc.DownloadEpisode(ctx, 1, 1) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "status 404") { + t.Errorf("expected status 404 in error, got: %v", err) + } +} + +func TestPodcastService_DownloadEpisode_EpisodeNotFound(t *testing.T) { + ctx := context.Background() + svc, store := setupPodcastService(t) + store.PodcastRepo = repository.MockPodcastRepo{ + GetEpisodeByIDFunc: func(ctx context.Context, id int64) (*model.PodcastEpisode, error) { + return nil, nil + }, + } + _, err := svc.DownloadEpisode(ctx, 1, 1) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestPodcastService_DownloadEpisode_FeedNotFound(t *testing.T) { + ctx := context.Background() + svc, store := setupPodcastService(t) + store.PodcastRepo = repository.MockPodcastRepo{ + GetEpisodeByIDFunc: func(ctx context.Context, id int64) (*model.PodcastEpisode, error) { + return &model.PodcastEpisode{ID: id, FeedID: 1}, nil + }, + GetFeedByIDFunc: func(ctx context.Context, id int64) (*model.PodcastFeed, error) { + return nil, nil + }, + } + _, err := svc.DownloadEpisode(ctx, 1, 1) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestPodcastService_DownloadEpisode_SetNotFound(t *testing.T) { + ctx := context.Background() + svc, store := setupPodcastService(t) + store.PodcastRepo = repository.MockPodcastRepo{ + GetEpisodeByIDFunc: func(ctx context.Context, id int64) (*model.PodcastEpisode, error) { + return &model.PodcastEpisode{ID: id, FeedID: 1}, nil + }, + GetFeedByIDFunc: func(ctx context.Context, id int64) (*model.PodcastFeed, error) { + return &model.PodcastFeed{ID: id, SetID: 1}, nil + }, + } + store.UserRepo = repository.MockUserRepo{ + GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) { + return &model.User{ID: id, IsAdmin: true}, nil + }, + } + store.SetRepo = repository.MockSetRepo{ + GetSetByIDFunc: func(ctx context.Context, id int64) (*model.Set, error) { + return nil, nil + }, + } + _, err := svc.DownloadEpisode(ctx, 1, 1) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + func TestPodcastService_UpsertFeedEpisodes(t *testing.T) { ctx := context.Background() svc, store := setupPodcastService(t) |
