summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd/player/main.go2
-rw-r--r--internal/service/podcast.go31
-rw-r--r--internal/service/podcast_test.go181
3 files changed, 209 insertions, 5 deletions
diff --git a/cmd/player/main.go b/cmd/player/main.go
index 0e96e5f..c07ab93 100644
--- a/cmd/player/main.go
+++ b/cmd/player/main.go
@@ -96,7 +96,7 @@ func wireDeps(cfg *internal.Config, store repository.Store, logger *slog.Logger,
authSvc := service.NewAuthService(store, clk, hasher, sm)
helper := service.NewAccessHelper(store)
- podcastSvc := service.NewPodcastService(store, clk, cfg.MediaRoot, helper, prober, thumbGen, cfg.PodcastCheckMinutes)
+ podcastSvc := service.NewPodcastServiceWithLogger(store, clk, cfg.MediaRoot, helper, prober, thumbGen, cfg.PodcastCheckMinutes, logger)
gcWorker := service.NewGCWorker(store, clk, cfg.MediaRoot, time.Duration(cfg.GCIntervalMinutes)*time.Minute, logger)
diff --git a/internal/service/podcast.go b/internal/service/podcast.go
index 634c087..f5cf5dc 100644
--- a/internal/service/podcast.go
+++ b/internal/service/podcast.go
@@ -4,10 +4,12 @@ import (
"context"
"fmt"
"io"
+ "log/slog"
"net/http"
"os"
"path/filepath"
"strings"
+ "sync"
"time"
"codeberg.org/snonux/player/internal/clock"
@@ -72,6 +74,7 @@ type podcastService struct {
thumbGen thumb.Generator
httpClient *http.Client
checkInterval int // minutes
+ logger *slog.Logger
parseFeed func(string) (*podcast.ParsedFeed, error)
parseFeedReader func(io.Reader) (*podcast.ParsedFeed, error)
downloadCover func(*http.Client, string, string) error
@@ -80,9 +83,17 @@ type podcastService struct {
// NewPodcastService creates a PodcastService with the given dependencies.
// checkInterval should be the number of minutes between background feed checks.
func NewPodcastService(store PodcastServiceStore, clk clock.Clock, mediaRoot string, helper *accessHelper, prober probe.Prober, thumbGen thumb.Generator, checkInterval int) *podcastService {
+ return NewPodcastServiceWithLogger(store, clk, mediaRoot, helper, prober, thumbGen, checkInterval, slog.Default())
+}
+
+// NewPodcastServiceWithLogger creates a PodcastService with an injected logger.
+func NewPodcastServiceWithLogger(store PodcastServiceStore, clk clock.Clock, mediaRoot string, helper *accessHelper, prober probe.Prober, thumbGen thumb.Generator, checkInterval int, logger *slog.Logger) *podcastService {
if checkInterval <= 0 {
checkInterval = 60
}
+ if logger == nil {
+ logger = slog.Default()
+ }
s := &podcastService{
store: store,
clock: clk,
@@ -92,6 +103,7 @@ func NewPodcastService(store PodcastServiceStore, clk clock.Clock, mediaRoot str
thumbGen: thumbGen,
httpClient: &http.Client{Timeout: 30 * time.Second},
checkInterval: checkInterval,
+ logger: logger,
}
// Wire package-level helpers so tests can inject fakes.
s.parseFeed = podcast.ParseFeed
@@ -503,12 +515,23 @@ func (s *podcastService) CheckFeeds(ctx context.Context) error {
return fmt.Errorf("list feeds needing check: %w", err)
}
+ s.logger.Info("podcast feed check starting", "count", len(feeds))
+
+ var wg sync.WaitGroup
for _, feed := range feeds {
- if err := s.checkFeed(ctx, feed); err != nil {
- // Log and continue with other feeds.
- continue
- }
+ wg.Add(1)
+ go func(f model.PodcastFeed) {
+ defer wg.Done()
+ if err := s.checkFeed(ctx, f); err != nil {
+ s.logger.Warn("podcast feed check failed", "feed_id", f.ID, "feed_url", f.FeedURL, "err", err)
+ } else {
+ s.logger.Info("podcast feed check ok", "feed_id", f.ID, "feed_url", f.FeedURL)
+ }
+ }(feed)
}
+ wg.Wait()
+
+ s.logger.Info("podcast feed check finished", "count", len(feeds))
return nil
}
diff --git a/internal/service/podcast_test.go b/internal/service/podcast_test.go
index de61d5e..cbf6131 100644
--- a/internal/service/podcast_test.go
+++ b/internal/service/podcast_test.go
@@ -3,11 +3,14 @@ package service
import (
"context"
"errors"
+ "io"
+ "log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
+ "sync"
"testing"
"time"
@@ -23,7 +26,9 @@ func setupPodcastService(t *testing.T) (*podcastService, *repository.MockStore)
clk := &clock.MockClock{T: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}
store := repository.NewMockStore()
helper := &accessHelper{store: store}
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := NewPodcastService(store, clk, mediaRoot, helper, nil, nil, 60)
+ svc.logger = logger
return svc, store
}
@@ -687,3 +692,179 @@ func TestPodcastService_InsertPodcastEpisodes(t *testing.T) {
t.Errorf("episode mismatch: %+v", created[0])
}
}
+
+func TestPodcastService_CheckFeeds_EmptyList(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+
+ store.PodcastRepo = repository.MockPodcastRepo{
+ ListFeedsNeedingCheckFunc: func(ctx context.Context, before time.Time) ([]model.PodcastFeed, error) {
+ return []model.PodcastFeed{}, nil
+ },
+ }
+
+ err := svc.CheckFeeds(ctx)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestPodcastService_CheckFeeds_Concurrent_Ok(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+
+ var checked []int64
+ var mu sync.Mutex
+
+ callOrder := make(chan int64, 3)
+ svc.parseFeedReader = func(r io.Reader) (*podcast.ParsedFeed, error) {
+ return &podcast.ParsedFeed{Title: "T"}, nil
+ }
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("ETag", "etag-"+r.URL.Path)
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`<rss><channel><title>X</title></channel></rss>`))
+ select {
+ case callOrder <- 1:
+ default:
+ }
+ }))
+ defer server.Close()
+
+ store.PodcastRepo = repository.MockPodcastRepo{
+ ListFeedsNeedingCheckFunc: func(ctx context.Context, before time.Time) ([]model.PodcastFeed, error) {
+ return []model.PodcastFeed{
+ {ID: 1, FeedURL: server.URL + "/1.xml"},
+ {ID: 2, FeedURL: server.URL + "/2.xml"},
+ {ID: 3, FeedURL: server.URL + "/3.xml"},
+ }, nil
+ },
+ UpdateFeedFunc: func(ctx context.Context, feed *model.PodcastFeed) error {
+ mu.Lock()
+ checked = append(checked, feed.ID)
+ mu.Unlock()
+ return nil
+ },
+ }
+
+ svc.httpClient = server.Client()
+
+ err := svc.CheckFeeds(ctx)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ mu.Lock()
+ if len(checked) != 3 {
+ t.Fatalf("expected 3 feeds checked, got %d", len(checked))
+ }
+ mu.Unlock()
+
+ // Verify we processed 3 requests concurrently by reading from channel.
+ processed := 0
+ done := time.After(100 * time.Millisecond)
+ for {
+ select {
+ case <-callOrder:
+ processed++
+ if processed == 3 {
+ return
+ }
+ case <-done:
+ t.Fatalf("expected 3 feed checks, got %d", processed)
+ }
+ }
+}
+
+func TestPodcastService_CheckFeeds_AllFeedsFail(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.StatusInternalServerError)
+ }))
+ defer server.Close()
+
+ store.PodcastRepo = repository.MockPodcastRepo{
+ ListFeedsNeedingCheckFunc: func(ctx context.Context, before time.Time) ([]model.PodcastFeed, error) {
+ return []model.PodcastFeed{
+ {ID: 1, FeedURL: server.URL + "/1.xml"},
+ {ID: 2, FeedURL: server.URL + "/2.xml"},
+ }, nil
+ },
+ UpdateFeedFunc: func(ctx context.Context, feed *model.PodcastFeed) error { return nil },
+ }
+
+ svc.httpClient = server.Client()
+
+ err := svc.CheckFeeds(ctx)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestPodcastService_CheckFeeds_ListError(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+ boom := errors.New("boom")
+ store.PodcastRepo = repository.MockPodcastRepo{
+ ListFeedsNeedingCheckFunc: func(ctx context.Context, before time.Time) ([]model.PodcastFeed, error) {
+ return nil, boom
+ },
+ }
+
+ err := svc.CheckFeeds(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !errors.Is(err, boom) {
+ t.Fatalf("expected wrapped boom, got %v", err)
+ }
+}
+
+func TestPodcastService_CheckFeeds_FeedError_Continues(t *testing.T) {
+ ctx := context.Background()
+ svc, store := setupPodcastService(t)
+
+ var checked []int64
+ var mu sync.Mutex
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.Contains(r.URL.Path, "bad") {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`<rss><channel><title>X</title></channel></rss>`))
+ }))
+ defer server.Close()
+
+ store.PodcastRepo = repository.MockPodcastRepo{
+ ListFeedsNeedingCheckFunc: func(ctx context.Context, before time.Time) ([]model.PodcastFeed, error) {
+ return []model.PodcastFeed{
+ {ID: 1, FeedURL: server.URL + "/bad.xml"},
+ {ID: 2, FeedURL: server.URL + "/ok.xml"},
+ }, nil
+ },
+ UpdateFeedFunc: func(ctx context.Context, feed *model.PodcastFeed) error {
+ mu.Lock()
+ checked = append(checked, feed.ID)
+ mu.Unlock()
+ return nil
+ },
+ }
+
+ svc.httpClient = server.Client()
+
+ err := svc.CheckFeeds(ctx)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ mu.Lock()
+ if len(checked) != 1 || checked[0] != 2 {
+ t.Fatalf("expected only feed 2 ok, got %v", checked)
+ }
+ mu.Unlock()
+}