diff options
| -rw-r--r-- | cmd/player/main.go | 2 | ||||
| -rw-r--r-- | internal/service/podcast.go | 31 | ||||
| -rw-r--r-- | internal/service/podcast_test.go | 181 |
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() +} |
