summaryrefslogtreecommitdiff
path: root/internal/service
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-06 14:21:55 +0300
committerPaul Buetow <paul@buetow.org>2026-05-06 14:21:55 +0300
commitbfe6e791fd959d963d7ff99870ab9ddc8cdc7eb8 (patch)
treef7c3eee68c5cf7c0ddd80a32f0eb640cbfd53225 /internal/service
parent76a9fa783b098d46e1352fb19731a2f6a6ab7e53 (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/service')
-rw-r--r--internal/service/podcast.go106
-rw-r--r--internal/service/podcast_test.go160
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)