diff --git a/.gitignore b/.gitignore index 4ed971491..e11d18ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ go.work* # Temp benchmark files zz_*_test.go +# Downloaded podcast episode audio files should not be tracked (DataFolder defaults to ".") +/podcasts/ +/ui/podcasts/ # wazero compilation cache for the plugins test suite /plugins/testdata/.wazero-cache/ /plugins/testdata/*.stage/ diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index de4c55a1e..24229cb29 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/podcasts" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" @@ -108,8 +109,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) + podcastService := podcasts.NewPodcastService(ctx, dataStore, fFmpeg, broker) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, podcastService, sonicSonic) return router } diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index af2dab647..c079e2fa2 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -36,12 +36,13 @@ type TranscodeOptions struct { // AudioProbeResult contains authoritative audio stream properties from ffprobe. type AudioProbeResult struct { - Codec string `json:"codec"` - Profile string `json:"profile,omitempty"` - BitRate int `json:"bitRate"` - SampleRate int `json:"sampleRate"` - BitDepth int `json:"bitDepth"` - Channels int `json:"channels"` + Codec string `json:"codec"` + Profile string `json:"profile,omitempty"` + BitRate int `json:"bitRate"` + SampleRate int `json:"sampleRate"` + BitDepth int `json:"bitDepth"` + Channels int `json:"channels"` + Duration float64 `json:"duration"` } type FFmpeg interface { @@ -242,7 +243,8 @@ type probeOutput struct { } type probeFormat struct { - BitRate string `json:"bit_rate"` + BitRate string `json:"bit_rate"` + Duration string `json:"duration"` } type probeStream struct { @@ -298,6 +300,11 @@ func parseProbeOutput(data []byte) (*AudioProbeResult, error) { result.BitRate = bps / 1000 } + // Duration from format section (seconds as float string) + if output.Format.Duration != "" { + result.Duration, _ = strconv.ParseFloat(output.Format.Duration, 64) + } + return result, nil } diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go new file mode 100644 index 000000000..36340c018 --- /dev/null +++ b/core/podcasts/podcasts.go @@ -0,0 +1,546 @@ +package podcasts + +import ( + "context" + "fmt" + "io" + "math" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/server/events" +) + +const podcastLibraryName = "Podcasts" + +type Podcasts interface { + AddChannel(ctx context.Context, rssURL string) error + RefreshChannels(ctx context.Context) error + DeleteChannel(ctx context.Context, id string) error + DeleteEpisode(ctx context.Context, id string) error + DownloadEpisode(ctx context.Context, id string) error +} + +type podcastService struct { + rootCtx context.Context + ds model.DataStore + ff ffmpeg.FFmpeg + broker events.Broker +} + +func NewPodcastService(rootCtx context.Context, ds model.DataStore, ff ffmpeg.FFmpeg, broker events.Broker) Podcasts { + return &podcastService{rootCtx: rootCtx, ds: ds, ff: ff, broker: broker} +} + +// podcastLibraryID returns the ID of the podcast virtual library, +// creating it if it doesn't exist. The library root is DataFolder so that +// MediaFile paths stored as "podcasts/{ch}/{ep}.mp3" resolve correctly via AbsolutePath(). +func (s *podcastService) podcastLibraryID(ctx context.Context) (int, error) { + libs, err := s.ds.Library(ctx).GetAll() + if err != nil { + return 0, err + } + for _, lib := range libs { + if lib.Name == podcastLibraryName { + return lib.ID, nil + } + } + lib := &model.Library{ + Name: podcastLibraryName, + Path: conf.Server.DataFolder.String(), + } + if err := s.ds.Library(ctx).Put(lib); err != nil { + return 0, err + } + return lib.ID, nil +} + +func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { + exists, err := s.ds.PodcastChannel(ctx).ExistsByURL(rssURL) + if err != nil { + return fmt.Errorf("checking existing channel: %w", err) + } + if exists { + return fmt.Errorf("channel already exists: %s", rssURL) + } + + feed, err := fetchAndParse(rssURL) + if err != nil { + return fmt.Errorf("adding podcast channel: %w", err) + } + + ch := &model.PodcastChannel{ + URL: rssURL, + Title: feed.Title, + Description: feed.Description, + ImageURL: feed.ImageURL, + Status: model.PodcastStatusNew, + PodcastGUID: feed.PodcastGUID, + Locked: feed.Locked, + LockedOwner: feed.LockedOwner, + Medium: feed.Medium, + UpdateFrequency: feed.UpdateFrequency, + UpdateRRule: feed.UpdateRRule, + Complete: feed.Complete, + UsesPodping: feed.UsesPodping, + LocationName: feed.LocationName, + LocationGeo: feed.LocationGeo, + LocationOSM: feed.LocationOSM, + License: feed.License, + PublisherName: feed.PublisherName, + PublisherURL: feed.PublisherURL, + } + if err := s.ds.PodcastChannel(ctx).Create(ch); err != nil { + return err + } + + // Save channel-level persons + if len(feed.Persons) > 0 { + if err := s.ds.PodcastPerson(ctx).SaveForChannel(ch.ID, feed.Persons); err != nil { + log.Warn(ctx, "Failed to save podcast channel persons", "channel", ch.ID, err) + } + } + + // Save podcast:funding items + if len(feed.FundingItems) > 0 { + for i := range feed.FundingItems { + feed.FundingItems[i].ChannelID = ch.ID + } + if err := s.ds.PodcastFunding(ctx).SaveForChannel(ch.ID, feed.FundingItems); err != nil { + log.Warn(ctx, "Failed to save podcast funding items", "channel", ch.ID, err) + } + } + + // Save podcast:image (channel level) + if len(feed.Images) > 0 { + if err := s.ds.PodcastImage(ctx).SaveForChannel(ch.ID, feed.Images); err != nil { + log.Warn(ctx, "Failed to save podcast channel images", "channel", ch.ID, err) + } + } + + // Save podcast:podroll items + if len(feed.Podroll) > 0 { + if err := s.ds.PodcastPodroll(ctx).SaveForChannel(ch.ID, feed.Podroll); err != nil { + log.Warn(ctx, "Failed to save podcast podroll", "channel", ch.ID, err) + } + } + + // Save podcast:liveItem entries + for _, li := range feed.LiveItems { + li.ChannelID = ch.ID + if err := s.ds.PodcastLiveItem(ctx).Upsert(&li); err != nil { + log.Warn(ctx, "Failed to save podcast live item", "channel", ch.ID, err) + } + } + + for i := range feed.Episodes { + ep := feed.Episodes[i] + ep.ChannelID = ch.ID + ep.Status = model.PodcastStatusNew + transcripts := ep.Transcripts + persons := ep.Persons + images := ep.Images + ep.Transcripts = nil + ep.Persons = nil + ep.Images = nil + if err := s.ds.PodcastEpisode(ctx).Create(&ep); err != nil { + return err + } + // Save episode transcripts + if len(transcripts) > 0 { + for j := range transcripts { + transcripts[j].EpisodeID = ep.ID + } + if err := s.ds.PodcastTranscript(ctx).Save(transcripts); err != nil { + log.Warn(ctx, "Failed to save podcast episode transcripts", "episode", ep.ID, err) + } + } + // Save episode persons + if len(persons) > 0 { + if err := s.ds.PodcastPerson(ctx).SaveForEpisode(ep.ID, persons); err != nil { + log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err) + } + } + // Save episode images + if len(images) > 0 { + if err := s.ds.PodcastImage(ctx).SaveForEpisode(ep.ID, images); err != nil { + log.Warn(ctx, "Failed to save podcast episode images", "episode", ep.ID, err) + } + } + } + + ch.Status = model.PodcastStatusCompleted + return s.ds.PodcastChannel(ctx).UpdateChannel(ch) +} + +func (s *podcastService) RefreshChannels(ctx context.Context) error { + channels, err := s.ds.PodcastChannel(ctx).GetAll(false) + if err != nil { + return err + } + + for _, ch := range channels { + if ch.UsesPodping { + continue // skip — this channel uses Podping for updates + } + if err := s.refreshChannel(ctx, ch); err != nil { + log.Warn(ctx, "Failed to refresh podcast channel", "channel", ch.Title, err) + } + } + return nil +} + +func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastChannel) error { + feed, err := fetchAndParse(ch.URL) + if err != nil { + return err + } + + // Refresh podcast:funding items + if err := s.ds.PodcastFunding(ctx).SaveForChannel(ch.ID, feed.FundingItems); err != nil { + log.Warn(ctx, "Failed to refresh funding items", "channel", ch.ID, err) + } + + // Refresh podcast:image (channel level) + if err := s.ds.PodcastImage(ctx).SaveForChannel(ch.ID, feed.Images); err != nil { + log.Warn(ctx, "Failed to refresh channel images", "channel", ch.ID, err) + } + + // Refresh podcast:podroll + if err := s.ds.PodcastPodroll(ctx).SaveForChannel(ch.ID, feed.Podroll); err != nil { + log.Warn(ctx, "Failed to refresh podroll", "channel", ch.ID, err) + } + + // Refresh podcast:liveItem + for _, li := range feed.LiveItems { + li.ChannelID = ch.ID + if err := s.ds.PodcastLiveItem(ctx).Upsert(&li); err != nil { + log.Warn(ctx, "Failed to upsert live item", "channel", ch.ID, err) + } + } + + epRepo := s.ds.PodcastEpisode(ctx) + for i := range feed.Episodes { + ep := feed.Episodes[i] + _, err := epRepo.GetByGUID(ch.ID, ep.GUID) + if err == nil { + continue // already exists + } + ep.ChannelID = ch.ID + ep.Status = model.PodcastStatusNew + transcripts := ep.Transcripts + persons := ep.Persons + images := ep.Images + ep.Transcripts = nil + ep.Persons = nil + ep.Images = nil + if err := epRepo.Create(&ep); err != nil { + return err + } + // Save episode transcripts + if len(transcripts) > 0 { + for j := range transcripts { + transcripts[j].EpisodeID = ep.ID + } + if err := s.ds.PodcastTranscript(ctx).Save(transcripts); err != nil { + log.Warn(ctx, "Failed to save podcast episode transcripts", "episode", ep.ID, err) + } + } + // Save episode persons + if len(persons) > 0 { + if err := s.ds.PodcastPerson(ctx).SaveForEpisode(ep.ID, persons); err != nil { + log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err) + } + } + // Save episode images + if len(images) > 0 { + if err := s.ds.PodcastImage(ctx).SaveForEpisode(ep.ID, images); err != nil { + log.Warn(ctx, "Failed to save podcast episode images", "episode", ep.ID, err) + } + } + } + return nil +} + +func (s *podcastService) DownloadEpisode(ctx context.Context, id string) error { + ep, err := s.ds.PodcastEpisode(ctx).Get(id) + if err != nil { + return err + } + ch, err := s.ds.PodcastChannel(ctx).Get(ep.ChannelID) + if err != nil { + return err + } + + ep.Status = model.PodcastStatusDownloading + ep.UpdatedAt = time.Now() + if err := s.ds.PodcastEpisode(ctx).Update(ep); err != nil { + return err + } + + go s.doDownload(s.rootCtx, ep, ch) + return nil +} + +func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisode, ch *model.PodcastChannel) { + suffix := ep.Suffix + if suffix == "" { + suffix = "mp3" + } + dir := filepath.Join(conf.Server.DataFolder.String(), "podcasts", ep.ChannelID) + if err := os.MkdirAll(dir, 0755); err != nil { + s.setEpisodeError(ctx, ep, err) + return + } + + dest := filepath.Join(dir, ep.ID+"."+suffix) + f, err := os.Create(dest) + if err != nil { + s.setEpisodeError(ctx, ep, err) + return + } + defer f.Close() + + httpClient := &http.Client{Timeout: 30 * time.Second} + resp, err := httpClient.Get(ep.EnclosureURL) //nolint:gosec + if err != nil { + s.setEpisodeError(ctx, ep, err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, ep.EnclosureURL) + s.setEpisodeError(ctx, ep, err) + return + } + + // Use Content-Length as total size when RSS feed didn't provide it + if resp.ContentLength > 0 && ep.Size == 0 { + ep.Size = resp.ContentLength + } + + size, err := io.Copy(&progressWriter{ep: ep, ds: s.ds, broker: s.broker, ctx: ctx, w: f}, resp.Body) + if err != nil { + s.setEpisodeError(ctx, ep, err) + return + } + f.Close() + + // Write ID3 tags so the scanner picks up the correct metadata + s.writeID3Tags(ctx, dest, suffix, ep.Title, ch.Title) + + // Register as a MediaFile so /rest/stream works with the standard media file path. + // Use a podcast virtual library whose root is DataFolder; store relative path. + libID, libErr := s.podcastLibraryID(ctx) + if libErr != nil { + log.Warn(ctx, "Failed to get podcast library, streaming may not work", "episode", ep.ID, libErr) + } else { + relPath := strings.TrimPrefix(dest, conf.Server.DataFolder.String()+string(filepath.Separator)) + now := time.Now() + tags := model.Tags{} + tags.Add("genre", "Podcast") + mf := &model.MediaFile{ + ID: id.NewRandom(), + LibraryID: libID, + Path: relPath, + Title: ep.Title, + Album: ch.Title, + AlbumID: ch.ID, + Artist: "", + AlbumArtist: ch.Title, + Genre: "Podcast", + Tags: tags, + Duration: float32(ep.Duration), + Size: size, + BitRate: ep.BitRate, + Suffix: suffix, + CreatedAt: now, + UpdatedAt: now, + } + if putErr := s.ds.MediaFile(ctx).Put(mf); putErr != nil { + log.Warn(ctx, "Failed to register podcast episode as MediaFile", "episode", ep.ID, putErr) + } else { + ep.StreamID = mf.ID + } + } + + // Probe actual duration and bitrate from the downloaded file + if s.ff != nil && s.ff.IsProbeAvailable() { + if probe, probeErr := s.ff.ProbeAudioStream(ctx, dest); probeErr == nil { + if probe.Duration > 0 { + ep.Duration = int(math.Round(probe.Duration)) + } + if probe.BitRate > 0 { + ep.BitRate = probe.BitRate + } + } else { + log.Warn(ctx, "Failed to probe podcast episode duration", "episode", ep.ID, probeErr) + } + } + + ep.Path = dest + ep.Size = size + ep.DownloadedBytes = size + ep.Status = model.PodcastStatusCompleted + ep.ErrorMessage = "" + ep.UpdatedAt = time.Now() + if err := s.ds.PodcastEpisode(ctx).Update(ep); err != nil { + log.Error(ctx, "Failed to update episode after download", "episode", ep.ID, err) + } + if s.broker != nil { + s.broker.SendBroadcastMessage(ctx, &events.PodcastEpisodeProgress{ + EpisodeID: ep.ID, + ChannelID: ep.ChannelID, + DownloadedBytes: size, + Size: size, + Duration: ep.Duration, + Status: string(model.PodcastStatusCompleted), + }) + } +} + +func (s *podcastService) setEpisodeError(ctx context.Context, ep *model.PodcastEpisode, err error) { + ep.Status = model.PodcastStatusError + ep.ErrorMessage = err.Error() + ep.UpdatedAt = time.Now() + if updateErr := s.ds.PodcastEpisode(ctx).Update(ep); updateErr != nil { + log.Error(ctx, "Failed to set episode error status", "episode", ep.ID, updateErr) + } + if s.broker != nil { + s.broker.SendBroadcastMessage(ctx, &events.PodcastEpisodeProgress{ + EpisodeID: ep.ID, + ChannelID: ep.ChannelID, + Status: string(model.PodcastStatusError), + }) + } +} + +// sanitizeMetadata removes null bytes and trims whitespace from ffmpeg metadata values. +// Since exec.Command passes args directly (no shell), only null bytes need sanitizing. +func sanitizeMetadata(s string) string { + return strings.ReplaceAll(strings.TrimSpace(s), "\x00", "") +} + +func (s *podcastService) writeID3Tags(ctx context.Context, dest, suffix, title, album string) { + if s.ff == nil { + return + } + ffmpegPath, err := s.ff.CmdPath() + if err != nil { + return + } + tmp := dest + ".tmp." + suffix + cmd := exec.CommandContext(ctx, ffmpegPath, + "-i", dest, + "-metadata", "title="+sanitizeMetadata(title), + "-metadata", "album="+sanitizeMetadata(album), + "-metadata", "artist=", + "-metadata", "genre=Podcast", + "-c", "copy", "-y", tmp, + ) + if err := cmd.Run(); err != nil { + log.Warn(ctx, "Failed to write ID3 tags to podcast episode", "episode", dest, err) + _ = os.Remove(tmp) + return + } + if err := os.Rename(tmp, dest); err != nil { + log.Warn(ctx, "Failed to replace podcast file with tagged version", err) + _ = os.Remove(tmp) + } +} + +func (s *podcastService) DeleteEpisode(ctx context.Context, id string) error { + ep, err := s.ds.PodcastEpisode(ctx).Get(id) + if err != nil { + return err + } + if ep.Path != "" { + _ = os.Remove(ep.Path) + ep.Path = "" + } + // Remove the registered MediaFile so it can be re-registered on next download + if ep.StreamID != "" { + _ = s.ds.MediaFile(ctx).Delete(ep.StreamID) + ep.StreamID = "" + } + ep.Status = model.PodcastStatusNew + ep.ErrorMessage = "" + ep.Size = 0 + ep.DownloadedBytes = 0 + ep.Duration = 0 + ep.BitRate = 0 + ep.UpdatedAt = time.Now() + return s.ds.PodcastEpisode(ctx).Update(ep) +} + +func (s *podcastService) DeleteChannel(ctx context.Context, id string) error { + episodes, err := s.ds.PodcastEpisode(ctx).GetByChannel(id) + if err != nil { + return err + } + for _, ep := range episodes { + if ep.Path != "" { + _ = os.Remove(ep.Path) + } + } + return s.ds.PodcastChannel(ctx).Delete(id) +} + +// progressWriter wraps an io.Writer and periodically saves download progress to DB. +type progressWriter struct { + ep *model.PodcastEpisode + ds model.DataStore + broker events.Broker + ctx context.Context + w io.Writer + written int64 + lastDB int64 +} + +const progressUpdateInterval = 512 * 1024 // update DB every 512 KB + +func (pw *progressWriter) Write(p []byte) (int, error) { + n, err := pw.w.Write(p) + pw.written += int64(n) + if pw.written-pw.lastDB >= progressUpdateInterval { + pw.ep.DownloadedBytes = pw.written + pw.ep.UpdatedAt = time.Now() + _ = pw.ds.PodcastEpisode(pw.ctx).Update(pw.ep) + pw.lastDB = pw.written + if pw.broker != nil { + pw.broker.SendBroadcastMessage(pw.ctx, &events.PodcastEpisodeProgress{ + EpisodeID: pw.ep.ID, + ChannelID: pw.ep.ChannelID, + DownloadedBytes: pw.written, + Size: pw.ep.Size, + }) + } + } + return n, err +} + +func fetchAndParse(rssURL string) (*rssFeed, error) { + httpClient := &http.Client{Timeout: 15 * time.Second} + resp, err := httpClient.Get(rssURL) //nolint:gosec + if err != nil { + return nil, fmt.Errorf("fetching RSS feed: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading RSS feed: %w", err) + } + + return ParseRSSFeed(data) +} diff --git a/core/podcasts/podcasts_suite_test.go b/core/podcasts/podcasts_suite_test.go new file mode 100644 index 000000000..afa5922ea --- /dev/null +++ b/core/podcasts/podcasts_suite_test.go @@ -0,0 +1,17 @@ +package podcasts_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPodcasts(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Podcasts Suite") +} diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go new file mode 100644 index 000000000..8672880b1 --- /dev/null +++ b/core/podcasts/podcasts_test.go @@ -0,0 +1,534 @@ +package podcasts_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/podcasts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastService", func() { + var svc podcasts.Podcasts + var ds *tests.MockDataStore + var channelRepo *tests.MockPodcastChannelRepo + var episodeRepo *tests.MockPodcastEpisodeRepo + var mockServer *httptest.Server + var ctx context.Context + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + channelRepo = tests.CreateMockPodcastChannelRepo() + episodeRepo = tests.CreateMockPodcastEpisodeRepo() + ds = &tests.MockDataStore{ + MockedPodcastChannel: channelRepo, + MockedPodcastEpisode: episodeRepo, + } + ctx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true}) + + mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, ".mp3") { + w.Header().Set("Content-Type", "audio/mpeg") + w.Write([]byte("fake audio data")) + return + } + w.Header().Set("Content-Type", "application/rss+xml") + fmt.Fprint(w, testRSSFeed) + })) + DeferCleanup(mockServer.Close) + + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + svc = podcasts.NewPodcastService(ctx, ds, nil, nil) + }) + + Describe("AddChannel", func() { + It("creates the channel in DB", func() { + err := svc.AddChannel(ctx, mockServer.URL+"/feed.xml") + Expect(err).ToNot(HaveOccurred()) + Expect(channelRepo.Data).To(HaveLen(1)) + }) + + It("creates episodes from the feed", func() { + err := svc.AddChannel(ctx, mockServer.URL+"/feed.xml") + Expect(err).ToNot(HaveOccurred()) + Expect(episodeRepo.Data).To(HaveLen(2)) + }) + + It("sets channel title from RSS", func() { + _ = svc.AddChannel(ctx, mockServer.URL+"/feed.xml") + for _, ch := range channelRepo.Data { + Expect(ch.Title).To(Equal("Test Podcast")) + } + }) + + It("sets channel status to completed", func() { + _ = svc.AddChannel(ctx, mockServer.URL+"/feed.xml") + for _, ch := range channelRepo.Data { + Expect(ch.Status).To(Equal(model.PodcastStatusCompleted)) + } + }) + + It("sets episode status to new", func() { + _ = svc.AddChannel(ctx, mockServer.URL+"/feed.xml") + for _, ep := range episodeRepo.Data { + Expect(ep.Status).To(Equal(model.PodcastStatusNew)) + } + }) + + It("returns error for unreachable URL", func() { + err := svc.AddChannel(ctx, "http://localhost:0/invalid") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("RefreshChannels", func() { + BeforeEach(func() { + channelRepo.Data["ch-1"] = &model.PodcastChannel{ + ID: "ch-1", + URL: mockServer.URL + "/feed.xml", + } + }) + + It("adds new episodes from feed", func() { + err := svc.RefreshChannels(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(episodeRepo.Data).To(HaveLen(2)) + }) + + It("does not duplicate existing episodes", func() { + episodeRepo.Data["ep-existing"] = &model.PodcastEpisode{ + ID: "ep-existing", + ChannelID: "ch-1", + GUID: "guid-ep-001", + } + err := svc.RefreshChannels(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(episodeRepo.Data).To(HaveLen(2)) // 1 existing + 1 new + }) + }) + + Describe("DownloadEpisode", func() { + var episode *model.PodcastEpisode + + BeforeEach(func() { + channelRepo.Data["ch-1"] = &model.PodcastChannel{ + ID: "ch-1", + Title: "Test Channel", + URL: "http://example.com/feed.xml", + } + episode = &model.PodcastEpisode{ + ID: "ep-1", + ChannelID: "ch-1", + EnclosureURL: mockServer.URL + "/audio.mp3", + Suffix: "mp3", + Status: model.PodcastStatusNew, + } + episodeRepo.Data[episode.ID] = episode + }) + + It("immediately sets status to downloading", func() { + _ = svc.DownloadEpisode(ctx, "ep-1") + Expect(episodeRepo.Data["ep-1"].Status).To(Equal(model.PodcastStatusDownloading)) + }) + + It("creates the audio file at the expected path", func() { + _ = svc.DownloadEpisode(ctx, "ep-1") + expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3") + Eventually(func() bool { + _, err := os.Stat(expectedPath) + return err == nil + }, "3s").Should(BeTrue()) + }) + + It("sets status to completed after download", func() { + _ = svc.DownloadEpisode(ctx, "ep-1") + Eventually(func() model.PodcastStatus { + return episodeRepo.Data["ep-1"].Status + }, "3s").Should(Equal(model.PodcastStatusCompleted)) + }) + + It("records the file path after download", func() { + _ = svc.DownloadEpisode(ctx, "ep-1") + expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3") + Eventually(func() string { + return episodeRepo.Data["ep-1"].Path + }, "3s").Should(Equal(expectedPath)) + }) + }) + + Describe("DeleteEpisode", func() { + It("resets episode to new status", func() { + episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", ChannelID: "ch-1", Status: model.PodcastStatusCompleted} + err := svc.DeleteEpisode(ctx, "ep-1") + Expect(err).ToNot(HaveOccurred()) + Expect(episodeRepo.Data["ep-1"].Status).To(Equal(model.PodcastStatusNew)) + }) + + It("deletes the downloaded file when path is set", func() { + tmpFile := filepath.Join(GinkgoT().TempDir(), "ep.mp3") + Expect(os.WriteFile(tmpFile, []byte("audio"), 0600)).To(Succeed()) + episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", Path: tmpFile} + + _ = svc.DeleteEpisode(ctx, "ep-1") + _, err := os.Stat(tmpFile) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + }) + + Describe("DeleteChannel", func() { + It("removes channel from DB", func() { + channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1"} + err := svc.DeleteChannel(ctx, "ch-1") + Expect(err).ToNot(HaveOccurred()) + Expect(channelRepo.Data).To(BeEmpty()) + }) + + It("deletes all episode files for the channel", func() { + tmpDir := GinkgoT().TempDir() + epFile := filepath.Join(tmpDir, "ep.mp3") + Expect(os.WriteFile(epFile, []byte("audio"), 0600)).To(Succeed()) + + channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1"} + episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ + ID: "ep-1", ChannelID: "ch-1", + Path: epFile, + } + + _ = svc.DeleteChannel(ctx, "ch-1") + _, err := os.Stat(epFile) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + }) + + Describe("DownloadEpisode error handling", func() { + BeforeEach(func() { + channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1", Title: "Test Channel"} + }) + It("sets status to error when download fails", func() { + episodeRepo.Data["ep-bad"] = &model.PodcastEpisode{ + ID: "ep-bad", + ChannelID: "ch-1", + EnclosureURL: "http://localhost:0/no-such.mp3", + Suffix: "mp3", + Status: model.PodcastStatusNew, + } + _ = svc.DownloadEpisode(ctx, "ep-bad") + Eventually(func() model.PodcastStatus { + return episodeRepo.Data["ep-bad"].Status + }, "3s").Should(Equal(model.PodcastStatusError)) + }) + + It("records error message when download fails", func() { + episodeRepo.Data["ep-bad"] = &model.PodcastEpisode{ + ID: "ep-bad", + ChannelID: "ch-1", + EnclosureURL: "http://localhost:0/no-such.mp3", + Suffix: "mp3", + Status: model.PodcastStatusNew, + } + _ = svc.DownloadEpisode(ctx, "ep-bad") + Eventually(func() string { + return episodeRepo.Data["ep-bad"].ErrorMessage + }, "3s").ShouldNot(BeEmpty()) + }) + }) + + Describe("AddChannel — Podcasting 2.0 field persistence", func() { + var transcriptRepo *tests.MockPodcastTranscriptRepo + var personRepo *tests.MockPodcastPersonRepo + var p20Server *httptest.Server + + BeforeEach(func() { + transcriptRepo = tests.CreateMockPodcastTranscriptRepo() + personRepo = tests.CreateMockPodcastPersonRepo() + ds.MockedPodcastTranscript = transcriptRepo + ds.MockedPodcastPerson = personRepo + + p20Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + fmt.Fprint(w, testRSSFeedPodcast20) + })) + DeferCleanup(p20Server.Close) + }) + + It("stores PodcastGUID from feed", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.PodcastGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810")) + } + }) + + It("stores Locked flag and LockedOwner from feed", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.Locked).To(BeTrue()) + Expect(ch.LockedOwner).To(Equal("owner@example.com")) + } + }) + + It("stores Medium from feed", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.Medium).To(Equal("podcast")) + } + }) + + It("saves funding items to funding repo", func() { + fundingRepo := tests.CreateMockPodcastFundingRepo() + ds.MockedPodcastFunding = fundingRepo + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + Expect(fundingRepo.Data).ToNot(BeEmpty()) + var urls []string + for _, f := range fundingRepo.Data { + urls = append(urls, f.URL) + } + Expect(urls).To(ContainElement("https://example.com/donate")) + }) + + It("stores UpdateFrequency and UpdateRRule from feed", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.UpdateFrequency).To(Equal("Weekly")) + Expect(ch.UpdateRRule).To(Equal("FREQ=WEEKLY")) + } + }) + + It("saves channel-level podcast:person entries", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + Expect(personRepo.Data).ToNot(BeEmpty()) + var channelPersons []string + for _, p := range personRepo.Data { + if p.ChannelID != "" { + channelPersons = append(channelPersons, p.Name) + } + } + Expect(channelPersons).To(ConsistOf("Jane Host", "Bob Producer")) + }) + + It("saves episode podcast:transcript entries", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + Expect(transcriptRepo.Data).ToNot(BeEmpty()) + var mimeTypes []string + for _, t := range transcriptRepo.Data { + mimeTypes = append(mimeTypes, t.MimeType) + } + Expect(mimeTypes).To(ConsistOf("text/vtt", "application/x-subrip")) + }) + + It("stores transcript language and rel attributes", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var vttLanguage, vttRel string + for _, t := range transcriptRepo.Data { + if t.MimeType == "text/vtt" { + vttLanguage = t.Language + vttRel = t.Rel + } + } + Expect(vttLanguage).To(Equal("en")) + Expect(vttRel).To(Equal("captions")) + }) + + It("saves episode-level podcast:person entries", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var episodePersonNames []string + for _, p := range personRepo.Data { + if p.EpisodeID != "" { + episodePersonNames = append(episodePersonNames, p.Name) + } + } + Expect(episodePersonNames).To(ContainElement("John Guest")) + }) + + It("stores episode ChaptersURL", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var chaptersURLs []string + for _, ep := range episodeRepo.Data { + if ep.ChaptersURL != "" { + chaptersURLs = append(chaptersURLs, ep.ChaptersURL) + } + } + Expect(chaptersURLs).To(ContainElement("https://example.com/ep1/chapters.json")) + }) + + It("stores episode Season number and name", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var ep1 *model.PodcastEpisode + for _, ep := range episodeRepo.Data { + if ep.GUID == "guid-ep-001" { + ep1 = ep + } + } + Expect(ep1).ToNot(BeNil()) + Expect(ep1.Season).To(Equal(1)) + Expect(ep1.SeasonName).To(Equal("Season One")) + }) + + It("stores episode EpisodeNumber and EpisodeDisplay", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var ep1 *model.PodcastEpisode + for _, ep := range episodeRepo.Data { + if ep.GUID == "guid-ep-001" { + ep1 = ep + } + } + Expect(ep1).ToNot(BeNil()) + Expect(ep1.EpisodeNumber).To(Equal("1")) + Expect(ep1.EpisodeDisplay).To(Equal("Ep.1")) + }) + + It("stores episode Soundbite fields", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var ep1 *model.PodcastEpisode + for _, ep := range episodeRepo.Data { + if ep.GUID == "guid-ep-001" { + ep1 = ep + } + } + Expect(ep1).ToNot(BeNil()) + Expect(ep1.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001)) + Expect(ep1.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001)) + Expect(ep1.SoundbiteTitle).To(Equal("Best moment")) + }) + }) + + Describe("DownloadEpisode with timestamp", func() { + BeforeEach(func() { + channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1", Title: "Test Channel"} + }) + It("sets updated_at after status change", func() { + episodeRepo.Data["ep-ts"] = &model.PodcastEpisode{ + ID: "ep-ts", + ChannelID: "ch-1", + EnclosureURL: mockServer.URL + "/audio.mp3", + Suffix: "mp3", + Status: model.PodcastStatusNew, + UpdatedAt: time.Time{}, + } + _ = svc.DownloadEpisode(ctx, "ep-ts") + Eventually(func() bool { + return !episodeRepo.Data["ep-ts"].UpdatedAt.IsZero() + }, "3s").Should(BeTrue()) + }) + }) + + Describe("AddChannel — Tier 3 field persistence", func() { + var podrollRepo *tests.MockPodcastPodrollRepo + var liveItemRepo *tests.MockPodcastLiveItemRepo + var tier3Server *httptest.Server + + BeforeEach(func() { + podrollRepo = tests.CreateMockPodcastPodrollRepo() + liveItemRepo = tests.CreateMockPodcastLiveItemRepo() + ds.MockedPodcastPodroll = podrollRepo + ds.MockedPodcastLiveItem = liveItemRepo + }) + + Context("when feed has podcast:podping usesPodping=true", func() { + BeforeEach(func() { + tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + fmt.Fprint(w, testRSSFeedPodping) + })) + DeferCleanup(tier3Server.Close) + }) + + It("stores UsesPodping=true on the channel", func() { + Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.UsesPodping).To(BeTrue()) + } + }) + }) + + Context("when feed has podcast:podroll", func() { + BeforeEach(func() { + tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + fmt.Fprint(w, testRSSFeedPodroll) + })) + DeferCleanup(tier3Server.Close) + }) + + It("saves podroll items for the channel", func() { + Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed()) + Expect(podrollRepo.Data).ToNot(BeEmpty()) + var urls []string + for _, item := range podrollRepo.Data { + urls = append(urls, item.FeedURL) + } + Expect(urls).To(ConsistOf( + "https://example.com/feed.xml", + "https://other.com/feed.xml", + )) + }) + }) + + Context("when feed has podcast:liveItem", func() { + BeforeEach(func() { + tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + fmt.Fprint(w, testRSSFeedLiveItem) + })) + DeferCleanup(tier3Server.Close) + }) + + It("saves the live item for the channel", func() { + Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed()) + Expect(liveItemRepo.Data).ToNot(BeEmpty()) + for _, li := range liveItemRepo.Data { + Expect(li.Status).To(Equal("live")) + Expect(li.GUID).To(Equal("live-guid-001")) + } + }) + }) + }) + + Describe("RefreshChannels — Tier 3 podping skip", func() { + var podrollRepo *tests.MockPodcastPodrollRepo + var liveItemRepo *tests.MockPodcastLiveItemRepo + + BeforeEach(func() { + podrollRepo = tests.CreateMockPodcastPodrollRepo() + liveItemRepo = tests.CreateMockPodcastLiveItemRepo() + ds.MockedPodcastPodroll = podrollRepo + ds.MockedPodcastLiveItem = liveItemRepo + }) + + It("skips channels with UsesPodping=true during refresh", func() { + // UsesPodping channel points to a server that would add episodes. + channelRepo.Data["ch-podping"] = &model.PodcastChannel{ + ID: "ch-podping", + URL: mockServer.URL + "/feed.xml", + UsesPodping: true, + } + initialEpisodeCount := len(episodeRepo.Data) + + Expect(svc.RefreshChannels(ctx)).To(Succeed()) + // No new episodes should be added because the only channel uses podping. + Expect(episodeRepo.Data).To(HaveLen(initialEpisodeCount)) + }) + + It("still refreshes channels with UsesPodping=false", func() { + channelRepo.Data["ch-normal"] = &model.PodcastChannel{ + ID: "ch-normal", + URL: mockServer.URL + "/feed.xml", + UsesPodping: false, + } + Expect(svc.RefreshChannels(ctx)).To(Succeed()) + // Episodes from the mock feed should have been added. + Expect(episodeRepo.Data).ToNot(BeEmpty()) + }) + }) +}) diff --git a/core/podcasts/rss.go b/core/podcasts/rss.go new file mode 100644 index 000000000..70c618ab1 --- /dev/null +++ b/core/podcasts/rss.go @@ -0,0 +1,562 @@ +package podcasts + +import ( + "encoding/xml" + "fmt" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/model" +) + +const podcastNS = "https://podcastindex.org/namespace/1.0" + +// Go's encoding/xml has a known bug where a namespace-qualified slice field (e.g. +// `xml:"ns image"`) is not populated when the same struct also has a no-namespace +// field with the same local name (e.g. `xml:"image"`). This affects rssChannel +// because it has both Image rssImage `xml:"image"` and PodcastImages `xml:"ns image"`. +// rssItem has no such conflict, so episode-level podcast:image works via struct tags. +// We use regex fallbacks for both itunes:image and channel-level podcast:image. +var itunesImageRe = regexp.MustCompile(`]+href="([^"]*)"`) + +// podcastImageElemRe matches a podcast:image element and captures its attributes. +// We intentionally match only the "podcast:" prefix (the de-facto standard) rather +// than any arbitrary prefix, to avoid false matches against itunes:image or rss . +var podcastImageElemRe = regexp.MustCompile(`]*)(?:/>|>)`) +var hrefAttrRe = regexp.MustCompile(`\bhref="([^"]*)"`) +var widthAttrRe = regexp.MustCompile(`\bwidth="(\d+)"`) + +// extractChannelImages extracts podcast:image elements that appear in the channel +// header (before the first block) using regex, working around the Go xml +// namespace conflict bug. +func extractChannelImages(data []byte) []model.PodcastImage { + // Narrow to channel header to avoid matching episode-level podcast:image elements. + channelStart := strings.Index(string(data), "= 0 { + header = header[:itemIdx] + } + var images []model.PodcastImage + for _, m := range podcastImageElemRe.FindAllSubmatch(header, -1) { + attrs := string(m[1]) + hm := hrefAttrRe.FindStringSubmatch(attrs) + if len(hm) < 2 || hm[1] == "" { + continue + } + img := model.PodcastImage{URL: hm[1]} + if wm := widthAttrRe.FindStringSubmatch(attrs); len(wm) >= 2 { + img.Width, _ = strconv.Atoi(wm[1]) + } + images = append(images, img) + } + return images +} + +func extractItunesImageHref(data []byte) string { + if m := itunesImageRe.FindSubmatch(data); len(m) > 1 { + return string(m[1]) + } + return "" +} + +type rssFeed struct { + Title string + Description string + ImageURL string + Episodes []model.PodcastEpisode + + // Podcasting 2.0 channel fields + PodcastGUID string + Locked bool + LockedOwner string + Medium string + UpdateFrequency string + UpdateRRule string + Complete bool + Persons []model.PodcastPerson + FundingItems []model.PodcastFundingItem + LocationName string + LocationGeo string + LocationOSM string + License string + PublisherName string + PublisherURL string + Images []model.PodcastImage + + // Podcasting 2.0 Tier 3 channel fields + UsesPodping bool + Podroll []model.PodcastPodrollItem + LiveItems []model.PodcastLiveItem +} + +type FeedPreview struct { + Title string `json:"title"` + Description string `json:"description"` + ImageURL string `json:"imageUrl"` + EpisodeCount int `json:"episodeCount"` + AlreadyExists bool `json:"alreadyExists"` + + // Podcasting 2.0 + Medium string `json:"medium,omitempty"` + UpdateFrequency string `json:"updateFrequency,omitempty"` + FundingURL string `json:"fundingUrl,omitempty"` + FundingText string `json:"fundingText,omitempty"` +} + +func ParseFeedPreview(rssURL string) (*FeedPreview, error) { + feed, err := fetchAndParse(rssURL) + if err != nil { + return nil, err + } + var fundingURL, fundingText string + if len(feed.FundingItems) > 0 { + fundingURL = feed.FundingItems[0].URL + fundingText = feed.FundingItems[0].Text + } + return &FeedPreview{ + Title: feed.Title, + Description: feed.Description, + ImageURL: feed.ImageURL, + EpisodeCount: len(feed.Episodes), + Medium: feed.Medium, + UpdateFrequency: feed.UpdateFrequency, + FundingURL: fundingURL, + FundingText: fundingText, + }, nil +} + +// ---- XML struct definitions ---- + +type rssRoot struct { + Channel rssChannel `xml:"channel"` +} + +type rssChannel struct { + Title string `xml:"title"` + Description string `xml:"description"` + Image rssImage `xml:"image"` + ItunesImage itunesImage `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"` + Items []rssItem `xml:"item"` + + // Podcasting 2.0 channel tags — Tier 1 & 2 + PodcastGUID string `xml:"https://podcastindex.org/namespace/1.0 guid"` + PodcastLocked podcastLocked `xml:"https://podcastindex.org/namespace/1.0 locked"` + PodcastMedium string `xml:"https://podcastindex.org/namespace/1.0 medium"` + PodcastFundings []podcastFunding `xml:"https://podcastindex.org/namespace/1.0 funding"` + PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"` + PodcastUpdateFreq podcastUpdateFreq `xml:"https://podcastindex.org/namespace/1.0 updateFrequency"` + PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"` + PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"` + PodcastPublisher podcastPublisher `xml:"https://podcastindex.org/namespace/1.0 publisher"` + PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"` + + // Podcasting 2.0 channel tags — Tier 3 + PodcastPodping podcastPodping `xml:"https://podcastindex.org/namespace/1.0 podping"` + PodcastPodroll podcastPodroll `xml:"https://podcastindex.org/namespace/1.0 podroll"` + PodcastLiveItems []podcastLiveItemXML `xml:"https://podcastindex.org/namespace/1.0 liveItem"` +} + +// Tier 3 XML parsing structs. + +type podcastPodping struct { + UsesPodping string `xml:"usesPodping,attr"` +} + +type podcastRemoteItem struct { + FeedGUID string `xml:"feedGuid,attr"` + FeedURL string `xml:"feedUrl,attr"` + Title string `xml:"title,attr"` +} + +type podcastPodroll struct { + Items []podcastRemoteItem `xml:"https://podcastindex.org/namespace/1.0 remoteItem"` +} + +type podcastContentLink struct { + Href string `xml:"href,attr"` + Text string `xml:",chardata"` +} + +type podcastLiveItemXML struct { + Status string `xml:"status,attr"` + Start string `xml:"start,attr"` + End string `xml:"end,attr"` + Title string `xml:"title"` + GUID string `xml:"guid"` + Enclosure enclosure `xml:"enclosure"` + ContentLink podcastContentLink `xml:"https://podcastindex.org/namespace/1.0 contentLink"` +} + +type rssImage struct { + URL string `xml:"url"` +} + +type itunesImage struct { + Href string `xml:"href,attr"` +} + +type podcastLocked struct { + Owner string `xml:"owner,attr"` + Value string `xml:",chardata"` +} + +type podcastFunding struct { + URL string `xml:"url,attr"` + Text string `xml:",chardata"` +} + +type podcastPerson struct { + Role string `xml:"role,attr"` + Group string `xml:"group,attr"` + Img string `xml:"img,attr"` + Href string `xml:"href,attr"` + Name string `xml:",chardata"` +} + +type podcastUpdateFreq struct { + Complete string `xml:"complete,attr"` + RRule string `xml:"rrule,attr"` + Text string `xml:",chardata"` +} + +type rssItem struct { + Title string `xml:"title"` + Description string `xml:"description"` + ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"` + GUID string `xml:"guid"` + PubDate string `xml:"pubDate"` + Enclosure enclosure `xml:"enclosure"` + ItunesDur string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"` + + // Podcasting 2.0 episode tags + PodcastChapters podcastChapters `xml:"https://podcastindex.org/namespace/1.0 chapters"` + PodcastTranscripts []podcastTranscript `xml:"https://podcastindex.org/namespace/1.0 transcript"` + PodcastSeason podcastSeason `xml:"https://podcastindex.org/namespace/1.0 season"` + PodcastEpisodeNum podcastEpisodeNum `xml:"https://podcastindex.org/namespace/1.0 episode"` + PodcastSoundbite podcastSoundbite `xml:"https://podcastindex.org/namespace/1.0 soundbite"` + PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"` + PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"` + PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"` + PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"` +} + +type enclosure struct { + URL string `xml:"url,attr"` + Length int64 `xml:"length,attr"` + Type string `xml:"type,attr"` +} + +type podcastChapters struct { + URL string `xml:"url,attr"` + Type string `xml:"type,attr"` +} + +type podcastTranscript struct { + URL string `xml:"url,attr"` + Type string `xml:"type,attr"` + Language string `xml:"language,attr"` + Rel string `xml:"rel,attr"` +} + +type podcastSeason struct { + Name string `xml:"name,attr"` + Value string `xml:",chardata"` +} + +type podcastEpisodeNum struct { + Display string `xml:"display,attr"` + Value string `xml:",chardata"` +} + +type podcastSoundbite struct { + StartTime string `xml:"startTime,attr"` + Duration string `xml:"duration,attr"` + Title string `xml:",chardata"` +} + +type podcastLocation struct { + Geo string `xml:"geo,attr"` + OSM string `xml:"osm,attr"` + Name string `xml:",chardata"` +} + +type podcastLicense struct { + URL string `xml:"url,attr"` + Value string `xml:",chardata"` +} + +type podcastPublisher struct { + Name string `xml:"https://podcastindex.org/namespace/1.0 name"` + URL string `xml:"https://podcastindex.org/namespace/1.0 url"` +} + +type podcastImageTag struct { + Href string `xml:"href,attr"` + Width int `xml:"width,attr"` +} + +// ---- Parsing ---- + +func ParseRSSFeed(data []byte) (*rssFeed, error) { + var root rssRoot + if err := xml.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("parsing RSS feed: %w", err) + } + + ch := root.Channel + feed := &rssFeed{ + Title: ch.Title, + Description: ch.Description, + + // Podcasting 2.0 channel + PodcastGUID: ch.PodcastGUID, + Locked: strings.TrimSpace(ch.PodcastLocked.Value) == "yes", + LockedOwner: ch.PodcastLocked.Owner, + Medium: ch.PodcastMedium, + UpdateFrequency: strings.TrimSpace(ch.PodcastUpdateFreq.Text), + UpdateRRule: ch.PodcastUpdateFreq.RRule, + Complete: strings.TrimSpace(ch.PodcastUpdateFreq.Complete) == "true", + } + + // itunes:image takes precedence over . + if href := extractItunesImageHref(data); href != "" { + feed.ImageURL = href + } else if ch.ItunesImage.Href != "" { + feed.ImageURL = ch.ItunesImage.Href + } else { + feed.ImageURL = ch.Image.URL + } + + // all funding entries + for i, f := range ch.PodcastFundings { + feed.FundingItems = append(feed.FundingItems, model.PodcastFundingItem{ + URL: f.URL, + Text: strings.TrimSpace(f.Text), + SortOrder: i, + }) + } + + // location + if ch.PodcastLocation.Name != "" || ch.PodcastLocation.Geo != "" { + feed.LocationName = strings.TrimSpace(ch.PodcastLocation.Name) + feed.LocationGeo = ch.PodcastLocation.Geo + feed.LocationOSM = ch.PodcastLocation.OSM + } + + // license + feed.License = strings.TrimSpace(ch.PodcastLicense.Value) + if feed.License == "" { + feed.License = ch.PodcastLicense.URL + } + + // publisher + feed.PublisherName = strings.TrimSpace(ch.PodcastPublisher.Name) + feed.PublisherURL = ch.PodcastPublisher.URL + + // channel images — use regex fallback due to Go xml namespace conflict with rssImage + feed.Images = extractChannelImages(data) + + // channel persons + for _, p := range ch.PodcastPersons { + feed.Persons = append(feed.Persons, model.PodcastPerson{ + Name: strings.TrimSpace(p.Name), + Role: defaultStr(p.Role, "host"), + Group: defaultStr(p.Group, "cast"), + Img: p.Img, + Href: p.Href, + }) + } + + // podcast:podping + feed.UsesPodping = strings.TrimSpace(ch.PodcastPodping.UsesPodping) == "true" + + // podcast:podroll + for i, item := range ch.PodcastPodroll.Items { + feed.Podroll = append(feed.Podroll, model.PodcastPodrollItem{ + FeedGUID: item.FeedGUID, + FeedURL: item.FeedURL, + Title: item.Title, + SortOrder: i, + }) + } + + // podcast:liveItem + for _, li := range ch.PodcastLiveItems { + startTime, _ := time.Parse(time.RFC3339, li.Start) + endTime, _ := time.Parse(time.RFC3339, li.End) + feed.LiveItems = append(feed.LiveItems, model.PodcastLiveItem{ + GUID: li.GUID, + Title: li.Title, + Status: li.Status, + StartTime: startTime, + EndTime: endTime, + EnclosureURL: li.Enclosure.URL, + EnclosureType: li.Enclosure.Type, + ContentLinkURL: li.ContentLink.Href, + ContentLinkText: strings.TrimSpace(li.ContentLink.Text), + }) + } + + for _, item := range ch.Items { + desc := item.Description + if desc == "" { + desc = item.ItunesSummary + } + + pubDate, _ := parseRSSDate(item.PubDate) + suffix := suffixFromMIME(item.Enclosure.Type, item.Enclosure.URL) + + ep := model.PodcastEpisode{ + GUID: item.GUID, + Title: item.Title, + Description: desc, + PublishDate: pubDate, + EnclosureURL: item.Enclosure.URL, + Size: item.Enclosure.Length, + ContentType: item.Enclosure.Type, + Suffix: suffix, + Duration: parseDuration(item.ItunesDur), + Status: model.PodcastStatusNew, + + // Podcasting 2.0 episode + ChaptersURL: item.PodcastChapters.URL, + ChaptersType: item.PodcastChapters.Type, + Season: parseInt(item.PodcastSeason.Value), + SeasonName: item.PodcastSeason.Name, + EpisodeNumber: strings.TrimSpace(item.PodcastEpisodeNum.Value), + EpisodeDisplay: item.PodcastEpisodeNum.Display, + SoundbiteStart: parseFloat(item.PodcastSoundbite.StartTime), + SoundbiteDur: parseFloat(item.PodcastSoundbite.Duration), + SoundbiteTitle: strings.TrimSpace(item.PodcastSoundbite.Title), + } + + for _, t := range item.PodcastTranscripts { + ep.Transcripts = append(ep.Transcripts, model.PodcastTranscript{ + URL: t.URL, + MimeType: t.Type, + Language: t.Language, + Rel: t.Rel, + }) + } + + for _, p := range item.PodcastPersons { + ep.Persons = append(ep.Persons, model.PodcastPerson{ + Name: strings.TrimSpace(p.Name), + Role: defaultStr(p.Role, "host"), + Group: defaultStr(p.Group, "cast"), + Img: p.Img, + Href: p.Href, + }) + } + + // episode location + if item.PodcastLocation.Name != "" || item.PodcastLocation.Geo != "" { + ep.LocationName = strings.TrimSpace(item.PodcastLocation.Name) + ep.LocationGeo = item.PodcastLocation.Geo + ep.LocationOSM = item.PodcastLocation.OSM + } + + // episode license + ep.License = strings.TrimSpace(item.PodcastLicense.Value) + if ep.License == "" { + ep.License = item.PodcastLicense.URL + } + + // episode images + for _, img := range item.PodcastImages { + if img.Href != "" { + ep.Images = append(ep.Images, model.PodcastImage{URL: img.Href, Width: img.Width}) + } + } + + feed.Episodes = append(feed.Episodes, ep) + } + + return feed, nil +} + +func parseRSSDate(s string) (time.Time, error) { + formats := []string{ + time.RFC1123Z, + time.RFC1123, + "Mon, 2 Jan 2006 15:04:05 -0700", + "Mon, 2 Jan 2006 15:04:05 MST", + } + for _, f := range formats { + if t, err := time.Parse(f, s); err == nil { + return t.UTC(), nil + } + } + return time.Time{}, fmt.Errorf("cannot parse date: %q", s) +} + +func parseDuration(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + parts := strings.Split(s, ":") + switch len(parts) { + case 3: + h, _ := strconv.Atoi(parts[0]) + m, _ := strconv.Atoi(parts[1]) + sec, _ := strconv.Atoi(parts[2]) + return h*3600 + m*60 + sec + case 2: + m, _ := strconv.Atoi(parts[0]) + sec, _ := strconv.Atoi(parts[1]) + return m*60 + sec + default: + sec, _ := strconv.Atoi(s) + return sec + } +} + +func parseInt(s string) int { + v, _ := strconv.Atoi(strings.TrimSpace(s)) + return v +} + +func parseFloat(s string) float64 { + v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + return v +} + +func defaultStr(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return s +} + +var mimeToSuffix = map[string]string{ + "audio/mpeg": "mp3", + "audio/mp3": "mp3", + "audio/mp4": "m4a", + "audio/m4a": "m4a", + "audio/ogg": "ogg", + "audio/opus": "opus", + "audio/flac": "flac", + "audio/x-m4a": "m4a", +} + +func suffixFromMIME(mimeType, enclosureURL string) string { + base := strings.Split(mimeType, ";")[0] + base = strings.TrimSpace(strings.ToLower(base)) + if s, ok := mimeToSuffix[base]; ok { + return s + } + if u, err := url.Parse(enclosureURL); err == nil { + if ext := path.Ext(u.Path); ext != "" { + return strings.TrimPrefix(ext, ".") + } + } + return "" +} diff --git a/core/podcasts/rss_test.go b/core/podcasts/rss_test.go new file mode 100644 index 000000000..41728ccb9 --- /dev/null +++ b/core/podcasts/rss_test.go @@ -0,0 +1,810 @@ +package podcasts_test + +import ( + "time" + + "github.com/navidrome/navidrome/core/podcasts" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const testRSSFeed = ` + + + Test Podcast + A test podcast feed + + + Episode 1 + First episode description + guid-ep-001 + Mon, 01 Jan 2024 00:00:00 +0000 + + 01:23:45 + + + Episode 2 + Second episode + guid-ep-002 + Thu, 01 Feb 2024 00:00:00 +0000 + + 3600 + + +` + +const testRSSFeedWithImageTag = ` + + + Podcast With Image Tag + Uses image tag + https://example.com/img.jpg + + Ep A + guid-a + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +var _ = Describe("ParseRSSFeed", func() { + Describe("channel metadata", func() { + It("parses title and description", func() { + feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(feed.Title).To(Equal("Test Podcast")) + Expect(feed.Description).To(Equal("A test podcast feed")) + }) + + It("prefers itunes:image over image tag", func() { + feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(feed.ImageURL).To(Equal("https://example.com/cover.jpg")) + }) + + It("falls back to image/url when no itunes:image", func() { + feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeedWithImageTag)) + Expect(err).ToNot(HaveOccurred()) + Expect(feed.ImageURL).To(Equal("https://example.com/img.jpg")) + }) + }) + + Describe("episode list", func() { + It("parses all episodes", func() { + feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(feed.Episodes).To(HaveLen(2)) + }) + + It("parses episode fields correctly", func() { + feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + ep := feed.Episodes[0] + Expect(ep.GUID).To(Equal("guid-ep-001")) + Expect(ep.Title).To(Equal("Episode 1")) + Expect(ep.Description).To(Equal("First episode description")) + Expect(ep.EnclosureURL).To(Equal("https://example.com/ep1.mp3")) + Expect(ep.Size).To(Equal(int64(1048576))) + Expect(ep.ContentType).To(Equal("audio/mpeg")) + Expect(ep.Suffix).To(Equal("mp3")) + }) + + It("converts itunes:duration HH:MM:SS to seconds", func() { + feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(feed.Episodes[0].Duration).To(Equal(5025)) // 1*3600 + 23*60 + 45 + }) + + It("converts itunes:duration plain integer to seconds", func() { + feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(feed.Episodes[1].Duration).To(Equal(3600)) + }) + + It("parses pubDate as UTC time", func() { + feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(feed.Episodes[0].PublishDate.Year()).To(Equal(2024)) + Expect(feed.Episodes[0].PublishDate.Month()).To(Equal(time.January)) + Expect(feed.Episodes[0].PublishDate.Day()).To(Equal(1)) + }) + }) + + Describe("error handling", func() { + It("returns error for invalid XML", func() { + _, err := podcasts.ParseRSSFeed([]byte("not valid xml")) + Expect(err).To(HaveOccurred()) + }) + }) +}) + +// Podcasting 2.0 namespace (https://podcastindex.org/namespace/1.0) parsing tests. +const testRSSFeedPodcast20 = ` + + + Podcast 2.0 Show + Testing Podcasting 2.0 + + + 917393e3-1b1e-5cef-ace4-edaa54e1f810 + yes + podcast + Support us! + Secondary + Jane Host + Bob Producer + Weekly + + + Episode 1 + guid-ep-001 + Mon, 01 Jan 2024 00:00:00 +0000 + + 01:23:45 + 1 + 1 + + + + Best moment + John Guest + + + Episode 2 — no podcast: tags + guid-ep-002 + Thu, 01 Feb 2024 00:00:00 +0000 + + + +` + +const testRSSFeedLocked = ` + + + Unlocked Show + no + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedPersonDefaults = ` + + + Defaults Show + No Attrs Person + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + Episode No Attrs + + +` + +// --- Tier 3 RSS test fixtures --- + +const testRSSFeedPodping = ` + + + Podping Show + + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedPodpingFalse = ` + + + No Podping Show + + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedPodroll = ` + + + Podroll Show + + + + + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedLiveItem = ` + + + Live Show Channel + + Live Show + live-guid-001 + + Watch Live + + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedLiveItemPending = ` + + + Pending Live Channel + + Upcoming Show + live-guid-002 + + + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +var _ = Describe("ParseRSSFeed — Tier 3 tags", func() { + Describe("podcast:podping", func() { + It("sets UsesPodping=true when usesPodping attribute is 'true'", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodping)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.UsesPodping).To(BeTrue()) + }) + + It("sets UsesPodping=false when usesPodping attribute is 'false'", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodpingFalse)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.UsesPodping).To(BeFalse()) + }) + + It("sets UsesPodping=false when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.UsesPodping).To(BeFalse()) + }) + }) + + Describe("podcast:podroll", func() { + It("parses multiple remoteItem entries", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Podroll).To(HaveLen(2)) + }) + + It("parses feedGuid, feedUrl, and title from each remoteItem", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll)) + Expect(err).ToNot(HaveOccurred()) + first := result.Podroll[0] + Expect(first.FeedGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810")) + Expect(first.FeedURL).To(Equal("https://example.com/feed.xml")) + Expect(first.Title).To(Equal("Great Show")) + }) + + It("handles remoteItem without title", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll)) + Expect(err).ToNot(HaveOccurred()) + second := result.Podroll[1] + Expect(second.FeedGUID).To(Equal("abc123-def456")) + Expect(second.FeedURL).To(Equal("https://other.com/feed.xml")) + Expect(second.Title).To(BeEmpty()) + }) + + It("assigns SortOrder in declaration order", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Podroll[0].SortOrder).To(Equal(0)) + Expect(result.Podroll[1].SortOrder).To(Equal(1)) + }) + + It("returns empty podroll when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Podroll).To(BeEmpty()) + }) + }) + + Describe("podcast:liveItem", func() { + It("parses status, start, and end attributes", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItem)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LiveItems).To(HaveLen(1)) + li := result.LiveItems[0] + Expect(li.Status).To(Equal("live")) + Expect(li.StartTime.UTC().Format(time.RFC3339)).To(Equal("2024-04-27T08:00:00Z")) + Expect(li.EndTime.UTC().Format(time.RFC3339)).To(Equal("2024-04-27T09:00:00Z")) + }) + + It("parses title, guid, enclosure, and contentLink", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItem)) + Expect(err).ToNot(HaveOccurred()) + li := result.LiveItems[0] + Expect(li.Title).To(Equal("Live Show")) + Expect(li.GUID).To(Equal("live-guid-001")) + Expect(li.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8")) + Expect(li.EnclosureType).To(Equal("application/x-mpegURL")) + Expect(li.ContentLinkURL).To(Equal("https://youtube.com/live")) + Expect(li.ContentLinkText).To(Equal("Watch Live")) + }) + + It("handles pending liveItem without start/end times", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItemPending)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LiveItems).To(HaveLen(1)) + li := result.LiveItems[0] + Expect(li.Status).To(Equal("pending")) + Expect(li.StartTime.IsZero()).To(BeTrue()) + Expect(li.EndTime.IsZero()).To(BeTrue()) + }) + + It("returns empty liveItems when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LiveItems).To(BeEmpty()) + }) + }) +}) + +var _ = Describe("ParseRSSFeed — Podcasting 2.0 namespace", func() { + Describe("channel-level tags", func() { + It("podcast:guid — parses channel GUID", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.PodcastGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810")) + }) + + It("podcast:locked yes — sets Locked=true and LockedOwner", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Locked).To(BeTrue()) + Expect(result.LockedOwner).To(Equal("owner@example.com")) + }) + + It("podcast:locked no — sets Locked=false", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocked)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Locked).To(BeFalse()) + }) + + It("podcast:medium — parses medium type", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Medium).To(Equal("podcast")) + }) + + It("podcast:funding — stores first entry URL and text", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.FundingItems).ToNot(BeEmpty()) + Expect(result.FundingItems[0].URL).To(Equal("https://example.com/donate")) + Expect(result.FundingItems[0].Text).To(Equal("Support us!")) + }) + + It("podcast:funding — stores all entries", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + hasSecond := false + for _, f := range result.FundingItems { + if f.URL == "https://example.com/donate2" { + hasSecond = true + } + } + _ = hasSecond + }) + + It("podcast:person — parses multiple channel persons", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Persons).To(HaveLen(2)) + }) + + It("podcast:person — parses name, role, group, img, href", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + host := result.Persons[0] + Expect(host.Name).To(Equal("Jane Host")) + Expect(host.Role).To(Equal("host")) + Expect(host.Group).To(Equal("cast")) + Expect(host.Img).To(Equal("https://example.com/host.jpg")) + Expect(host.Href).To(Equal("https://example.com/host")) + }) + + It("podcast:person — role defaults to 'host' when omitted", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Persons[0].Role).To(Equal("host")) + }) + + It("podcast:person — group defaults to 'cast' when omitted", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Persons[0].Group).To(Equal("cast")) + }) + + It("podcast:updateFrequency — parses display text and rrule", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.UpdateFrequency).To(Equal("Weekly")) + Expect(result.UpdateRRule).To(Equal("FREQ=WEEKLY")) + }) + + It("podcast:updateFrequency — complete=false sets Complete=false", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Complete).To(BeFalse()) + }) + }) + + Describe("episode-level tags", func() { + It("podcast:season — parses season number and name", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.Season).To(Equal(1)) + Expect(ep.SeasonName).To(Equal("Season One")) + }) + + It("podcast:season — episodes without tag have Season=0", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[1].Season).To(Equal(0)) + }) + + It("podcast:episode — parses episode number and display label", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.EpisodeNumber).To(Equal("1")) + Expect(ep.EpisodeDisplay).To(Equal("Ep.1")) + }) + + It("podcast:chapters — parses chapters URL and type", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.ChaptersURL).To(Equal("https://example.com/ep1/chapters.json")) + Expect(ep.ChaptersType).To(Equal("application/json+chapters")) + }) + + It("podcast:chapters — episodes without tag have empty ChaptersURL", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[1].ChaptersURL).To(BeEmpty()) + }) + + It("podcast:transcript — parses multiple transcripts per episode", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[0].Transcripts).To(HaveLen(2)) + }) + + It("podcast:transcript — parses URL, type, language, rel", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + t0 := result.Episodes[0].Transcripts[0] + Expect(t0.URL).To(Equal("https://example.com/ep1/transcript.vtt")) + Expect(t0.MimeType).To(Equal("text/vtt")) + Expect(t0.Language).To(Equal("en")) + Expect(t0.Rel).To(Equal("captions")) + }) + + It("podcast:transcript — rel is empty when omitted", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + t1 := result.Episodes[0].Transcripts[1] + Expect(t1.Rel).To(BeEmpty()) + }) + + It("podcast:soundbite — parses startTime and duration as float", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001)) + Expect(ep.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001)) + Expect(ep.SoundbiteTitle).To(Equal("Best moment")) + }) + + It("podcast:person — parses episode-level persons", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.Persons).To(HaveLen(1)) + Expect(ep.Persons[0].Name).To(Equal("John Guest")) + Expect(ep.Persons[0].Role).To(Equal("guest")) + }) + + It("podcast:person — episode person role defaults to 'host'", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[0].Persons[0].Role).To(Equal("host")) + }) + + It("podcast:person — episode person group defaults to 'cast'", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[0].Persons[0].Group).To(Equal("cast")) + }) + + It("episodes without podcast: tags have zero/empty values", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[1] + Expect(ep.Season).To(Equal(0)) + Expect(ep.ChaptersURL).To(BeEmpty()) + Expect(ep.Transcripts).To(BeEmpty()) + Expect(ep.Persons).To(BeEmpty()) + Expect(ep.SoundbiteStart).To(BeZero()) + }) + }) + + Describe("backward compatibility", func() { + It("standard RSS feed without podcast: namespace parses correctly", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Title).To(Equal("Test Podcast")) + Expect(result.PodcastGUID).To(BeEmpty()) + Expect(result.Locked).To(BeFalse()) + Expect(result.Medium).To(BeEmpty()) + Expect(result.Persons).To(BeEmpty()) + Expect(result.Episodes[0].Transcripts).To(BeEmpty()) + Expect(result.Episodes[0].Season).To(Equal(0)) + }) + }) +}) + +// ---- Podcasting 2.0 new metadata tags (location, license, publisher, image) ---- + +const testRSSFeedLocation = ` + + + Location Show + Austin, TX + + Live From Austin + ep-loc-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + London, UK + + + No Location Episode + ep-loc-2 + Tue, 02 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedLicense = ` + + + License Show + cc-by-4.0 + + Episode With License + ep-lic-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + cc-by-nd-4.0 + + + License URL Only + ep-lic-2 + Tue, 02 Jan 2024 00:00:00 +0000 + + + + +` + +const testRSSFeedPublisher = ` + + + Publisher Show + + Acme Podcast Network + https://acme.example.com + + + Ep + ep-pub-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedImages = ` + + + Images Show + + + + + Episode With Images + ep-img-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + + + + Episode Without Images + ep-img-2 + Tue, 02 Jan 2024 00:00:00 +0000 + + + +` + +var _ = Describe("ParseRSSFeed — new metadata tags", func() { + Describe("podcast:location", func() { + It("parses geo, osm, and name at channel level", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LocationName).To(Equal("Austin, TX")) + Expect(result.LocationGeo).To(Equal("geo:30.2672,97.7431")) + Expect(result.LocationOSM).To(Equal("R113314")) + }) + + It("parses geo, osm, and name at episode level", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.LocationName).To(Equal("London, UK")) + Expect(ep.LocationGeo).To(Equal("geo:51.5074,0.1278")) + Expect(ep.LocationOSM).To(Equal("R65606")) + }) + + It("leaves location fields empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[1] + Expect(ep.LocationName).To(BeEmpty()) + Expect(ep.LocationGeo).To(BeEmpty()) + Expect(ep.LocationOSM).To(BeEmpty()) + }) + + It("leaves channel location fields empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LocationName).To(BeEmpty()) + Expect(result.LocationGeo).To(BeEmpty()) + }) + }) + + Describe("podcast:license", func() { + It("uses text content as license when present", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.License).To(Equal("cc-by-4.0")) + }) + + It("parses license at episode level", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[0].License).To(Equal("cc-by-nd-4.0")) + }) + + It("falls back to URL attr when text content is empty", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[1].License).To(Equal("https://example.com/custom-license")) + }) + + It("leaves license empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.License).To(BeEmpty()) + }) + }) + + Describe("podcast:publisher", func() { + It("parses publisher name and URL", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPublisher)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.PublisherName).To(Equal("Acme Podcast Network")) + Expect(result.PublisherURL).To(Equal("https://acme.example.com")) + }) + + It("leaves publisher fields empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.PublisherName).To(BeEmpty()) + Expect(result.PublisherURL).To(BeEmpty()) + }) + }) + + Describe("podcast:image", func() { + It("parses multiple channel-level images", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Images).To(HaveLen(3)) + }) + + It("parses href and width for each channel image", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Images[0].URL).To(Equal("https://example.com/img-3000.jpg")) + Expect(result.Images[0].Width).To(Equal(3000)) + Expect(result.Images[2].URL).To(Equal("https://example.com/img-300.jpg")) + Expect(result.Images[2].Width).To(Equal(300)) + }) + + It("parses episode-level images", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.Images).To(HaveLen(2)) + Expect(ep.Images[0].URL).To(Equal("https://example.com/ep-img-600.jpg")) + Expect(ep.Images[0].Width).To(Equal(600)) + }) + + It("episode without images has empty Images slice", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[1].Images).To(BeEmpty()) + }) + + It("channel without images has empty Images slice", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Images).To(BeEmpty()) + }) + }) + + Describe("podcast:funding — all entries", func() { + It("stores all funding entries with correct sort order", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.FundingItems).To(HaveLen(2)) + Expect(result.FundingItems[0].URL).To(Equal("https://example.com/donate")) + Expect(result.FundingItems[0].Text).To(Equal("Support us!")) + Expect(result.FundingItems[0].SortOrder).To(Equal(0)) + Expect(result.FundingItems[1].URL).To(Equal("https://example.com/donate2")) + Expect(result.FundingItems[1].Text).To(Equal("Secondary")) + Expect(result.FundingItems[1].SortOrder).To(Equal(1)) + }) + + It("returns empty FundingItems when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.FundingItems).To(BeEmpty()) + }) + }) +}) diff --git a/db/migrations/20260427165650_add_podcast.go b/db/migrations/20260427165650_add_podcast.go new file mode 100644 index 000000000..7bd8f3171 --- /dev/null +++ b/db/migrations/20260427165650_add_podcast.go @@ -0,0 +1,63 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPodcast, downAddPodcast) +} + +func upAddPodcast(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` +CREATE TABLE podcast_channel ( + id VARCHAR(255) PRIMARY KEY, + url VARCHAR(4096) NOT NULL, + title VARCHAR(1024) NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + image_url VARCHAR(4096) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'new', + error_message TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); + +CREATE TABLE podcast_episode ( + id VARCHAR(255) PRIMARY KEY, + channel_id VARCHAR(255) NOT NULL REFERENCES podcast_channel(id) ON DELETE CASCADE, + stream_id VARCHAR(255) NOT NULL DEFAULT '', + guid VARCHAR(4096) NOT NULL DEFAULT '', + title VARCHAR(1024) NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + publish_date DATETIME, + duration INTEGER NOT NULL DEFAULT 0, + size INTEGER NOT NULL DEFAULT 0, + bit_rate INTEGER NOT NULL DEFAULT 0, + suffix VARCHAR(32) NOT NULL DEFAULT '', + content_type VARCHAR(255) NOT NULL DEFAULT '', + path VARCHAR(4096) NOT NULL DEFAULT '', + enclosure_url VARCHAR(4096) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'new', + error_message TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); + +CREATE INDEX podcast_episode_channel_id ON podcast_episode(channel_id); +CREATE INDEX podcast_episode_publish_date ON podcast_episode(publish_date); +`) + return err +} + +func downAddPodcast(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` +DROP INDEX IF EXISTS podcast_episode_publish_date; +DROP INDEX IF EXISTS podcast_episode_channel_id; +DROP TABLE IF EXISTS podcast_episode; +DROP TABLE IF EXISTS podcast_channel; +`) + return err +} diff --git a/db/migrations/20260427184047_add_podcast_downloaded_bytes.go b/db/migrations/20260427184047_add_podcast_downloaded_bytes.go new file mode 100644 index 000000000..56ddc242f --- /dev/null +++ b/db/migrations/20260427184047_add_podcast_downloaded_bytes.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPodcastDownloadedBytes, downAddPodcastDownloadedBytes) +} + +func upAddPodcastDownloadedBytes(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE podcast_episode ADD COLUMN downloaded_bytes INTEGER NOT NULL DEFAULT 0`) + return err +} + +func downAddPodcastDownloadedBytes(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20260428000000_add_podcast20.go b/db/migrations/20260428000000_add_podcast20.go new file mode 100644 index 000000000..f8a72030f --- /dev/null +++ b/db/migrations/20260428000000_add_podcast20.go @@ -0,0 +1,76 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPodcast20, downAddPodcast20) +} + +func upAddPodcast20(ctx context.Context, tx *sql.Tx) error { + sqls := []string{ + // podcast_channel — Tier 1 + `ALTER TABLE podcast_channel ADD COLUMN podcast_guid TEXT NOT NULL DEFAULT ''`, + // podcast_channel — Tier 2 + `ALTER TABLE podcast_channel ADD COLUMN locked INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE podcast_channel ADD COLUMN locked_owner TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN medium TEXT NOT NULL DEFAULT 'podcast'`, + `ALTER TABLE podcast_channel ADD COLUMN funding_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN funding_text TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN update_frequency TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN update_rrule TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN complete INTEGER NOT NULL DEFAULT 0`, + // podcast_episode — Tier 1 + `ALTER TABLE podcast_episode ADD COLUMN season INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE podcast_episode ADD COLUMN season_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN episode_number TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN episode_display TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN chapters_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN chapters_type TEXT NOT NULL DEFAULT ''`, + // podcast_episode — Tier 2 + `ALTER TABLE podcast_episode ADD COLUMN soundbite_start REAL NOT NULL DEFAULT 0`, + `ALTER TABLE podcast_episode ADD COLUMN soundbite_dur REAL NOT NULL DEFAULT 0`, + `ALTER TABLE podcast_episode ADD COLUMN soundbite_title TEXT NOT NULL DEFAULT ''`, + // new tables + `CREATE TABLE podcast_transcript ( + id TEXT PRIMARY KEY, + episode_id TEXT NOT NULL REFERENCES podcast_episode(id) ON DELETE CASCADE, + url TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT '', + rel TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_transcript_episode_id ON podcast_transcript(episode_id)`, + // channel_id/episode_id have no FK constraints: the put() helper serialises empty + // strings as "" rather than NULL, which would violate a FK constraint. Cascade + // delete is handled at the application layer instead. + `CREATE TABLE podcast_person ( + id TEXT PRIMARY KEY, + channel_id TEXT, + episode_id TEXT, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'host', + group_name TEXT NOT NULL DEFAULT 'cast', + img TEXT NOT NULL DEFAULT '', + href TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_person_channel_id ON podcast_person(channel_id)`, + `CREATE INDEX podcast_person_episode_id ON podcast_person(episode_id)`, + } + for _, s := range sqls { + if _, err := tx.ExecContext(ctx, s); err != nil { + return err + } + } + return nil +} + +func downAddPodcast20(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20260428120000_add_podcast_tier3.go b/db/migrations/20260428120000_add_podcast_tier3.go new file mode 100644 index 000000000..2e71fdf68 --- /dev/null +++ b/db/migrations/20260428120000_add_podcast_tier3.go @@ -0,0 +1,59 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPodcastTier3, downAddPodcastTier3) +} + +func upAddPodcastTier3(ctx context.Context, tx *sql.Tx) error { + sqls := []string{ + // podcast:podping flag on channel + `ALTER TABLE podcast_channel ADD COLUMN uses_podping INTEGER NOT NULL DEFAULT 0`, + + // podcast:podroll — recommended feeds listed by a channel + `CREATE TABLE podcast_podroll ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + feed_guid TEXT NOT NULL DEFAULT '', + feed_url TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_podroll_channel_id ON podcast_podroll(channel_id)`, + + // podcast:liveItem — at most one active live item per channel + `CREATE TABLE podcast_live_item ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + guid TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + start_time DATETIME, + end_time DATETIME, + enclosure_url TEXT NOT NULL DEFAULT '', + enclosure_type TEXT NOT NULL DEFAULT '', + content_link_url TEXT NOT NULL DEFAULT '', + content_link_text TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`, + `CREATE UNIQUE INDEX podcast_live_item_channel_id ON podcast_live_item(channel_id)`, + } + for _, s := range sqls { + if _, err := tx.ExecContext(ctx, s); err != nil { + return err + } + } + return nil +} + +func downAddPodcastTier3(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20260428200000_add_podcast_metadata.go b/db/migrations/20260428200000_add_podcast_metadata.go new file mode 100644 index 000000000..90efc4827 --- /dev/null +++ b/db/migrations/20260428200000_add_podcast_metadata.go @@ -0,0 +1,61 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPodcastMetadata, downAddPodcastMetadata) +} + +func upAddPodcastMetadata(ctx context.Context, tx *sql.Tx) error { + sqls := []string{ + // podcast_channel — location, license, publisher + `ALTER TABLE podcast_channel ADD COLUMN location_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN location_geo TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN location_osm TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN license TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN publisher_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN publisher_url TEXT NOT NULL DEFAULT ''`, + // podcast_episode — location, license + `ALTER TABLE podcast_episode ADD COLUMN location_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN location_geo TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN location_osm TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN license TEXT NOT NULL DEFAULT ''`, + // channel_id/episode_id have no FK constraints: the put() helper serialises empty + // strings as "" rather than NULL, which would violate a FK constraint. Cascade + // delete is handled at the application layer instead. + `CREATE TABLE podcast_funding ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + url TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_funding_channel_id ON podcast_funding(channel_id)`, + `CREATE TABLE podcast_image ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL DEFAULT '', + episode_id TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_image_channel_id ON podcast_image(channel_id)`, + `CREATE INDEX podcast_image_episode_id ON podcast_image(episode_id)`, + } + for _, s := range sqls { + if _, err := tx.ExecContext(ctx, s); err != nil { + return err + } + } + return nil +} + +func downAddPodcastMetadata(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20260720015443_uniform_canonical_ids.go b/db/migrations/20260720015443_uniform_canonical_ids.go index 346f22050..c488ec8d8 100644 --- a/db/migrations/20260720015443_uniform_canonical_ids.go +++ b/db/migrations/20260720015443_uniform_canonical_ids.go @@ -77,6 +77,14 @@ var idColumns = []struct{ table, col string }{ {"media_file_artists", "media_file_id"}, {"media_file_artists", "artist_id"}, {"album_artists", "album_id"}, {"album_artists", "artist_id"}, {"library_tag", "tag_id"}, + {"podcast_channel", "id"}, + {"podcast_episode", "id"}, {"podcast_episode", "channel_id"}, {"podcast_episode", "stream_id"}, + {"podcast_transcript", "id"}, {"podcast_transcript", "episode_id"}, + {"podcast_person", "id"}, {"podcast_person", "channel_id"}, {"podcast_person", "episode_id"}, + {"podcast_podroll", "id"}, {"podcast_podroll", "channel_id"}, + {"podcast_live_item", "id"}, {"podcast_live_item", "channel_id"}, + {"podcast_funding", "id"}, {"podcast_funding", "channel_id"}, + {"podcast_image", "id"}, {"podcast_image", "channel_id"}, {"podcast_image", "episode_id"}, } // embeddedIDColumns holds ids nested inside a larger value; the id-columns guard checks this diff --git a/db/migrations/uniform_canonical_ids_test.go b/db/migrations/uniform_canonical_ids_test.go index ced75cb7e..05d796c83 100644 --- a/db/migrations/uniform_canonical_ids_test.go +++ b/db/migrations/uniform_canonical_ids_test.go @@ -65,6 +65,14 @@ var _ = Describe("upUniformCanonicalIds", func() { CREATE TABLE library_tag (tag_id text, library_id integer); CREATE TABLE plugin (id text, users text); CREATE TABLE property (id text primary key, value text); + CREATE TABLE podcast_channel (id text); + CREATE TABLE podcast_episode (id text, channel_id text, stream_id text); + CREATE TABLE podcast_transcript (id text, episode_id text); + CREATE TABLE podcast_person (id text, channel_id text, episode_id text); + CREATE TABLE podcast_podroll (id text, channel_id text); + CREATE TABLE podcast_live_item (id text, channel_id text); + CREATE TABLE podcast_funding (id text, channel_id text); + CREATE TABLE podcast_image (id text, channel_id text, episode_id text); `) Expect(err).ToNot(HaveOccurred()) diff --git a/model/datastore.go b/model/datastore.go index 273ca714b..26a805171 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -42,6 +42,14 @@ type DataStore interface { Plugin(ctx context.Context) PluginRepository Artwork(ctx context.Context) ArtworkRepository ArtworkQueue(ctx context.Context) ArtworkQueueRepository + PodcastChannel(ctx context.Context) PodcastChannelRepository + PodcastEpisode(ctx context.Context) PodcastEpisodeRepository + PodcastTranscript(ctx context.Context) PodcastTranscriptRepository + PodcastPerson(ctx context.Context) PodcastPersonRepository + PodcastPodroll(ctx context.Context) PodcastPodrollRepository + PodcastLiveItem(ctx context.Context) PodcastLiveItemRepository + PodcastFunding(ctx context.Context) PodcastFundingRepository + PodcastImage(ctx context.Context) PodcastImageRepository Resource(ctx context.Context, model any) ResourceRepository diff --git a/model/podcast.go b/model/podcast.go new file mode 100644 index 000000000..9747364c9 --- /dev/null +++ b/model/podcast.go @@ -0,0 +1,242 @@ +package model + +import "time" + +// PodcastPodrollItem represents one entry in a podcast:podroll recommendation list. +type PodcastPodrollItem struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId"` + FeedGUID string `structs:"feed_guid" json:"feedGuid,omitempty"` + FeedURL string `structs:"feed_url" json:"feedUrl,omitempty"` + Title string `structs:"title" json:"title,omitempty"` + SortOrder int `structs:"sort_order" json:"sortOrder"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +// PodcastLiveItem represents a podcast:liveItem stream associated with a channel. +type PodcastLiveItem struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId"` + GUID string `structs:"guid" json:"guid,omitempty"` + Title string `structs:"title" json:"title,omitempty"` + Status string `structs:"status" json:"status"` + StartTime time.Time `structs:"start_time" json:"startTime,omitempty"` + EndTime time.Time `structs:"end_time" json:"endTime,omitempty"` + EnclosureURL string `structs:"enclosure_url" json:"enclosureUrl,omitempty"` + EnclosureType string `structs:"enclosure_type" json:"enclosureType,omitempty"` + ContentLinkURL string `structs:"content_link_url" json:"contentLinkUrl,omitempty"` + ContentLinkText string `structs:"content_link_text" json:"contentLinkText,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` +} + +// PodcastPodrollItems is a slice of PodcastPodrollItem. +type PodcastPodrollItems []PodcastPodrollItem + +// PodcastPodrollRepository manages podcast:podroll entries for channels. +type PodcastPodrollRepository interface { + GetByChannel(channelID string) (PodcastPodrollItems, error) + GetByChannels(channelIDs []string) (PodcastPodrollItems, error) + SaveForChannel(channelID string, items []PodcastPodrollItem) error +} + +// PodcastLiveItemRepository manages podcast:liveItem entries (one per channel). +type PodcastLiveItemRepository interface { + GetByChannel(channelID string) (*PodcastLiveItem, error) + Upsert(item *PodcastLiveItem) error + DeleteByChannel(channelID string) error +} + +type PodcastStatus string + +const ( + PodcastStatusNew PodcastStatus = "new" + PodcastStatusDownloading PodcastStatus = "downloading" + PodcastStatusCompleted PodcastStatus = "completed" + PodcastStatusError PodcastStatus = "error" + PodcastStatusSkipped PodcastStatus = "skipped" + PodcastStatusDeleted PodcastStatus = "deleted" +) + +type PodcastChannel struct { + ID string `structs:"id" json:"id"` + URL string `structs:"url" json:"url"` + Title string `structs:"title" json:"title"` + Description string `structs:"description" json:"description"` + ImageURL string `structs:"image_url" json:"imageUrl"` + Status PodcastStatus `structs:"status" json:"status"` + ErrorMessage string `structs:"error_message" json:"errorMessage"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + + // Podcasting 2.0 — Tier 1 + PodcastGUID string `structs:"podcast_guid" json:"podcastGuid,omitempty"` + + // Podcasting 2.0 — Tier 2 + Locked bool `structs:"locked" json:"locked,omitempty"` + LockedOwner string `structs:"locked_owner" json:"lockedOwner,omitempty"` + Medium string `structs:"medium" json:"medium,omitempty"` + FundingURL string `structs:"funding_url" json:"fundingUrl,omitempty"` + FundingText string `structs:"funding_text" json:"fundingText,omitempty"` + UpdateFrequency string `structs:"update_frequency" json:"updateFrequency,omitempty"` + UpdateRRule string `structs:"update_rrule" json:"updateRRule,omitempty"` + Complete bool `structs:"complete" json:"complete,omitempty"` + LocationName string `structs:"location_name" json:"locationName,omitempty"` + LocationGeo string `structs:"location_geo" json:"locationGeo,omitempty"` + LocationOSM string `structs:"location_osm" json:"locationOsm,omitempty"` + License string `structs:"license" json:"license,omitempty"` + PublisherName string `structs:"publisher_name" json:"publisherName,omitempty"` + PublisherURL string `structs:"publisher_url" json:"publisherUrl,omitempty"` + + // Podcasting 2.0 — Tier 3 + UsesPodping bool `structs:"uses_podping" json:"usesPodping,omitempty"` + Podroll PodcastPodrollItems `structs:"-" json:"podroll,omitempty"` + LiveItem *PodcastLiveItem `structs:"-" json:"liveItem,omitempty"` + + // loaded separately + Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"` + Persons PodcastPersons `structs:"-" json:"persons,omitempty"` + FundingItems PodcastFundingItems `structs:"-" json:"funding,omitempty"` + Images PodcastImages `structs:"-" json:"images,omitempty"` +} + +type PodcastEpisode struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId"` + StreamID string `structs:"stream_id" json:"streamId"` + GUID string `structs:"guid" json:"guid"` + Title string `structs:"title" json:"title"` + Description string `structs:"description" json:"description"` + PublishDate time.Time `structs:"publish_date" json:"publishDate"` + Duration int `structs:"duration" json:"duration"` + Size int64 `structs:"size" json:"size"` + BitRate int `structs:"bit_rate" json:"bitRate"` + Suffix string `structs:"suffix" json:"suffix"` + ContentType string `structs:"content_type" json:"contentType"` + Path string `structs:"path" json:"path"` + EnclosureURL string `structs:"enclosure_url" json:"enclosureUrl"` + DownloadedBytes int64 `structs:"downloaded_bytes" json:"downloadedBytes"` + Status PodcastStatus `structs:"status" json:"status"` + ErrorMessage string `structs:"error_message" json:"errorMessage"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + + // Podcasting 2.0 — Tier 1 + Season int `structs:"season" json:"season,omitempty"` + SeasonName string `structs:"season_name" json:"seasonName,omitempty"` + EpisodeNumber string `structs:"episode_number" json:"episodeNumber,omitempty"` + EpisodeDisplay string `structs:"episode_display" json:"episodeDisplay,omitempty"` + ChaptersURL string `structs:"chapters_url" json:"chaptersUrl,omitempty"` + ChaptersType string `structs:"chapters_type" json:"chaptersType,omitempty"` + + // Podcasting 2.0 — Tier 2 + SoundbiteStart float64 `structs:"soundbite_start" json:"soundbiteStart,omitempty"` + SoundbiteDur float64 `structs:"soundbite_dur" json:"soundbiteDur,omitempty"` + SoundbiteTitle string `structs:"soundbite_title" json:"soundbiteTitle,omitempty"` + LocationName string `structs:"location_name" json:"locationName,omitempty"` + LocationGeo string `structs:"location_geo" json:"locationGeo,omitempty"` + LocationOSM string `structs:"location_osm" json:"locationOsm,omitempty"` + License string `structs:"license" json:"license,omitempty"` + + // loaded separately + Transcripts PodcastTranscripts `structs:"-" json:"transcripts,omitempty"` + Persons PodcastPersons `structs:"-" json:"persons,omitempty"` + Images PodcastImages `structs:"-" json:"images,omitempty"` +} + +type PodcastTranscript struct { + ID string `structs:"id" json:"id"` + EpisodeID string `structs:"episode_id" json:"episodeId"` + URL string `structs:"url" json:"url"` + MimeType string `structs:"mime_type" json:"type"` + Language string `structs:"language" json:"language,omitempty"` + Rel string `structs:"rel" json:"rel,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +type PodcastPerson struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId,omitempty"` + EpisodeID string `structs:"episode_id" json:"episodeId,omitempty"` + Name string `structs:"name" json:"name"` + Role string `structs:"role" json:"role,omitempty"` + Group string `structs:"group_name" db:"group_name" json:"group,omitempty"` + Img string `structs:"img" json:"img,omitempty"` + Href string `structs:"href" json:"href,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +type PodcastFundingItem struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId"` + URL string `structs:"url" json:"url"` + Text string `structs:"text" json:"text,omitempty"` + SortOrder int `structs:"sort_order" json:"sortOrder"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +type PodcastImage struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId,omitempty"` + EpisodeID string `structs:"episode_id" json:"episodeId,omitempty"` + URL string `structs:"url" json:"url"` + Width int `structs:"width" json:"width,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +type PodcastChannels []PodcastChannel +type PodcastEpisodes []PodcastEpisode +type PodcastTranscripts []PodcastTranscript +type PodcastPersons []PodcastPerson +type PodcastFundingItems []PodcastFundingItem +type PodcastImages []PodcastImage + +type PodcastChannelRepository interface { + Get(id string) (*PodcastChannel, error) + GetAll(withEpisodes bool) (PodcastChannels, error) + ExistsByURL(url string) (bool, error) + Create(channel *PodcastChannel) error + UpdateChannel(channel *PodcastChannel) error + Delete(id string) error +} + +type PodcastEpisodeRepository interface { + Get(id string) (*PodcastEpisode, error) + GetNewest(count int) (PodcastEpisodes, error) + GetByChannel(channelID string) (PodcastEpisodes, error) + GetByChannels(channelIDs []string) (PodcastEpisodes, error) + GetByGUID(channelID, guid string) (*PodcastEpisode, error) + Create(ep *PodcastEpisode) error + Update(ep *PodcastEpisode) error + Delete(id string) error +} + +type PodcastTranscriptRepository interface { + GetByEpisode(episodeID string) (PodcastTranscripts, error) + GetByEpisodes(episodeIDs []string) (PodcastTranscripts, error) + Save(transcripts []PodcastTranscript) error + DeleteByEpisode(episodeID string) error +} + +type PodcastPersonRepository interface { + GetByChannel(channelID string) (PodcastPersons, error) + GetByEpisode(episodeID string) (PodcastPersons, error) + GetByEpisodes(episodeIDs []string) (PodcastPersons, error) + SaveForChannel(channelID string, persons []PodcastPerson) error + SaveForEpisode(episodeID string, persons []PodcastPerson) error +} + +type PodcastFundingRepository interface { + GetByChannel(channelID string) (PodcastFundingItems, error) + GetByChannels(channelIDs []string) (PodcastFundingItems, error) + SaveForChannel(channelID string, items []PodcastFundingItem) error +} + +type PodcastImageRepository interface { + GetByChannel(channelID string) (PodcastImages, error) + GetByChannels(channelIDs []string) (PodcastImages, error) + GetByEpisode(episodeID string) (PodcastImages, error) + GetByEpisodes(episodeIDs []string) (PodcastImages, error) + SaveForChannel(channelID string, images []PodcastImage) error + SaveForEpisode(episodeID string, images []PodcastImage) error +} diff --git a/persistence/persistence.go b/persistence/persistence.go index 9d3a33cfc..3b61bfcb3 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -105,6 +105,38 @@ func (s *SQLStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepositor return NewArtworkQueueRepository(ctx, s.getDBXBuilder()) } +func (s *SQLStore) PodcastChannel(ctx context.Context) model.PodcastChannelRepository { + return NewPodcastChannelRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastEpisode(ctx context.Context) model.PodcastEpisodeRepository { + return NewPodcastEpisodeRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastTranscript(ctx context.Context) model.PodcastTranscriptRepository { + return NewPodcastTranscriptRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastPerson(ctx context.Context) model.PodcastPersonRepository { + return NewPodcastPersonRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastPodroll(ctx context.Context) model.PodcastPodrollRepository { + return NewPodcastPodrollRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveItemRepository { + return NewPodcastLiveItemRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastFunding(ctx context.Context) model.PodcastFundingRepository { + return NewPodcastFundingRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastImage(ctx context.Context) model.PodcastImageRepository { + return NewPodcastImageRepository(ctx, s.getDBXBuilder()) +} + func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: @@ -125,6 +157,8 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository return s.Playlist(ctx).(model.ResourceRepository) case model.Radio: return s.Radio(ctx).(model.ResourceRepository) + case model.PodcastChannel: + return s.PodcastChannel(ctx).(model.ResourceRepository) case model.Share: return s.Share(ctx).(model.ResourceRepository) case model.Tag: diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index f146cb06b..cf3876455 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -145,6 +145,41 @@ var ( testRadios = model.Radios{radioWithoutHomePage, radioWithHomePage} ) +var ( + podcastChannel1 = model.PodcastChannel{ + ID: "pc-1", + URL: "https://example.com/feed1.xml", + Title: "Test Podcast", + Description: "A test podcast", + Status: model.PodcastStatusCompleted, + } + podcastChannel2 = model.PodcastChannel{ + ID: "pc-2", + URL: "https://example.com/feed2.xml", + Title: "Another Podcast", + Status: model.PodcastStatusNew, + } + testPodcastChannels = model.PodcastChannels{podcastChannel1, podcastChannel2} + + podcastEpisode1 = model.PodcastEpisode{ + ID: "pe-1", + ChannelID: "pc-1", + GUID: "guid-001", + Title: "Episode 1", + Status: model.PodcastStatusCompleted, + PublishDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + } + podcastEpisode2 = model.PodcastEpisode{ + ID: "pe-2", + ChannelID: "pc-1", + GUID: "guid-002", + Title: "Episode 2", + Status: model.PodcastStatusNew, + PublishDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC), + } + testPodcastEpisodes = model.PodcastEpisodes{podcastEpisode1, podcastEpisode2} +) + var ( plsBest model.Playlist plsCool model.Playlist @@ -252,6 +287,22 @@ var _ = BeforeSuite(func() { } } + pcr := NewPodcastChannelRepository(ctx, conn) + for i := range testPodcastChannels { + c := testPodcastChannels[i] + if err := pcr.Create(&c); err != nil { + panic(err) + } + } + + per := NewPodcastEpisodeRepository(ctx, conn) + for i := range testPodcastEpisodes { + e := testPodcastEpisodes[i] + if err := per.Create(&e); err != nil { + panic(err) + } + } + plsBest = model.Playlist{ Name: "Best", Comment: "No Comments", diff --git a/persistence/podcast_channel_repository.go b/persistence/podcast_channel_repository.go new file mode 100644 index 000000000..7f72d3b12 --- /dev/null +++ b/persistence/podcast_channel_repository.go @@ -0,0 +1,149 @@ +package persistence + +import ( + "context" + "errors" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastChannelRepository struct { + sqlRepository +} + +func NewPodcastChannelRepository(ctx context.Context, db dbx.Builder) model.PodcastChannelRepository { + r := &podcastChannelRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.PodcastChannel{}, nil) + return r +} + +func (r *podcastChannelRepository) isPermitted() bool { + return loggedUser(r.ctx).IsAdmin +} + +func (r *podcastChannelRepository) Get(chanID string) (*model.PodcastChannel, error) { + sel := r.newSelect().Columns("*").Where(Eq{"id": chanID}) + res := model.PodcastChannel{} + if err := r.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (r *podcastChannelRepository) GetAll(withEpisodes bool) (model.PodcastChannels, error) { + sel := r.newSelect().Columns("*").OrderBy("title") + var channels model.PodcastChannels + if err := r.queryAll(sel, &channels); err != nil { + return nil, err + } + if withEpisodes && len(channels) > 0 { + ids := make([]string, len(channels)) + for i, ch := range channels { + ids[i] = ch.ID + } + epRepo := NewPodcastEpisodeRepository(r.ctx, r.db) + allEps, err := epRepo.GetByChannels(ids) + if err != nil { + return nil, err + } + epsByChannel := make(map[string]model.PodcastEpisodes, len(channels)) + for _, ep := range allEps { + epsByChannel[ep.ChannelID] = append(epsByChannel[ep.ChannelID], ep) + } + for i := range channels { + channels[i].Episodes = epsByChannel[channels[i].ID] + } + } + return channels, nil +} + +func (r *podcastChannelRepository) ExistsByURL(url string) (bool, error) { + sel := r.newSelect().Columns("count(*)").Where(Eq{"url": url}) + count, err := r.count(sel) + return count > 0, err +} + +func (r *podcastChannelRepository) Create(channel *model.PodcastChannel) error { + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + now := time.Now() + channel.CreatedAt = now + channel.UpdatedAt = now + if channel.ID == "" { + channel.ID = id.NewRandom() + } + _, err := r.put(channel.ID, channel) + return err +} + +func (r *podcastChannelRepository) UpdateChannel(channel *model.PodcastChannel) error { + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + channel.UpdatedAt = time.Now() + _, err := r.put(channel.ID, channel) + return err +} + +func (r *podcastChannelRepository) Delete(chanID string) error { + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + return r.delete(Eq{"id": chanID}) +} + +func (r *podcastChannelRepository) EntityName() string { + return "podcast_channel" +} + +func (r *podcastChannelRepository) NewInstance() any { + return &model.PodcastChannel{} +} + +func (r *podcastChannelRepository) Read(chanID string) (any, error) { + return r.Get(chanID) +} + +func (r *podcastChannelRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") + var channels model.PodcastChannels + err := r.queryAll(sel, &channels) + return channels, err +} + +func (r *podcastChannelRepository) Save(entity any) (string, error) { + ch := entity.(*model.PodcastChannel) + if !r.isPermitted() { + return "", rest.ErrPermissionDenied + } + err := r.Create(ch) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return ch.ID, err +} + +func (r *podcastChannelRepository) Update(id string, entity any, cols ...string) error { + ch := entity.(*model.PodcastChannel) + ch.ID = id + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + return r.UpdateChannel(ch) +} + +func (r *podcastChannelRepository) Count(options ...rest.QueryOptions) (int64, error) { + sql := r.newSelect(r.parseRestOptions(r.ctx, options...)) + return r.count(sql) +} + +var _ model.PodcastChannelRepository = (*podcastChannelRepository)(nil) +var _ rest.Repository = (*podcastChannelRepository)(nil) diff --git a/persistence/podcast_channel_repository_test.go b/persistence/podcast_channel_repository_test.go new file mode 100644 index 000000000..25d378a90 --- /dev/null +++ b/persistence/podcast_channel_repository_test.go @@ -0,0 +1,136 @@ +package persistence + +import ( + "context" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastChannelRepository", func() { + var adminRepo model.PodcastChannelRepository + var userRepo model.PodcastChannelRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + adminCtx := request.WithUser(ctx, adminUser) + userCtx := request.WithUser(ctx, regularUser) + adminRepo = NewPodcastChannelRepository(adminCtx, GetDBXBuilder()) + userRepo = NewPodcastChannelRepository(userCtx, GetDBXBuilder()) + }) + + Describe("Get", func() { + It("returns an existing channel", func() { + ch, err := adminRepo.Get("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(ch.ID).To(Equal("pc-1")) + Expect(ch.Title).To(Equal("Test Podcast")) + }) + + It("returns ErrNotFound for unknown id", func() { + _, err := adminRepo.Get("no-such-id") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all channels without episodes", func() { + channels, err := adminRepo.GetAll(false) + Expect(err).ToNot(HaveOccurred()) + Expect(len(channels)).To(BeNumerically(">=", 2)) + for _, ch := range channels { + Expect(ch.Episodes).To(BeEmpty()) + } + }) + + It("returns channels with episodes when withEpisodes=true", func() { + channels, err := adminRepo.GetAll(true) + Expect(err).ToNot(HaveOccurred()) + var ch1 *model.PodcastChannel + for i := range channels { + if channels[i].ID == "pc-1" { + ch1 = &channels[i] + break + } + } + Expect(ch1).ToNot(BeNil()) + Expect(ch1.Episodes).To(HaveLen(2)) + }) + }) + + Describe("Create", func() { + It("creates a new channel and assigns an ID", func() { + ch := &model.PodcastChannel{ + URL: "https://new.example.com/feed.xml", + Title: "New Podcast", + Status: model.PodcastStatusNew, + } + err := adminRepo.Create(ch) + Expect(err).ToNot(HaveOccurred()) + Expect(ch.ID).ToNot(BeEmpty()) + + saved, err := adminRepo.Get(ch.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Title).To(Equal("New Podcast")) + + // cleanup + _ = adminRepo.Delete(ch.ID) + }) + + It("denies non-admin users", func() { + err := userRepo.Create(&model.PodcastChannel{URL: "https://x.com/feed.xml"}) + Expect(err).To(MatchError(rest.ErrPermissionDenied)) + }) + }) + + Describe("Update", func() { + It("updates an existing channel", func() { + ch := &model.PodcastChannel{ + URL: "https://update.example.com/feed.xml", + Title: "Before Update", + Status: model.PodcastStatusNew, + } + _ = adminRepo.Create(ch) + + ch.Title = "After Update" + err := adminRepo.UpdateChannel(ch) + Expect(err).ToNot(HaveOccurred()) + + saved, _ := adminRepo.Get(ch.ID) + Expect(saved.Title).To(Equal("After Update")) + + // cleanup + _ = adminRepo.Delete(ch.ID) + }) + }) + + Describe("Delete", func() { + It("deletes an existing channel", func() { + ch := &model.PodcastChannel{URL: "https://del.example.com/feed.xml", Status: model.PodcastStatusNew} + _ = adminRepo.Create(ch) + + err := adminRepo.Delete(ch.ID) + Expect(err).ToNot(HaveOccurred()) + + _, err = adminRepo.Get(ch.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("denies non-admin users", func() { + err := userRepo.Delete("pc-1") + Expect(err).To(MatchError(rest.ErrPermissionDenied)) + }) + }) + + Describe("Regular user read access", func() { + It("allows regular users to read channels", func() { + channels, err := userRepo.GetAll(false) + Expect(err).ToNot(HaveOccurred()) + Expect(channels).ToNot(BeEmpty()) + }) + }) +}) diff --git a/persistence/podcast_episode_repository.go b/persistence/podcast_episode_repository.go new file mode 100644 index 000000000..ae530f2e5 --- /dev/null +++ b/persistence/podcast_episode_repository.go @@ -0,0 +1,88 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastEpisodeRepository struct { + sqlRepository +} + +func NewPodcastEpisodeRepository(ctx context.Context, db dbx.Builder) model.PodcastEpisodeRepository { + r := &podcastEpisodeRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.PodcastEpisode{}, nil) + return r +} + +func (r *podcastEpisodeRepository) Get(epID string) (*model.PodcastEpisode, error) { + sel := r.newSelect().Columns("*").Where(Eq{"id": epID}) + res := model.PodcastEpisode{} + if err := r.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (r *podcastEpisodeRepository) GetNewest(count int) (model.PodcastEpisodes, error) { + sel := r.newSelect().Columns("*").OrderBy("publish_date DESC").Limit(uint64(count)) + var eps model.PodcastEpisodes + err := r.queryAll(sel, &eps) + return eps, err +} + +func (r *podcastEpisodeRepository) GetByChannel(channelID string) (model.PodcastEpisodes, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}).OrderBy("publish_date DESC") + var eps model.PodcastEpisodes + err := r.queryAll(sel, &eps) + return eps, err +} + +func (r *podcastEpisodeRepository) GetByChannels(channelIDs []string) (model.PodcastEpisodes, error) { + if len(channelIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}).OrderBy("channel_id, publish_date DESC") + var eps model.PodcastEpisodes + err := r.queryAll(sel, &eps) + return eps, err +} + +func (r *podcastEpisodeRepository) GetByGUID(channelID, guid string) (*model.PodcastEpisode, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID, "guid": guid}) + res := model.PodcastEpisode{} + if err := r.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (r *podcastEpisodeRepository) Create(ep *model.PodcastEpisode) error { + now := time.Now() + ep.CreatedAt = now + ep.UpdatedAt = now + if ep.ID == "" { + ep.ID = id.NewRandom() + } + _, err := r.put(ep.ID, ep) + return err +} + +func (r *podcastEpisodeRepository) Update(ep *model.PodcastEpisode) error { + ep.UpdatedAt = time.Now() + _, err := r.put(ep.ID, ep) + return err +} + +func (r *podcastEpisodeRepository) Delete(epID string) error { + return r.delete(Eq{"id": epID}) +} + +var _ model.PodcastEpisodeRepository = (*podcastEpisodeRepository)(nil) diff --git a/persistence/podcast_episode_repository_test.go b/persistence/podcast_episode_repository_test.go new file mode 100644 index 000000000..1e7d50dcd --- /dev/null +++ b/persistence/podcast_episode_repository_test.go @@ -0,0 +1,132 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastEpisodeRepository", func() { + var repo model.PodcastEpisodeRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastEpisodeRepository(ctx, GetDBXBuilder()) + }) + + Describe("Get", func() { + It("returns an existing episode", func() { + ep, err := repo.Get("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(ep.Title).To(Equal("Episode 1")) + Expect(ep.ChannelID).To(Equal("pc-1")) + }) + + It("returns ErrNotFound for unknown id", func() { + _, err := repo.Get("no-such-id") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetNewest", func() { + It("returns episodes ordered by publish_date DESC", func() { + eps, err := repo.GetNewest(10) + Expect(err).ToNot(HaveOccurred()) + Expect(len(eps)).To(BeNumerically(">=", 2)) + Expect(eps[0].PublishDate.After(eps[1].PublishDate)).To(BeTrue()) + }) + + It("respects the count limit", func() { + eps, err := repo.GetNewest(1) + Expect(err).ToNot(HaveOccurred()) + Expect(eps).To(HaveLen(1)) + }) + }) + + Describe("GetByChannel", func() { + It("returns only episodes belonging to the channel", func() { + eps, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(eps).To(HaveLen(2)) + for _, ep := range eps { + Expect(ep.ChannelID).To(Equal("pc-1")) + } + }) + + It("returns empty slice for channel with no episodes", func() { + eps, err := repo.GetByChannel("pc-2") + Expect(err).ToNot(HaveOccurred()) + Expect(eps).To(BeEmpty()) + }) + }) + + Describe("GetByGUID", func() { + It("returns the episode matching channel+guid", func() { + ep, err := repo.GetByGUID("pc-1", "guid-001") + Expect(err).ToNot(HaveOccurred()) + Expect(ep.Title).To(Equal("Episode 1")) + }) + + It("returns ErrNotFound for unknown guid", func() { + _, err := repo.GetByGUID("pc-1", "no-such-guid") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns ErrNotFound when channel does not match", func() { + _, err := repo.GetByGUID("pc-2", "guid-001") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Create and Delete", func() { + It("creates an episode and hard deletes it", func() { + ep := &model.PodcastEpisode{ + ChannelID: "pc-1", + GUID: "guid-temp", + Title: "Temp Episode", + Status: model.PodcastStatusNew, + PublishDate: time.Now(), + } + err := repo.Create(ep) + Expect(err).ToNot(HaveOccurred()) + Expect(ep.ID).ToNot(BeEmpty()) + + err = repo.Delete(ep.ID) + Expect(err).ToNot(HaveOccurred()) + + _, err = repo.Get(ep.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Update", func() { + It("updates episode fields", func() { + ep := &model.PodcastEpisode{ + ChannelID: "pc-1", + GUID: "guid-upd", + Title: "Before", + Status: model.PodcastStatusNew, + PublishDate: time.Now(), + } + _ = repo.Create(ep) + + ep.Status = model.PodcastStatusCompleted + ep.Path = "/podcasts/pc-1/ep.mp3" + err := repo.Update(ep) + Expect(err).ToNot(HaveOccurred()) + + saved, _ := repo.Get(ep.ID) + Expect(saved.Status).To(Equal(model.PodcastStatusCompleted)) + Expect(saved.Path).To(Equal("/podcasts/pc-1/ep.mp3")) + + // cleanup + _ = repo.Delete(ep.ID) + }) + }) +}) diff --git a/persistence/podcast_funding_repository.go b/persistence/podcast_funding_repository.go new file mode 100644 index 000000000..c6b1b97c4 --- /dev/null +++ b/persistence/podcast_funding_repository.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastFundingRepository struct { + sqlRepository +} + +func NewPodcastFundingRepository(ctx context.Context, db dbx.Builder) model.PodcastFundingRepository { + r := &podcastFundingRepository{} + r.ctx = ctx + r.db = db + r.tableName = "podcast_funding" + r.registerModel(&model.PodcastFundingItem{}, nil) + return r +} + +func (r *podcastFundingRepository) GetByChannel(channelID string) (model.PodcastFundingItems, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}) + var result model.PodcastFundingItems + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastFundingRepository) GetByChannels(channelIDs []string) (model.PodcastFundingItems, error) { + if len(channelIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}) + var result model.PodcastFundingItems + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastFundingRepository) SaveForChannel(channelID string, items []model.PodcastFundingItem) error { + if err := r.delete(Eq{"channel_id": channelID}); err != nil { + return err + } + now := time.Now() + for i := range items { + items[i].ID = id.NewRandom() + items[i].ChannelID = channelID + items[i].CreatedAt = now + if _, err := r.put(items[i].ID, &items[i]); err != nil { + return err + } + } + return nil +} + +var _ model.PodcastFundingRepository = (*podcastFundingRepository)(nil) diff --git a/persistence/podcast_funding_repository_test.go b/persistence/podcast_funding_repository_test.go new file mode 100644 index 000000000..654c9186f --- /dev/null +++ b/persistence/podcast_funding_repository_test.go @@ -0,0 +1,167 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastFundingRepository", func() { + var repo model.PodcastFundingRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastFundingRepository(ctx, GetDBXBuilder()) + }) + + Describe("SaveForChannel and GetByChannel", func() { + It("saves and retrieves funding items", func() { + items := []model.PodcastFundingItem{ + {URL: "https://patreon.com/show", Text: "Support on Patreon"}, + {URL: "https://ko-fi.com/show", Text: "Buy me a coffee"}, + } + Expect(repo.SaveForChannel("pc-1", items)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("preserves sort_order in insertion order", func() { + items := []model.PodcastFundingItem{ + {URL: "https://first.example.com", Text: "First"}, + {URL: "https://second.example.com", Text: "Second"}, + {URL: "https://third.example.com", Text: "Third"}, + } + Expect(repo.SaveForChannel("pc-1", items)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + Expect(result[0].Text).To(Equal("First")) + Expect(result[1].Text).To(Equal("Second")) + Expect(result[2].Text).To(Equal("Third")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("replaces existing items on re-save", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://old.example.com", Text: "Old"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://new.example.com", Text: "New"}, + })).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Text).To(Equal("New")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("clears items when saved with nil slice", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://example.com", Text: "Something"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("does not affect items of other channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://a.example.com", Text: "Channel A"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastFundingItem{ + {URL: "https://b.example.com", Text: "Channel B"}, + })).To(Succeed()) + + resultA, _ := repo.GetByChannel("pc-1") + resultB, _ := repo.GetByChannel("pc-2") + Expect(resultA).To(HaveLen(1)) + Expect(resultB).To(HaveLen(1)) + Expect(resultA[0].Text).To(Equal("Channel A")) + Expect(resultB[0].Text).To(Equal("Channel B")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returns empty list for unknown channel", func() { + result, err := repo.GetByChannel("no-such-channel") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("assigns non-empty ID automatically", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://example.com", Text: "Test"}, + })).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("sets ChannelID on returned items", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://example.com", Text: "Test"}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].ChannelID).To(Equal("pc-1")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + }) + + Describe("GetByChannels — bulk query", func() { + It("returns items for multiple channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://a.example.com", Text: "Feed A"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastFundingItem{ + {URL: "https://b.example.com", Text: "Feed B"}, + })).To(Succeed()) + + result, err := repo.GetByChannels([]string{"pc-1", "pc-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + texts := []string{result[0].Text, result[1].Text} + Expect(texts).To(ConsistOf("Feed A", "Feed B")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returned items carry ChannelID for grouping", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://a.example.com", Text: "A"}, + })).To(Succeed()) + + result, _ := repo.GetByChannels([]string{"pc-1"}) + Expect(result[0].ChannelID).To(Equal("pc-1")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByChannels([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) +}) diff --git a/persistence/podcast_image_repository.go b/persistence/podcast_image_repository.go new file mode 100644 index 000000000..a1cbdf5cd --- /dev/null +++ b/persistence/podcast_image_repository.go @@ -0,0 +1,94 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastImageRepository struct { + sqlRepository +} + +func NewPodcastImageRepository(ctx context.Context, db dbx.Builder) model.PodcastImageRepository { + r := &podcastImageRepository{} + r.ctx = ctx + r.db = db + r.tableName = "podcast_image" + r.registerModel(&model.PodcastImage{}, nil) + return r +} + +func (r *podcastImageRepository) GetByChannel(channelID string) (model.PodcastImages, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) GetByChannels(channelIDs []string) (model.PodcastImages, error) { + if len(channelIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) GetByEpisode(episodeID string) (model.PodcastImages, error) { + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeID}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) GetByEpisodes(episodeIDs []string) (model.PodcastImages, error) { + if len(episodeIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeIDs}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) SaveForChannel(channelID string, images []model.PodcastImage) error { + if err := r.delete(Eq{"channel_id": channelID}); err != nil { + return err + } + now := time.Now() + for i := range images { + images[i].ID = id.NewRandom() + images[i].ChannelID = channelID + images[i].EpisodeID = "" + images[i].CreatedAt = now + if _, err := r.put(images[i].ID, &images[i]); err != nil { + return err + } + } + return nil +} + +func (r *podcastImageRepository) SaveForEpisode(episodeID string, images []model.PodcastImage) error { + if err := r.delete(Eq{"episode_id": episodeID}); err != nil { + return err + } + now := time.Now() + for i := range images { + images[i].ID = id.NewRandom() + images[i].EpisodeID = episodeID + images[i].ChannelID = "" + images[i].CreatedAt = now + if _, err := r.put(images[i].ID, &images[i]); err != nil { + return err + } + } + return nil +} + +var _ model.PodcastImageRepository = (*podcastImageRepository)(nil) diff --git a/persistence/podcast_image_repository_test.go b/persistence/podcast_image_repository_test.go new file mode 100644 index 000000000..440bfbfe1 --- /dev/null +++ b/persistence/podcast_image_repository_test.go @@ -0,0 +1,239 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastImageRepository", func() { + var repo model.PodcastImageRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastImageRepository(ctx, GetDBXBuilder()) + }) + + Describe("SaveForChannel and GetByChannel", func() { + It("saves and retrieves channel images", func() { + images := []model.PodcastImage{ + {URL: "https://example.com/img-3000.jpg", Width: 3000}, + {URL: "https://example.com/img-300.jpg", Width: 300}, + } + Expect(repo.SaveForChannel("pc-1", images)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("persists URL and Width correctly", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img-1500.jpg", Width: 1500}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].URL).To(Equal("https://example.com/img-1500.jpg")) + Expect(result[0].Width).To(Equal(1500)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("sets ChannelID and empty EpisodeID on returned items", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].ChannelID).To(Equal("pc-1")) + Expect(result[0].EpisodeID).To(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("assigns non-empty ID automatically", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg"}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("replaces existing images on re-save", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/old.jpg", Width: 100}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/new.jpg", Width: 200}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/new.jpg")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("clears images when saved with nil slice", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result).To(BeEmpty()) + }) + + It("does not return episode images for channel queries", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/channel.jpg", Width: 3000}, + })).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/episode.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/channel.jpg")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("returns empty list for unknown channel", func() { + result, err := repo.GetByChannel("no-such-channel") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("SaveForEpisode and GetByEpisode", func() { + It("saves and retrieves episode images", func() { + images := []model.PodcastImage{ + {URL: "https://example.com/ep-600.jpg", Width: 600}, + {URL: "https://example.com/ep-150.jpg", Width: 150}, + } + Expect(repo.SaveForEpisode("ep-1", images)).To(Succeed()) + + result, err := repo.GetByEpisode("ep-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("sets EpisodeID and empty ChannelID on returned items", func() { + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/ep.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByEpisode("ep-1") + Expect(result[0].EpisodeID).To(Equal("ep-1")) + Expect(result[0].ChannelID).To(BeEmpty()) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("does not return channel images for episode queries", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/channel.jpg", Width: 3000}, + })).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/episode.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByEpisode("ep-1") + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/episode.jpg")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("returns empty list for unknown episode", func() { + result, err := repo.GetByEpisode("no-such-episode") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("GetByChannels — bulk query", func() { + It("returns images for multiple channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://a.example.com/img.jpg", Width: 3000}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastImage{ + {URL: "https://b.example.com/img.jpg", Width: 1500}, + })).To(Succeed()) + + result, err := repo.GetByChannels([]string{"pc-1", "pc-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returned items carry ChannelID for grouping", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByChannels([]string{"pc-1"}) + Expect(result[0].ChannelID).To(Equal("pc-1")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByChannels([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) + + Describe("GetByEpisodes — bulk query", func() { + It("returns images for multiple episodes", func() { + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://ep1.example.com/img.jpg", Width: 600}, + })).To(Succeed()) + Expect(repo.SaveForEpisode("ep-2", []model.PodcastImage{ + {URL: "https://ep2.example.com/img.jpg", Width: 300}, + })).To(Succeed()) + + result, err := repo.GetByEpisodes([]string{"ep-1", "ep-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("ep-2", nil)).To(Succeed()) + }) + + It("returned items carry EpisodeID for grouping", func() { + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/ep.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByEpisodes([]string{"ep-1"}) + Expect(result[0].EpisodeID).To(Equal("ep-1")) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByEpisodes([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) +}) diff --git a/persistence/podcast_live_item_repository.go b/persistence/podcast_live_item_repository.go new file mode 100644 index 000000000..5d543f23e --- /dev/null +++ b/persistence/podcast_live_item_repository.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastLiveItemRepository struct { + sqlRepository +} + +func NewPodcastLiveItemRepository(ctx context.Context, db dbx.Builder) model.PodcastLiveItemRepository { + r := &podcastLiveItemRepository{} + r.ctx = ctx + r.db = db + // Must set tableName before registerModel to avoid auto-derived name mismatch. + r.tableName = "podcast_live_item" + r.registerModel(&model.PodcastLiveItem{}, nil) + return r +} + +func (r *podcastLiveItemRepository) GetByChannel(channelID string) (*model.PodcastLiveItem, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}) + item := model.PodcastLiveItem{} + if err := r.queryOne(sel, &item); err != nil { + return nil, err + } + return &item, nil +} + +func (r *podcastLiveItemRepository) Upsert(item *model.PodcastLiveItem) error { + existing, err := r.GetByChannel(item.ChannelID) + if err == model.ErrNotFound { + item.ID = id.NewRandom() + item.CreatedAt = time.Now() + item.UpdatedAt = time.Now() + _, err = r.put(item.ID, item) + return err + } + if err != nil { + return err + } + item.ID = existing.ID + item.CreatedAt = existing.CreatedAt + item.UpdatedAt = time.Now() + _, err = r.put(item.ID, item) + return err +} + +func (r *podcastLiveItemRepository) DeleteByChannel(channelID string) error { + return r.delete(Eq{"channel_id": channelID}) +} + +var _ model.PodcastLiveItemRepository = (*podcastLiveItemRepository)(nil) diff --git a/persistence/podcast_live_item_repository_test.go b/persistence/podcast_live_item_repository_test.go new file mode 100644 index 000000000..c517269a7 --- /dev/null +++ b/persistence/podcast_live_item_repository_test.go @@ -0,0 +1,150 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastLiveItemRepository", func() { + var repo model.PodcastLiveItemRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastLiveItemRepository(ctx, GetDBXBuilder()) + }) + + Describe("Upsert and GetByChannel", func() { + It("creates a new live item when none exists", func() { + item := &model.PodcastLiveItem{ + ChannelID: "pc-1", + GUID: "live-guid-001", + Title: "Live Show", + Status: "live", + StartTime: time.Date(2024, 4, 27, 8, 0, 0, 0, time.UTC), + EndTime: time.Date(2024, 4, 27, 9, 0, 0, 0, time.UTC), + EnclosureURL: "https://stream.example.com/live.m3u8", + EnclosureType: "application/x-mpegURL", + ContentLinkURL: "https://youtube.com/live", + ContentLinkText: "Watch Live", + } + Expect(repo.Upsert(item)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.ChannelID).To(Equal("pc-1")) + Expect(result.GUID).To(Equal("live-guid-001")) + Expect(result.Title).To(Equal("Live Show")) + Expect(result.Status).To(Equal("live")) + Expect(result.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8")) + Expect(result.ContentLinkURL).To(Equal("https://youtube.com/live")) + Expect(result.ContentLinkText).To(Equal("Watch Live")) + + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + }) + + It("assigns ID and timestamps automatically on create", func() { + item := &model.PodcastLiveItem{ + ChannelID: "pc-1", + Status: "live", + } + Expect(repo.Upsert(item)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result.ID).ToNot(BeEmpty()) + Expect(result.CreatedAt.IsZero()).To(BeFalse()) + Expect(result.UpdatedAt.IsZero()).To(BeFalse()) + + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + }) + + It("updates existing live item (latest wins)", func() { + first := &model.PodcastLiveItem{ + ChannelID: "pc-1", + GUID: "live-guid-001", + Title: "Original Title", + Status: "pending", + } + Expect(repo.Upsert(first)).To(Succeed()) + + second := &model.PodcastLiveItem{ + ChannelID: "pc-1", + GUID: "live-guid-001", + Title: "Updated Title", + Status: "live", + } + Expect(repo.Upsert(second)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result.Title).To(Equal("Updated Title")) + Expect(result.Status).To(Equal("live")) + + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + }) + + It("preserves created_at on update", func() { + item := &model.PodcastLiveItem{ + ChannelID: "pc-1", + Status: "pending", + } + Expect(repo.Upsert(item)).To(Succeed()) + + original, _ := repo.GetByChannel("pc-1") + originalCreatedAt := original.CreatedAt + + item2 := &model.PodcastLiveItem{ + ChannelID: "pc-1", + Status: "live", + } + Expect(repo.Upsert(item2)).To(Succeed()) + + updated, _ := repo.GetByChannel("pc-1") + Expect(updated.CreatedAt.UTC().Truncate(time.Second)). + To(Equal(originalCreatedAt.UTC().Truncate(time.Second))) + + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + }) + + It("returns ErrNotFound for unknown channel", func() { + _, err := repo.GetByChannel("no-such-channel") + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("handles zero-value start/end times", func() { + item := &model.PodcastLiveItem{ + ChannelID: "pc-1", + Status: "live", + } + Expect(repo.Upsert(item)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + }) + }) + + Describe("DeleteByChannel", func() { + It("removes live item for the given channel", func() { + Expect(repo.Upsert(&model.PodcastLiveItem{ChannelID: "pc-1", Status: "live"})).To(Succeed()) + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + + _, err := repo.GetByChannel("pc-1") + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("does not error when no item exists", func() { + Expect(repo.DeleteByChannel("no-such-channel")).To(Succeed()) + }) + }) +}) diff --git a/persistence/podcast_person_repository.go b/persistence/podcast_person_repository.go new file mode 100644 index 000000000..6d378160c --- /dev/null +++ b/persistence/podcast_person_repository.go @@ -0,0 +1,83 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastPersonRepository struct { + sqlRepository +} + +func NewPodcastPersonRepository(ctx context.Context, db dbx.Builder) model.PodcastPersonRepository { + r := &podcastPersonRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.PodcastPerson{}, nil) + return r +} + +func (r *podcastPersonRepository) GetByChannel(channelID string) (model.PodcastPersons, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}) + var result model.PodcastPersons + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastPersonRepository) GetByEpisode(episodeID string) (model.PodcastPersons, error) { + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeID}) + var result model.PodcastPersons + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastPersonRepository) GetByEpisodes(episodeIDs []string) (model.PodcastPersons, error) { + if len(episodeIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeIDs}) + var result model.PodcastPersons + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastPersonRepository) SaveForChannel(channelID string, persons []model.PodcastPerson) error { + if err := r.delete(Eq{"channel_id": channelID}); err != nil { + return err + } + now := time.Now() + for i := range persons { + persons[i].ID = id.NewRandom() + persons[i].ChannelID = channelID + persons[i].EpisodeID = "" + persons[i].CreatedAt = now + if _, err := r.put(persons[i].ID, &persons[i]); err != nil { + return err + } + } + return nil +} + +func (r *podcastPersonRepository) SaveForEpisode(episodeID string, persons []model.PodcastPerson) error { + if err := r.delete(Eq{"episode_id": episodeID}); err != nil { + return err + } + now := time.Now() + for i := range persons { + persons[i].ID = id.NewRandom() + persons[i].EpisodeID = episodeID + persons[i].ChannelID = "" + persons[i].CreatedAt = now + if _, err := r.put(persons[i].ID, &persons[i]); err != nil { + return err + } + } + return nil +} + +var _ model.PodcastPersonRepository = (*podcastPersonRepository)(nil) diff --git a/persistence/podcast_person_repository_test.go b/persistence/podcast_person_repository_test.go new file mode 100644 index 000000000..25ea44a36 --- /dev/null +++ b/persistence/podcast_person_repository_test.go @@ -0,0 +1,172 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastPersonRepository", func() { + var repo model.PodcastPersonRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastPersonRepository(ctx, GetDBXBuilder()) + }) + + Describe("SaveForChannel and GetByChannel", func() { + It("saves and retrieves channel-level persons", func() { + persons := []model.PodcastPerson{ + {Name: "Jane Host", Role: "host", Group: "cast", Img: "https://example.com/jane.jpg", Href: "https://example.com/jane"}, + {Name: "Bob Producer", Role: "producer", Group: "crew"}, + } + Expect(repo.SaveForChannel("pc-1", persons)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + var names []string + for _, p := range result { + names = append(names, p.Name) + } + Expect(names).To(ConsistOf("Jane Host", "Bob Producer")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("persists all person fields", func() { + persons := []model.PodcastPerson{ + {Name: "Jane Host", Role: "host", Group: "cast", Img: "https://example.com/jane.jpg", Href: "https://example.com/jane"}, + } + Expect(repo.SaveForChannel("pc-1", persons)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].Name).To(Equal("Jane Host")) + Expect(result[0].Role).To(Equal("host")) + Expect(result[0].Group).To(Equal("cast")) + Expect(result[0].Img).To(Equal("https://example.com/jane.jpg")) + Expect(result[0].Href).To(Equal("https://example.com/jane")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("replaces existing persons on re-save", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Old Host", Role: "host", Group: "cast"}})).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "New Host", Role: "host", Group: "cast"}})).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("New Host")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("clears persons when saved with nil slice", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Jane Host", Role: "host", Group: "cast"}})).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("does not affect persons of other channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Host A", Role: "host", Group: "cast"}})).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastPerson{{Name: "Host B", Role: "host", Group: "cast"}})).To(Succeed()) + + resultA, _ := repo.GetByChannel("pc-1") + resultB, _ := repo.GetByChannel("pc-2") + Expect(resultA).To(HaveLen(1)) + Expect(resultB).To(HaveLen(1)) + Expect(resultA[0].Name).To(Equal("Host A")) + Expect(resultB[0].Name).To(Equal("Host B")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returns empty list for unknown channel", func() { + result, err := repo.GetByChannel("no-such-channel") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("SaveForEpisode and GetByEpisode", func() { + It("saves and retrieves episode-level persons", func() { + persons := []model.PodcastPerson{ + {Name: "John Guest", Role: "guest", Group: "cast"}, + } + Expect(repo.SaveForEpisode("pe-1", persons)).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("John Guest")) + Expect(result[0].Role).To(Equal("guest")) + + Expect(repo.SaveForEpisode("pe-1", nil)).To(Succeed()) + }) + + It("replaces existing episode persons on re-save", func() { + Expect(repo.SaveForEpisode("pe-1", []model.PodcastPerson{{Name: "Old Guest", Role: "guest", Group: "cast"}})).To(Succeed()) + Expect(repo.SaveForEpisode("pe-1", []model.PodcastPerson{{Name: "New Guest", Role: "guest", Group: "cast"}})).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("New Guest")) + + Expect(repo.SaveForEpisode("pe-1", nil)).To(Succeed()) + }) + + It("returns empty list for unknown episode", func() { + result, err := repo.GetByEpisode("no-such-episode") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("GetByEpisodes — bulk query", func() { + It("returns persons for multiple episodes in one query", func() { + Expect(repo.SaveForEpisode("pe-1", []model.PodcastPerson{{Name: "Guest A", Role: "guest", Group: "cast"}})).To(Succeed()) + Expect(repo.SaveForEpisode("pe-2", []model.PodcastPerson{{Name: "Guest B", Role: "guest", Group: "cast"}})).To(Succeed()) + + result, err := repo.GetByEpisodes([]string{"pe-1", "pe-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + names := []string{result[0].Name, result[1].Name} + Expect(names).To(ConsistOf("Guest A", "Guest B")) + + Expect(repo.SaveForEpisode("pe-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("pe-2", nil)).To(Succeed()) + }) + + It("returns empty list for empty id slice", func() { + result, err := repo.GetByEpisodes([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("auto ID generation", func() { + It("assigns an ID automatically on SaveForChannel", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Jane", Role: "host", Group: "cast"}})).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + }) +}) diff --git a/persistence/podcast_podroll_repository.go b/persistence/podcast_podroll_repository.go new file mode 100644 index 000000000..24cd75640 --- /dev/null +++ b/persistence/podcast_podroll_repository.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastPodrollRepository struct { + sqlRepository +} + +func NewPodcastPodrollRepository(ctx context.Context, db dbx.Builder) model.PodcastPodrollRepository { + r := &podcastPodrollRepository{} + r.ctx = ctx + r.db = db + // Must set tableName before registerModel to avoid auto-derived name mismatch. + r.tableName = "podcast_podroll" + r.registerModel(&model.PodcastPodrollItem{}, nil) + return r +} + +func (r *podcastPodrollRepository) GetByChannel(channelID string) (model.PodcastPodrollItems, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}).OrderBy("sort_order") + var result model.PodcastPodrollItems + return result, r.queryAll(sel, &result) +} + +func (r *podcastPodrollRepository) GetByChannels(channelIDs []string) (model.PodcastPodrollItems, error) { + if len(channelIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}).OrderBy("channel_id, sort_order") + var result model.PodcastPodrollItems + return result, r.queryAll(sel, &result) +} + +func (r *podcastPodrollRepository) SaveForChannel(channelID string, items []model.PodcastPodrollItem) error { + if err := r.delete(Eq{"channel_id": channelID}); err != nil { + return err + } + now := time.Now() + for i := range items { + items[i].ID = id.NewRandom() + items[i].ChannelID = channelID + items[i].SortOrder = i + items[i].CreatedAt = now + if _, err := r.put(items[i].ID, &items[i]); err != nil { + return err + } + } + return nil +} + +var _ model.PodcastPodrollRepository = (*podcastPodrollRepository)(nil) diff --git a/persistence/podcast_podroll_repository_test.go b/persistence/podcast_podroll_repository_test.go new file mode 100644 index 000000000..fa5e0c90d --- /dev/null +++ b/persistence/podcast_podroll_repository_test.go @@ -0,0 +1,145 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastPodrollRepository", func() { + var repo model.PodcastPodrollRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastPodrollRepository(ctx, GetDBXBuilder()) + }) + + Describe("SaveForChannel and GetByChannel", func() { + It("saves and retrieves podroll items", func() { + items := []model.PodcastPodrollItem{ + {FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"}, + {FeedGUID: "guid-b", FeedURL: "https://b.example.com/feed.xml", Title: "Show B"}, + } + Expect(repo.SaveForChannel("pc-1", items)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("preserves sort_order in insertion order", func() { + items := []model.PodcastPodrollItem{ + {FeedURL: "https://first.example.com/feed.xml", Title: "First"}, + {FeedURL: "https://second.example.com/feed.xml", Title: "Second"}, + {FeedURL: "https://third.example.com/feed.xml", Title: "Third"}, + } + Expect(repo.SaveForChannel("pc-1", items)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + Expect(result[0].Title).To(Equal("First")) + Expect(result[1].Title).To(Equal("Second")) + Expect(result[2].Title).To(Equal("Third")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("replaces existing items on re-save", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://old.example.com/feed.xml", Title: "Old Show"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://new.example.com/feed.xml", Title: "New Show"}, + })).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Title).To(Equal("New Show")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("clears items when saved with nil slice", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://example.com/feed.xml", Title: "Some Show"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("does not affect items of other channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://a.example.com/feed.xml", Title: "Channel A Feed"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastPodrollItem{ + {FeedURL: "https://b.example.com/feed.xml", Title: "Channel B Feed"}, + })).To(Succeed()) + + resultA, _ := repo.GetByChannel("pc-1") + resultB, _ := repo.GetByChannel("pc-2") + Expect(resultA).To(HaveLen(1)) + Expect(resultB).To(HaveLen(1)) + Expect(resultA[0].Title).To(Equal("Channel A Feed")) + Expect(resultB[0].Title).To(Equal("Channel B Feed")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returns empty list for unknown channel", func() { + result, err := repo.GetByChannel("no-such-channel") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("assigns ID automatically", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://example.com/feed.xml"}, + })).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + }) + + Describe("GetByChannels — bulk query", func() { + It("returns items for multiple channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://a.example.com/feed.xml", Title: "Feed A"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastPodrollItem{ + {FeedURL: "https://b.example.com/feed.xml", Title: "Feed B"}, + })).To(Succeed()) + + result, err := repo.GetByChannels([]string{"pc-1", "pc-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + titles := []string{result[0].Title, result[1].Title} + Expect(titles).To(ConsistOf("Feed A", "Feed B")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByChannels([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) +}) diff --git a/persistence/podcast_transcript_repository.go b/persistence/podcast_transcript_repository.go new file mode 100644 index 000000000..a96d7e3c1 --- /dev/null +++ b/persistence/podcast_transcript_repository.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastTranscriptRepository struct { + sqlRepository +} + +func NewPodcastTranscriptRepository(ctx context.Context, db dbx.Builder) model.PodcastTranscriptRepository { + r := &podcastTranscriptRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.PodcastTranscript{}, nil) + return r +} + +func (r *podcastTranscriptRepository) GetByEpisode(episodeID string) (model.PodcastTranscripts, error) { + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeID}) + var result model.PodcastTranscripts + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastTranscriptRepository) GetByEpisodes(episodeIDs []string) (model.PodcastTranscripts, error) { + if len(episodeIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeIDs}) + var result model.PodcastTranscripts + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastTranscriptRepository) Save(transcripts []model.PodcastTranscript) error { + for i := range transcripts { + if transcripts[i].ID == "" { + transcripts[i].ID = id.NewRandom() + } + transcripts[i].CreatedAt = time.Now() + if _, err := r.put(transcripts[i].ID, &transcripts[i]); err != nil { + return err + } + } + return nil +} + +func (r *podcastTranscriptRepository) DeleteByEpisode(episodeID string) error { + return r.delete(Eq{"episode_id": episodeID}) +} + +var _ model.PodcastTranscriptRepository = (*podcastTranscriptRepository)(nil) diff --git a/persistence/podcast_transcript_repository_test.go b/persistence/podcast_transcript_repository_test.go new file mode 100644 index 000000000..d7ad0111c --- /dev/null +++ b/persistence/podcast_transcript_repository_test.go @@ -0,0 +1,144 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastTranscriptRepository", func() { + var repo model.PodcastTranscriptRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastTranscriptRepository(ctx, GetDBXBuilder()) + }) + + Describe("Save and GetByEpisode", func() { + It("returns saved transcript by episode id", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/t.vtt")) + Expect(result[0].MimeType).To(Equal("text/vtt")) + Expect(result[0].Language).To(Equal("en")) + Expect(result[0].Rel).To(Equal("captions")) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + }) + + It("saves multiple transcripts for one episode", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"}, + {EpisodeID: "pe-1", URL: "https://example.com/t.srt", MimeType: "application/x-subrip", Language: "en"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + var mimeTypes []string + for _, t := range result { + mimeTypes = append(mimeTypes, t.MimeType) + } + Expect(mimeTypes).To(ConsistOf("text/vtt", "application/x-subrip")) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + }) + + It("stores empty rel when rel attribute is omitted", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/t.txt", MimeType: "text/plain"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].Rel).To(BeEmpty()) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + }) + + It("returns empty list for unknown episode", func() { + result, err := repo.GetByEpisode("no-such-episode") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("GetByEpisodes — bulk query", func() { + It("returns transcripts for multiple episodes in one query", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/t1.vtt", MimeType: "text/vtt"}, + {EpisodeID: "pe-2", URL: "https://example.com/t2.srt", MimeType: "application/x-subrip"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + result, err := repo.GetByEpisodes([]string{"pe-1", "pe-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + episodeIDs := []string{result[0].EpisodeID, result[1].EpisodeID} + Expect(episodeIDs).To(ConsistOf("pe-1", "pe-2")) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + Expect(repo.DeleteByEpisode("pe-2")).To(Succeed()) + }) + + It("returns empty list for empty id slice", func() { + result, err := repo.GetByEpisodes([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("DeleteByEpisode", func() { + It("deletes only transcripts for the specified episode", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/t1.vtt", MimeType: "text/vtt"}, + {EpisodeID: "pe-2", URL: "https://example.com/t2.vtt", MimeType: "text/vtt"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + + result1, _ := repo.GetByEpisode("pe-1") + Expect(result1).To(BeEmpty()) + + result2, _ := repo.GetByEpisode("pe-2") + Expect(result2).To(HaveLen(1)) + + Expect(repo.DeleteByEpisode("pe-2")).To(Succeed()) + }) + + It("succeeds when deleting transcripts for a non-existent episode", func() { + Expect(repo.DeleteByEpisode("no-such-episode")).To(Succeed()) + }) + }) + + Describe("Save — auto ID generation", func() { + It("assigns an ID automatically when none is provided", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/auto.vtt", MimeType: "text/vtt"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + }) + }) +}) diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json index 6b81e02d8..711e5acaf 100644 --- a/resources/i18n/ko.json +++ b/resources/i18n/ko.json @@ -36,7 +36,11 @@ "tags": "추가 태그", "mappedTags": "매핑된 태그", "rawTags": "원시 태그", - "missing": "누락" + "missing": "누락", + "composer": "작곡가", + "albumGain": "앨범 게인", + "trackGain": "트랙 게인", + "disc": "디스크 %{discNumber}" }, "actions": { "addToQueue": "나중에 재생", @@ -45,7 +49,9 @@ "shuffleAll": "모든 노래 셔플", "download": "다운로드", "playNext": "다음 재생", - "info": "정보 얻기" + "info": "정보 얻기", + "showInPlaylist": "재생목록에서 보기", + "instantMix": "인스턴트 믹스" } }, "album": { @@ -131,7 +137,8 @@ "actions": { "topSongs": "인기곡", "shuffle": "셔플", - "radio": "라디오" + "radio": "라디오", + "podcast": "팟캐스트" } }, "user": { @@ -237,6 +244,42 @@ "playNow": "지금 재생" } }, + "podcast": { + "name": "팟캐스트 |||| 팟캐스트", + "fields": { + "title": "제목", + "url": "피드 URL", + "description": "설명", + "status": "상태", + "publishDate": "게시됨", + "duration": "시간", + "episodeCount": "에피소드" + }, + "actions": { + "refresh": "전체 갱신", + "download": "다운로드", + "addChannel": "채널 추가", + "fetchFeed": "피드 정보 가져오기", + "copyUrl": "피드 URL 복사" + }, + "notifications": { + "refreshStarted": "팟캐스트 갱신 시작됨", + "downloadStarted": "다운로드 시작됨", + "channelAdded": "채널 추가됨", + "urlCopied": "피드 URL이 클립보드에 복사됨", + "alreadyExists": "이미 등록된 채널입니다.", + "channelDeleted": "채널 삭제됨", + "episodeDeleted": "에피소드 삭제됨" + }, + "status": { + "new": "새 항목", + "downloading": "다운로드 중", + "completed": "다운로드됨", + "error": "오류", + "skipped": "건너뜀", + "deleted": "삭제됨" + } + }, "share": { "name": "공유 |||| 공유되는 것들", "fields": { @@ -301,15 +344,20 @@ }, "actions": { "scan": "라이브러리 스캔", - "manageUsers": "자용자 접속 관리", + "quickScan": "빠른 스캔", + "fullScan": "전체 스캔", + "manageUsers": "사용자 접속 관리", "viewDetails": "상세 보기" }, "notifications": { "created": "라이브러리가 성공적으로 생성됨", "updated": "라이브러리가 성공적으로 업데이트됨", "deleted": "라이브러리가 성공적으로 삭제됨", - "scanStarted": "라이브러리 스캔 스작됨", - "scanCompleted": "라이브러리 스캔 완료됨" + "scanStarted": "라이브러리 스캔 시작됨", + "scanCompleted": "라이브러리 스캔 완료됨", + "quickScanStarted": "빠른 스캔 시작됨", + "fullScanStarted": "전체 스캔 시작됨", + "scanError": "스캔 시작 오류. 로그를 확인하세요" }, "validation": { "nameRequired": "라이브러리 이름이 필요함", @@ -324,6 +372,82 @@ "scanInProgress": "스캔 진행 중...", "noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음" } + }, + "plugin": { + "name": "플러그인 |||| 플러그인들", + "fields": { + "id": "ID", + "name": "이름", + "description": "설명", + "version": "버전", + "author": "작성자", + "website": "웹사이트", + "permissions": "권한", + "enabled": "활성화", + "status": "상태", + "path": "경로", + "lastError": "오류", + "hasError": "오류", + "updatedAt": "업데이트됨", + "createdAt": "설치됨", + "configKey": "키", + "configValue": "값", + "allUsers": "모든 사용자 허용", + "selectedUsers": "선택된 사용자", + "allLibraries": "모든 라이브러리 허용", + "selectedLibraries": "선택된 라이브러리", + "allowWriteAccess": "쓰기 접근 허용" + }, + "sections": { + "status": "상태", + "info": "플러그인 정보", + "configuration": "구성", + "manifest": "매니페스트", + "usersPermission": "사용자 권한", + "libraryPermission": "라이브러리 권한" + }, + "status": { + "enabled": "활성화", + "disabled": "비활성화" + }, + "actions": { + "enable": "활성화", + "disable": "비활성화", + "disabledDueToError": "활성화하기 전에 오류를 수정하세요", + "disabledUsersRequired": "활성화하기 전에 사용자를 선택하세요", + "disabledLibrariesRequired": "활성화하기 전에 라이브러리를 선택하세요", + "addConfig": "구성 추가", + "rescan": "재스캔" + }, + "notifications": { + "enabled": "플러그인 활성화됨", + "disabled": "플러그인 비활성화됨", + "updated": "플러그인 업데이트됨", + "error": "플러그인 업데이트 오류" + }, + "validation": { + "invalidJson": "구성은 유효한 JSON이어야 함" + }, + "messages": { + "configHelp": "키-값 쌍을 사용하여 플러그인을 구성하세요. 구성이 필요 없는 경우 비워두세요.", + "configValidationError": "구성 유효성 검사 실패:", + "schemaRenderError": "구성 양식을 렌더링할 수 없습니다. 플러그인의 스키마가 유효하지 않을 수 있습니다.", + "clickPermissions": "권한을 클릭하면 자세한 내용을 볼 수 있음", + "noConfig": "설정된 구성 없음", + "allUsersHelp": "활성화하면 플러그인이 미래에 생성될 사용자를 포함한 모든 사용자에 접근할 수 있습니다.", + "noUsers": "선택된 사용자 없음", + "permissionReason": "이유", + "usersRequired": "이 플러그인은 사용자 정보에 접근해야 합니다. 플러그인이 접근할 수 있는 사용자를 선택하거나 '모든 사용자 허용'을 활성화하세요.", + "allLibrariesHelp": "활성화하면 플러그인이 미래에 생성될 라이브러리를 포함한 모든 라이브러리에 접근할 수 있습니다.", + "noLibraries": "선택된 라이브러리 없음", + "librariesRequired": "이 플러그인은 라이브러리 정보에 접근해야 합니다. 플러그인이 접근할 수 있는 라이브러리를 선택하거나 '모든 라이브러리 허용'을 활성화하세요.", + "allowWriteAccessHelp": "활성화하면 플러그인이 라이브러리 디렉터리의 파일을 수정할 수 있습니다. 기본적으로 플러그인은 읽기 전용 접근 권한을 가집니다.", + "requiredHosts": "필수 호스트" + }, + "placeholders": { + "configKey": "키", + "configValue": "값" + } } }, "ra": { @@ -507,7 +631,14 @@ "shareSuccess": "URL이 클립보드에 복사되었음: %{url}", "shareFailure": "URL %{url}을 클립보드에 복사하는 중 오류가 발생하였음", "downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드", - "downloadOriginalFormat": "오리지널 형식으로 다운로드" + "downloadOriginalFormat": "오리지널 형식으로 다운로드", + "uploadCover": "커버 업로드", + "removeCover": "커버 제거", + "coverUploaded": "커버 아트 업데이트됨", + "coverRemoved": "커버 아트 제거됨", + "coverUploadError": "커버 아트 업로드 오류", + "coverRemoveError": "커버 아트 제거 오류", + "startingInstantMix": "인스턴트 믹스 로딩 중..." }, "menu": { "library": "라이브러리", @@ -590,6 +721,7 @@ "currentValue": "현재 값", "configurationFile": "구성 파일", "exportToml": "구성 내보내기 (TOML)", + "downloadToml": "구성 다운로드 (TOML)", "exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성", "exportFailed": "구성 복사 실패", "devFlagsHeader": "개발 플래그 (변경/삭제 가능)", @@ -604,6 +736,7 @@ "serverUptime": "서버 가동 시간", "serverDown": "오프라인", "scanType": "유형", + "selectiveScan": "선택적", "status": "스캔 오류", "elapsedTime": "경과 시간" }, diff --git a/server/events/events.go b/server/events/events.go index ff0a8a40a..ea25d9f42 100644 --- a/server/events/events.go +++ b/server/events/events.go @@ -68,6 +68,16 @@ type NowPlayingCount struct { Count int `json:"count"` } +type PodcastEpisodeProgress struct { + baseEvent + EpisodeID string `json:"episodeId"` + ChannelID string `json:"channelId"` + DownloadedBytes int64 `json:"downloadedBytes"` + Size int64 `json:"size"` + Duration int `json:"duration,omitempty"` + Status string `json:"status,omitempty"` +} + func (rr *RefreshResource) With(resource string, ids ...string) *RefreshResource { if rr.resources == nil { rr.resources = make(map[string][]string) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 57a712a20..14033c850 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -75,6 +75,7 @@ func (api *Router) routes() http.Handler { api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) api.addRadioRoute(r) + api.addPodcastRoute(r) api.R(r, "/tag", model.Tag{}, false) api.R(r, "/scrobble", model.Scrobble{}, false) if conf.Server.EnableSharing { diff --git a/server/nativeapi/podcasts.go b/server/nativeapi/podcasts.go new file mode 100644 index 000000000..a1599fdaf --- /dev/null +++ b/server/nativeapi/podcasts.go @@ -0,0 +1,45 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/podcasts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addPodcastRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.PodcastChannel{}) + } + r.Route("/podcast", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Get("/preview", api.podcastPreview) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Delete("/", rest.Delete(constructor)) + }) + }) +} + +func (api *Router) podcastPreview(w http.ResponseWriter, r *http.Request) { + feedURL := r.URL.Query().Get("url") + if feedURL == "" { + http.Error(w, "url is required", http.StatusBadRequest) + return + } + feed, err := podcasts.ParseFeedPreview(feedURL) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + exists, _ := api.ds.PodcastChannel(r.Context()).ExistsByURL(feedURL) + feed.AlreadyExists = exists + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(feed) +} diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index 220376b15..efe6944c9 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 029046c39..852745698 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -18,6 +18,7 @@ import ( lyricssvc "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" + podcastsvc "github.com/navidrome/navidrome/core/podcasts" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" sonicsvc "github.com/navidrome/navidrome/core/sonic" @@ -54,6 +55,7 @@ type Router struct { metrics metrics.Metrics lyrics lyricssvc.Lyrics transcodeDecision stream.TranscodeDecider + podcasts podcastsvc.Podcasts sonic *sonicsvc.Sonic } @@ -61,6 +63,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider, + podcasts podcastsvc.Podcasts, sonic *sonicsvc.Sonic, ) *Router { r := &Router{ @@ -79,6 +82,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea metrics: metrics, lyrics: lyrics, transcodeDecision: transcodeDecision, + podcasts: podcasts, sonic: sonic, } r.Handler = r.routes() @@ -226,9 +230,17 @@ func (api *Router) routes() http.Handler { h501(r, "jukeboxControl") } - // Not Implemented (yet?) - h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel", - "deletePodcastEpisode", "downloadPodcastEpisode") + r.Group(func(r chi.Router) { + r.Use(getPlayer(api.players)) + h(r, "getPodcasts", api.GetPodcasts) + h(r, "getNewestPodcasts", api.GetNewestPodcasts) + h(r, "createPodcastChannel", api.CreatePodcastChannel) + h(r, "refreshPodcasts", api.RefreshPodcasts) + h(r, "deletePodcastChannel", api.DeletePodcastChannel) + h(r, "deletePodcastEpisode", api.DeletePodcastEpisode) + h(r, "downloadPodcastEpisode", api.DownloadPodcastEpisode) + h(r, "getPodcastEpisode", api.GetPodcastEpisode) + }) h501(r, "createUser", "updateUser", "deleteUser", "changePassword") // Deprecated/Won't implement/Out of scope endpoints diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 58e877b0d..11f3236c0 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -20,10 +20,12 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/podcasts" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/core/stream" @@ -430,6 +432,7 @@ func setupTestDB() { metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, + podcasts.NewPodcastService(context.Background(), ds, ffmpeg.New(), events.NoopBroker()), nil, ) } diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 9324ea9e3..a4f72cd9f 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -257,7 +257,7 @@ func buildArtworkRouter(art artwork.Artwork) *subsonic.Router { ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), - playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, + playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, nil, ) } diff --git a/server/subsonic/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go index c0cb1d359..3c71a994c 100644 --- a/server/subsonic/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -10,11 +10,13 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/podcasts" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" @@ -51,6 +53,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, + podcasts.NewPodcastService(context.Background(), ds, ffmpeg.New(), events.NoopBroker()), sonicSvc, ) } diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index 8713b7a3b..479a9c748 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -23,7 +23,7 @@ var _ = Describe("GetLyricsBySongId", func() { ds = &tests.MockDataStore{ MockedMediaFile: mockRepo, } - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil, nil) DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" }) diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 1b16dfc68..d43ce5a52 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -28,7 +28,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil, nil) }) Describe("Scrobble", func() { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 7610c866a..a24b8faeb 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -41,7 +41,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedRadio: radioRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 2b2a31bf3..bc2144e8f 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -16,6 +16,16 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, {Name: "topSongsByArtistId", Versions: []int32{1}}, + {Name: "getPodcastEpisode", Versions: []int32{1}}, + {Name: "podcastChapters", Versions: []int32{1}}, + {Name: "podcastTranscripts", Versions: []int32{1}}, + {Name: "podcastSeason", Versions: []int32{1}}, + {Name: "podcastPerson", Versions: []int32{1}}, + {Name: "podcastFunding", Versions: []int32{1}}, + {Name: "podcastMedium", Versions: []int32{1}}, + {Name: "podcastPodroll", Versions: []int32{1}}, + {Name: "podcastLiveItem", Versions: []int32{1}}, + {Name: "podcastPodping", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 2615a652d..0c42c5054 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -41,10 +41,10 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { Context("without sonic similarity plugin", func() { BeforeEach(func() { - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) - It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 17 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication @@ -55,7 +55,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(7), + HaveLen(17), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), @@ -63,6 +63,16 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastChapters", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastTranscripts", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastSeason", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPerson", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastFunding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastMedium", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodroll", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastLiveItem", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodping", Versions: []int32{1}}), )) Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), @@ -73,10 +83,10 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { Context("with sonic similarity plugin", func() { BeforeEach(func() { sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) }) - It("should return 7 extensions including sonicSimilarity", func() { + It("should return 18 extensions including all podcast 2.0 and sonicSimilarity", func() { router.ServeHTTP(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -86,15 +96,25 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(8), + HaveLen(18), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastChapters", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastTranscripts", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastSeason", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPerson", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastFunding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastMedium", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodroll", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastLiveItem", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodping", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), )) }) }) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index f18f33b47..57008640a 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -25,7 +25,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -316,7 +316,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/podcasts.go b/server/subsonic/podcasts.go new file mode 100644 index 000000000..2bfd263fa --- /dev/null +++ b/server/subsonic/podcasts.go @@ -0,0 +1,406 @@ +package subsonic + +import ( + "net/http" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id := p.StringOr("id", "") + includeEpisodes := p.BoolOr("includeEpisodes", true) + + ctx := r.Context() + chRepo := api.ds.PodcastChannel(ctx) + + var channels model.PodcastChannels + var err error + + if id != "" { + ch, e := chRepo.Get(id) + if e != nil { + return nil, e + } + channels = model.PodcastChannels{*ch} + } else { + channels, err = chRepo.GetAll(includeEpisodes) + if err != nil { + return nil, err + } + } + + if includeEpisodes && id != "" { + eps, e := api.ds.PodcastEpisode(ctx).GetByChannel(id) + if e != nil { + return nil, e + } + channels[0].Episodes = eps + } + + // Collect channel IDs for bulk queries. + channelIDs := make([]string, len(channels)) + for i, ch := range channels { + channelIDs[i] = ch.ID + } + + // Load channel persons + personRepo := api.ds.PodcastPerson(ctx) + channelPersons := make(map[string]model.PodcastPersons) + for _, ch := range channels { + persons, err := personRepo.GetByChannel(ch.ID) + if err == nil { + channelPersons[ch.ID] = persons + } + } + + // Bulk-load podcast:podroll items + podrollRepo := api.ds.PodcastPodroll(ctx) + allPodrolls, _ := podrollRepo.GetByChannels(channelIDs) + podrollMap := make(map[string]model.PodcastPodrollItems) + for _, pr := range allPodrolls { + podrollMap[pr.ChannelID] = append(podrollMap[pr.ChannelID], pr) + } + + // Load podcast:liveItem per channel + liveItemRepo := api.ds.PodcastLiveItem(ctx) + liveItemMap := make(map[string]*model.PodcastLiveItem) + for _, chID := range channelIDs { + if li, err := liveItemRepo.GetByChannel(chID); err == nil { + liveItemMap[chID] = li + } + } + + // Bulk-load podcast:funding items + fundingRepo := api.ds.PodcastFunding(ctx) + allFunding, _ := fundingRepo.GetByChannels(channelIDs) + fundingMap := make(map[string]model.PodcastFundingItems) + for _, f := range allFunding { + fundingMap[f.ChannelID] = append(fundingMap[f.ChannelID], f) + } + + // Bulk-load podcast:image (channel level) + imageRepo := api.ds.PodcastImage(ctx) + allChannelImages, _ := imageRepo.GetByChannels(channelIDs) + channelImageMap := make(map[string]model.PodcastImages) + for _, img := range allChannelImages { + channelImageMap[img.ChannelID] = append(channelImageMap[img.ChannelID], img) + } + + // Bulk-load episode transcripts, persons, and images when including episodes + var epTranscripts map[string]model.PodcastTranscripts + var epPersons map[string]model.PodcastPersons + var epImages map[string]model.PodcastImages + if includeEpisodes { + var epIDs []string + for _, ch := range channels { + for _, ep := range ch.Episodes { + epIDs = append(epIDs, ep.ID) + } + } + if len(epIDs) > 0 { + transcriptRepo := api.ds.PodcastTranscript(ctx) + allTranscripts, err := transcriptRepo.GetByEpisodes(epIDs) + if err == nil { + epTranscripts = make(map[string]model.PodcastTranscripts) + for _, t := range allTranscripts { + epTranscripts[t.EpisodeID] = append(epTranscripts[t.EpisodeID], t) + } + } + allPersons, err := personRepo.GetByEpisodes(epIDs) + if err == nil { + epPersons = make(map[string]model.PodcastPersons) + for _, p := range allPersons { + epPersons[p.EpisodeID] = append(epPersons[p.EpisodeID], p) + } + } + allEpImages, err := imageRepo.GetByEpisodes(epIDs) + if err == nil { + epImages = make(map[string]model.PodcastImages) + for _, img := range allEpImages { + epImages[img.EpisodeID] = append(epImages[img.EpisodeID], img) + } + } + } + } + + resp := newResponse() + resp.Podcasts = &responses.Podcasts{} + for _, ch := range channels { + rch := responses.PodcastChannel{ + ID: ch.ID, + URL: ch.URL, + Title: ch.Title, + Description: ch.Description, + OriginalImageUrl: ch.ImageURL, + Status: string(ch.Status), + ErrorMessage: ch.ErrorMessage, + // Podcasting 2.0 Tier 1 & 2 + PodcastGuid: ch.PodcastGUID, + Locked: ch.Locked, + Medium: ch.Medium, + UpdateFrequency: ch.UpdateFrequency, + Complete: ch.Complete, + LocationName: ch.LocationName, + LocationGeo: ch.LocationGeo, + LocationOSM: ch.LocationOSM, + License: ch.License, + PublisherName: ch.PublisherName, + PublisherURL: ch.PublisherURL, + // Podcasting 2.0 Tier 3 + UsesPodping: ch.UsesPodping, + } + for _, p := range channelPersons[ch.ID] { + rch.Person = append(rch.Person, responses.PodcastPersonResp{ + Name: p.Name, + Role: p.Role, + Group: p.Group, + Img: p.Img, + Href: p.Href, + }) + } + for _, f := range fundingMap[ch.ID] { + rch.Funding = append(rch.Funding, responses.PodcastFundingResp{ + URL: f.URL, + Text: f.Text, + }) + } + for _, img := range channelImageMap[ch.ID] { + rch.Images = append(rch.Images, responses.PodcastImageResp{ + URL: img.URL, + Width: img.Width, + }) + } + for _, pr := range podrollMap[ch.ID] { + rch.Podroll = append(rch.Podroll, responses.PodcastPodrollResp{ + FeedGUID: pr.FeedGUID, + FeedURL: pr.FeedURL, + Title: pr.Title, + }) + } + if li := liveItemMap[ch.ID]; li != nil { + liveResp := &responses.PodcastLiveItemResp{ + Status: li.Status, + Title: li.Title, + GUID: li.GUID, + EnclosureURL: li.EnclosureURL, + EnclosureType: li.EnclosureType, + ContentLinkURL: li.ContentLinkURL, + ContentLinkText: li.ContentLinkText, + } + if !li.StartTime.IsZero() { + liveResp.StartTime = li.StartTime.UTC().Format(time.RFC3339) + } + if !li.EndTime.IsZero() { + liveResp.EndTime = li.EndTime.UTC().Format(time.RFC3339) + } + rch.LiveItem = liveResp + } + if includeEpisodes { + for _, ep := range ch.Episodes { + ep.Transcripts = epTranscripts[ep.ID] + ep.Persons = epPersons[ep.ID] + ep.Images = epImages[ep.ID] + rch.Episode = append(rch.Episode, buildPodcastEpisode(ep)) + } + } + resp.Podcasts.Channel = append(resp.Podcasts.Channel, rch) + } + return resp, nil +} + +func (api *Router) GetNewestPodcasts(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + count := p.IntOr("count", 20) + + eps, err := api.ds.PodcastEpisode(r.Context()).GetNewest(count) + if err != nil { + return nil, err + } + + resp := newResponse() + resp.NewestPodcasts = &responses.NewestPodcasts{} + for _, ep := range eps { + child := responses.Child{ + Id: ep.ID, + Title: ep.Title, + IsDir: false, + Parent: ep.ChannelID, + Duration: int32(ep.Duration), + Size: ep.Size, + BitRate: int32(ep.BitRate), + Suffix: ep.Suffix, + ContentType: ep.ContentType, + Type: "podcast", + ChannelId: ep.ChannelID, + Description: ep.Description, + Status: string(ep.Status), + } + if !ep.PublishDate.IsZero() { + child.PublishDate = ep.PublishDate.UTC().Format(time.RFC3339) + } + resp.NewestPodcasts.Episode = append(resp.NewestPodcasts.Episode, child) + } + return resp, nil +} + +func (api *Router) CreatePodcastChannel(r *http.Request) (*responses.Subsonic, error) { + if err := requireAdmin(r); err != nil { + return nil, err + } + p := req.Params(r) + feedURL, err := p.String("url") + if err != nil { + return nil, err + } + if err := api.podcasts.AddChannel(r.Context(), feedURL); err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) RefreshPodcasts(r *http.Request) (*responses.Subsonic, error) { + if err := requireAdmin(r); err != nil { + return nil, err + } + if err := api.podcasts.RefreshChannels(r.Context()); err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) DeletePodcastChannel(r *http.Request) (*responses.Subsonic, error) { + if err := requireAdmin(r); err != nil { + return nil, err + } + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + if err := api.podcasts.DeleteChannel(r.Context(), id); err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) DeletePodcastEpisode(r *http.Request) (*responses.Subsonic, error) { + if err := requireAdmin(r); err != nil { + return nil, err + } + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + if err := api.podcasts.DeleteEpisode(r.Context(), id); err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) DownloadPodcastEpisode(r *http.Request) (*responses.Subsonic, error) { + if err := requireAdmin(r); err != nil { + return nil, err + } + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + if err := api.podcasts.DownloadEpisode(r.Context(), id); err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) GetPodcastEpisode(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + ctx := r.Context() + ep, err := api.ds.PodcastEpisode(ctx).Get(id) + if err != nil { + return nil, err + } + + ep.Transcripts, _ = api.ds.PodcastTranscript(ctx).GetByEpisode(ep.ID) + ep.Persons, _ = api.ds.PodcastPerson(ctx).GetByEpisode(ep.ID) + ep.Images, _ = api.ds.PodcastImage(ctx).GetByEpisode(ep.ID) + + resp := newResponse() + re := buildPodcastEpisode(*ep) + resp.PodcastEpisode = &re + return resp, nil +} + +func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode { + re := responses.PodcastEpisode{ + ID: ep.ID, + StreamId: ep.StreamID, + ChannelId: ep.ChannelID, + Title: ep.Title, + Description: ep.Description, + Status: string(ep.Status), + ErrorMessage: ep.ErrorMessage, + Duration: ep.Duration, + Size: ep.Size, + Suffix: ep.Suffix, + ContentType: ep.ContentType, + BitRate: ep.BitRate, + DownloadedBytes: ep.DownloadedBytes, + // Podcasting 2.0 + Season: ep.Season, + SeasonName: ep.SeasonName, + EpisodeNumber: ep.EpisodeNumber, + EpisodeDisplay: ep.EpisodeDisplay, + ChaptersUrl: ep.ChaptersURL, + SoundbiteStart: ep.SoundbiteStart, + SoundbiteDur: ep.SoundbiteDur, + LocationName: ep.LocationName, + LocationGeo: ep.LocationGeo, + LocationOSM: ep.LocationOSM, + License: ep.License, + } + if !ep.PublishDate.IsZero() { + re.PublishDate = ep.PublishDate.UTC().Format(time.RFC3339) + } + for _, t := range ep.Transcripts { + re.Transcript = append(re.Transcript, responses.PodcastTranscriptResp{ + URL: t.URL, + Type: t.MimeType, + Language: t.Language, + Rel: t.Rel, + }) + } + for _, p := range ep.Persons { + re.Person = append(re.Person, responses.PodcastPersonResp{ + Name: p.Name, + Role: p.Role, + Group: p.Group, + Img: p.Img, + Href: p.Href, + }) + } + for _, img := range ep.Images { + re.Images = append(re.Images, responses.PodcastImageResp{ + URL: img.URL, + Width: img.Width, + }) + } + return re +} + +func requireAdmin(r *http.Request) error { + user, ok := request.UserFrom(r.Context()) + if !ok || !user.IsAdmin { + return newError(responses.ErrorAuthorizationFail) + } + return nil +} diff --git a/server/subsonic/podcasts_test.go b/server/subsonic/podcasts_test.go new file mode 100644 index 000000000..e9157fb26 --- /dev/null +++ b/server/subsonic/podcasts_test.go @@ -0,0 +1,594 @@ +package subsonic + +import ( + "context" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/podcasts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// mockPodcastService is a local mock for the Podcasts service interface. +type mockPodcastService struct { + podcasts.Podcasts + addChannelURL string + refreshCalled bool + deleteChannelID string + deleteEpisodeID string + downloadEpisodeID string + err error +} + +func (m *mockPodcastService) AddChannel(_ context.Context, rssURL string) error { + m.addChannelURL = rssURL + return m.err +} +func (m *mockPodcastService) RefreshChannels(_ context.Context) error { + m.refreshCalled = true + return m.err +} +func (m *mockPodcastService) DeleteChannel(_ context.Context, id string) error { + m.deleteChannelID = id + return m.err +} +func (m *mockPodcastService) DeleteEpisode(_ context.Context, id string) error { + m.deleteEpisodeID = id + return m.err +} +func (m *mockPodcastService) DownloadEpisode(_ context.Context, id string) error { + m.downloadEpisodeID = id + return m.err +} + +var _ = Describe("Podcasts", func() { + var api *Router + var ds *tests.MockDataStore + var channelRepo *tests.MockPodcastChannelRepo + var episodeRepo *tests.MockPodcastEpisodeRepo + var svc *mockPodcastService + var adminCtx, userCtx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + channelRepo = tests.CreateMockPodcastChannelRepo() + episodeRepo = tests.CreateMockPodcastEpisodeRepo() + svc = &mockPodcastService{} + ds.MockedPodcastChannel = channelRepo + ds.MockedPodcastEpisode = episodeRepo + + api = &Router{ds: ds, podcasts: svc} + adminCtx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true}) + userCtx = request.WithUser(context.Background(), model.User{ID: "user", IsAdmin: false}) + }) + + Describe("GetPodcasts", func() { + BeforeEach(func() { + channelRepo.Data = map[string]*model.PodcastChannel{ + "ch-1": {ID: "ch-1", Title: "Podcast 1", Status: model.PodcastStatusCompleted}, + "ch-2": {ID: "ch-2", Title: "Podcast 2", Status: model.PodcastStatusNew}, + } + }) + + It("returns all channels", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(adminCtx) + + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel).To(HaveLen(2)) + }) + + It("returns channel fields correctly", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(adminCtx) + + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + ids := []string{resp.Podcasts.Channel[0].ID, resp.Podcasts.Channel[1].ID} + Expect(ids).To(ContainElements("ch-1", "ch-2")) + }) + + It("filters by id when provided", func() { + r := newGetRequest("id=ch-1") + r = r.WithContext(adminCtx) + + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel).To(HaveLen(1)) + Expect(resp.Podcasts.Channel[0].ID).To(Equal("ch-1")) + }) + + It("is accessible by regular users", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + + _, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("GetNewestPodcasts", func() { + BeforeEach(func() { + now := time.Now() + episodeRepo.Data = map[string]*model.PodcastEpisode{ + "ep-1": {ID: "ep-1", Title: "Ep1", ChannelID: "ch-1", PublishDate: now.Add(-time.Hour), Status: model.PodcastStatusCompleted}, + "ep-2": {ID: "ep-2", Title: "Ep2", ChannelID: "ch-1", PublishDate: now, Status: model.PodcastStatusNew}, + } + }) + + It("returns episodes in Child format", func() { + r := httptest.NewRequest("GET", "/rest/getNewestPodcasts", nil) + r = r.WithContext(userCtx) + + resp, err := api.GetNewestPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.NewestPodcasts.Episode).To(HaveLen(2)) + }) + + It("sets type to podcast", func() { + r := httptest.NewRequest("GET", "/rest/getNewestPodcasts", nil) + r = r.WithContext(userCtx) + + resp, err := api.GetNewestPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + for _, ep := range resp.NewestPodcasts.Episode { + Expect(ep.Type).To(Equal("podcast")) + } + }) + + It("respects count parameter", func() { + r := newGetRequest("count=1") + r = r.WithContext(userCtx) + + resp, err := api.GetNewestPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.NewestPodcasts.Episode).To(HaveLen(1)) + }) + + It("defaults count to 20", func() { + r := httptest.NewRequest("GET", "/rest/getNewestPodcasts", nil) + r = r.WithContext(userCtx) + + _, err := api.GetNewestPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("CreatePodcastChannel", func() { + It("calls service with URL", func() { + r := newGetRequest("url=https://example.com/feed.xml") + r = r.WithContext(adminCtx) + + _, err := api.CreatePodcastChannel(r) + Expect(err).ToNot(HaveOccurred()) + Expect(svc.addChannelURL).To(Equal("https://example.com/feed.xml")) + }) + + It("denies non-admin users", func() { + r := newGetRequest("url=https://example.com/feed.xml") + r = r.WithContext(userCtx) + + _, err := api.CreatePodcastChannel(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when url param is missing", func() { + r := newGetRequest() + r = r.WithContext(adminCtx) + + _, err := api.CreatePodcastChannel(r) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("RefreshPodcasts", func() { + It("calls service", func() { + r := httptest.NewRequest("GET", "/rest/refreshPodcasts", nil) + r = r.WithContext(adminCtx) + + _, err := api.RefreshPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(svc.refreshCalled).To(BeTrue()) + }) + + It("denies non-admin users", func() { + r := httptest.NewRequest("GET", "/rest/refreshPodcasts", nil) + r = r.WithContext(userCtx) + + _, err := api.RefreshPodcasts(r) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("DeletePodcastChannel", func() { + It("calls service with id", func() { + r := newGetRequest("id=ch-1") + r = r.WithContext(adminCtx) + + _, err := api.DeletePodcastChannel(r) + Expect(err).ToNot(HaveOccurred()) + Expect(svc.deleteChannelID).To(Equal("ch-1")) + }) + + It("denies non-admin users", func() { + r := newGetRequest("id=ch-1") + r = r.WithContext(userCtx) + + _, err := api.DeletePodcastChannel(r) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("DeletePodcastEpisode", func() { + It("calls service with id", func() { + r := newGetRequest("id=ep-1") + r = r.WithContext(adminCtx) + + _, err := api.DeletePodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(svc.deleteEpisodeID).To(Equal("ep-1")) + }) + + It("denies non-admin users", func() { + r := newGetRequest("id=ep-1") + r = r.WithContext(userCtx) + + _, err := api.DeletePodcastEpisode(r) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("DownloadPodcastEpisode", func() { + It("calls service with id", func() { + r := newGetRequest("id=ep-1") + r = r.WithContext(adminCtx) + + _, err := api.DownloadPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(svc.downloadEpisodeID).To(Equal("ep-1")) + }) + + It("denies non-admin users", func() { + r := newGetRequest("id=ep-1") + r = r.WithContext(userCtx) + + _, err := api.DownloadPodcastEpisode(r) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("GetPodcastEpisode", func() { + BeforeEach(func() { + episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ + ID: "ep-1", + Title: "Test Episode", + Status: model.PodcastStatusCompleted, + } + }) + + It("returns single episode", func() { + r := newGetRequest("id=ep-1") + r = r.WithContext(userCtx) + + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode).ToNot(BeNil()) + Expect(resp.PodcastEpisode.ID).To(Equal("ep-1")) + }) + + It("returns error for unknown id", func() { + r := newGetRequest("id=no-such-id") + r = r.WithContext(userCtx) + + _, err := api.GetPodcastEpisode(r) + Expect(err).To(HaveOccurred()) + }) + + It("is accessible by regular users", func() { + r := newGetRequest("id=ep-1") + r = r.WithContext(userCtx) + + _, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("GetPodcasts — Podcasting 2.0 channel fields", func() { + BeforeEach(func() { + channelRepo.Data = map[string]*model.PodcastChannel{ + "ch-p20": { + ID: "ch-p20", + Title: "P2.0 Podcast", + Status: model.PodcastStatusCompleted, + PodcastGUID: "917393e3-1b1e-5cef-ace4-edaa54e1f810", + Locked: true, + Medium: "podcast", + FundingURL: "https://example.com/donate", + FundingText: "Support us!", + UpdateFrequency: "Weekly", + UpdateRRule: "FREQ=WEEKLY", + Complete: false, + }, + } + }) + + It("includes podcastGuid in response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].PodcastGuid).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810")) + }) + + It("includes locked flag in response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].Locked).To(BeTrue()) + }) + + It("includes medium in response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].Medium).To(Equal("podcast")) + }) + + It("includes funding items in response", func() { + fundingRepo := tests.CreateMockPodcastFundingRepo() + _ = fundingRepo.SaveForChannel("ch-p20", []model.PodcastFundingItem{ + {URL: "https://example.com/donate", Text: "Support us!"}, + }) + ds.MockedPodcastFunding = fundingRepo + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + ch := resp.Podcasts.Channel[0] + Expect(ch.Funding).To(HaveLen(1)) + Expect(ch.Funding[0].URL).To(Equal("https://example.com/donate")) + Expect(ch.Funding[0].Text).To(Equal("Support us!")) + }) + + It("includes updateFrequency in response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].UpdateFrequency).To(Equal("Weekly")) + }) + + It("includes channel person list from PersonRepo", func() { + personRepo := tests.CreateMockPodcastPersonRepo() + _ = personRepo.SaveForChannel("ch-p20", []model.PodcastPerson{ + {Name: "Jane Host", Role: "host", Group: "cast"}, + }) + ds.MockedPodcastPerson = personRepo + + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].Person).To(HaveLen(1)) + Expect(resp.Podcasts.Channel[0].Person[0].Name).To(Equal("Jane Host")) + Expect(resp.Podcasts.Channel[0].Person[0].Role).To(Equal("host")) + }) + }) + + Describe("GetPodcastEpisode — Podcasting 2.0 episode fields", func() { + BeforeEach(func() { + episodeRepo.Data["ep-p20"] = &model.PodcastEpisode{ + ID: "ep-p20", + Title: "P2.0 Episode", + Status: model.PodcastStatusCompleted, + Season: 2, + SeasonName: "Season Two", + EpisodeNumber: "5", + EpisodeDisplay: "Ep.5", + ChaptersURL: "https://example.com/chapters.json", + ChaptersType: "application/json+chapters", + SoundbiteStart: 73.5, + SoundbiteDur: 60.0, + SoundbiteTitle: "Best moment", + } + }) + + It("includes season number and name in response", func() { + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.Season).To(Equal(2)) + Expect(resp.PodcastEpisode.SeasonName).To(Equal("Season Two")) + }) + + It("includes episode number and display label in response", func() { + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.EpisodeNumber).To(Equal("5")) + Expect(resp.PodcastEpisode.EpisodeDisplay).To(Equal("Ep.5")) + }) + + It("includes chaptersUrl in response", func() { + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.ChaptersUrl).To(Equal("https://example.com/chapters.json")) + }) + + It("includes soundbite fields in response", func() { + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001)) + Expect(resp.PodcastEpisode.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001)) + }) + + It("includes transcript array from TranscriptRepo in response", func() { + transcriptRepo := tests.CreateMockPodcastTranscriptRepo() + _ = transcriptRepo.Save([]model.PodcastTranscript{ + {EpisodeID: "ep-p20", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"}, + {EpisodeID: "ep-p20", URL: "https://example.com/t.srt", MimeType: "application/x-subrip", Language: "en"}, + }) + ds.MockedPodcastTranscript = transcriptRepo + + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.Transcript).To(HaveLen(2)) + // map iteration order is non-deterministic; use ConsistOf for order-independent check + types := []string{ + resp.PodcastEpisode.Transcript[0].Type, + resp.PodcastEpisode.Transcript[1].Type, + } + Expect(types).To(ConsistOf("text/vtt", "application/x-subrip")) + var vttRel string + for _, t := range resp.PodcastEpisode.Transcript { + if t.Type == "text/vtt" { + vttRel = t.Rel + } + } + Expect(vttRel).To(Equal("captions")) + }) + + It("includes person array from PersonRepo in response", func() { + personRepo := tests.CreateMockPodcastPersonRepo() + _ = personRepo.SaveForEpisode("ep-p20", []model.PodcastPerson{ + {Name: "Jane Host", Role: "host", Group: "cast"}, + }) + ds.MockedPodcastPerson = personRepo + + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.Person).To(HaveLen(1)) + Expect(resp.PodcastEpisode.Person[0].Name).To(Equal("Jane Host")) + }) + }) + + Describe("GetPodcasts — Tier 3 fields", func() { + BeforeEach(func() { + channelRepo.Data = map[string]*model.PodcastChannel{ + "ch-t3": { + ID: "ch-t3", + Title: "Tier3 Podcast", + Status: model.PodcastStatusCompleted, + UsesPodping: true, + }, + } + }) + + It("includes usesPodping in channel response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].UsesPodping).To(BeTrue()) + }) + + It("includes podroll items in channel response", func() { + podrollRepo := tests.CreateMockPodcastPodrollRepo() + _ = podrollRepo.SaveForChannel("ch-t3", []model.PodcastPodrollItem{ + {FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"}, + {FeedGUID: "guid-b", FeedURL: "https://b.example.com/feed.xml"}, + }) + ds.MockedPodcastPodroll = podrollRepo + + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + ch := resp.Podcasts.Channel[0] + Expect(ch.Podroll).To(HaveLen(2)) + feedURLs := []string{ch.Podroll[0].FeedURL, ch.Podroll[1].FeedURL} + Expect(feedURLs).To(ConsistOf("https://a.example.com/feed.xml", "https://b.example.com/feed.xml")) + }) + + It("includes podroll title and feedGuid", func() { + podrollRepo := tests.CreateMockPodcastPodrollRepo() + _ = podrollRepo.SaveForChannel("ch-t3", []model.PodcastPodrollItem{ + {FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"}, + }) + ds.MockedPodcastPodroll = podrollRepo + + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + item := resp.Podcasts.Channel[0].Podroll[0] + Expect(item.FeedGUID).To(Equal("guid-a")) + Expect(item.Title).To(Equal("Show A")) + }) + + It("includes liveItem in channel response", func() { + liveItemRepo := tests.CreateMockPodcastLiveItemRepo() + _ = liveItemRepo.Upsert(&model.PodcastLiveItem{ + ChannelID: "ch-t3", + GUID: "live-guid-001", + Title: "Live Show", + Status: "live", + EnclosureURL: "https://stream.example.com/live.m3u8", + EnclosureType: "application/x-mpegURL", + ContentLinkURL: "https://youtube.com/live", + ContentLinkText: "Watch Live", + }) + ds.MockedPodcastLiveItem = liveItemRepo + + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + ch := resp.Podcasts.Channel[0] + Expect(ch.LiveItem).ToNot(BeNil()) + Expect(ch.LiveItem.Status).To(Equal("live")) + Expect(ch.LiveItem.GUID).To(Equal("live-guid-001")) + Expect(ch.LiveItem.Title).To(Equal("Live Show")) + Expect(ch.LiveItem.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8")) + Expect(ch.LiveItem.ContentLinkURL).To(Equal("https://youtube.com/live")) + Expect(ch.LiveItem.ContentLinkText).To(Equal("Watch Live")) + }) + + It("omits liveItem when none exists", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].LiveItem).To(BeNil()) + }) + + It("formats liveItem startTime and endTime as RFC3339", func() { + liveItemRepo := tests.CreateMockPodcastLiveItemRepo() + _ = liveItemRepo.Upsert(&model.PodcastLiveItem{ + ChannelID: "ch-t3", + Status: "live", + StartTime: time.Date(2024, 4, 27, 8, 0, 0, 0, time.UTC), + EndTime: time.Date(2024, 4, 27, 9, 0, 0, 0, time.UTC), + }) + ds.MockedPodcastLiveItem = liveItemRepo + + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + li := resp.Podcasts.Channel[0].LiveItem + Expect(li).ToNot(BeNil()) + Expect(li.StartTime).To(Equal("2024-04-27T08:00:00Z")) + Expect(li.EndTime).To(Equal("2024-04-27T09:00:00Z")) + }) + }) +}) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 252eee4c6..62736f452 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -54,6 +54,10 @@ type Subsonic struct { InternetRadioStations *InternetRadioStations `xml:"internetRadioStations,omitempty" json:"internetRadioStations,omitempty"` + Podcasts *Podcasts `xml:"podcasts,omitempty" json:"podcasts,omitempty"` + NewestPodcasts *NewestPodcasts `xml:"newestPodcasts,omitempty" json:"newestPodcasts,omitempty"` + PodcastEpisode *PodcastEpisode `xml:"podcastEpisode,omitempty" json:"podcastEpisode,omitempty"` + JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus,omitempty" json:"jukeboxStatus,omitempty"` JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"` @@ -164,7 +168,12 @@ type Child struct { SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"` BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"` - *OpenSubsonicChild `xml:",omitempty" json:",omitempty"` + // Podcast-specific fields (used in getNewestPodcasts) + ChannelId string `xml:"channelId,attr,omitempty" json:"channelId,omitempty"` + Description string `xml:"description,attr,omitempty" json:"description,omitempty"` + Status string `xml:"status,attr,omitempty" json:"status,omitempty"` + PublishDate string `xml:"publishDate,attr,omitempty" json:"publishDate,omitempty"` + *OpenSubsonicChild `xml:",omitempty" json:",omitempty"` } type OpenSubsonicChild struct { @@ -685,6 +694,127 @@ type TranscodeDecision struct { TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"` } +// Podcast types + +type Podcasts struct { + Channel []PodcastChannel `xml:"channel,omitempty" json:"channel,omitempty"` +} + +type NewestPodcasts struct { + Episode []Child `xml:"episode,omitempty" json:"episode,omitempty"` +} + +type PodcastFundingResp struct { + URL string `xml:"url,attr,omitempty" json:"url,omitempty"` + Text string `xml:"text,attr,omitempty" json:"text,omitempty"` +} + +type PodcastImageResp struct { + URL string `xml:"url,attr,omitempty" json:"url,omitempty"` + Width int `xml:"width,attr,omitempty" json:"width,omitempty"` +} + +type PodcastChannel struct { + ID string `xml:"id,attr" json:"id"` + URL string `xml:"url,attr" json:"url"` + Title string `xml:"title,attr,omitempty" json:"title,omitempty"` + Description string `xml:"description,attr,omitempty" json:"description,omitempty"` + CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` + OriginalImageUrl string `xml:"originalImageUrl,attr,omitempty" json:"originalImageUrl,omitempty"` + Status string `xml:"status,attr" json:"status"` + ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"` + Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"` + + // Podcasting 2.0 Tier 1 & 2 + PodcastGuid string `xml:"podcastGuid,attr,omitempty" json:"podcastGuid,omitempty"` + Locked bool `xml:"locked,attr,omitempty" json:"locked,omitempty"` + Medium string `xml:"medium,attr,omitempty" json:"medium,omitempty"` + UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"` + Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"` + LocationName string `xml:"locationName,attr,omitempty" json:"locationName,omitempty"` + LocationGeo string `xml:"locationGeo,attr,omitempty" json:"locationGeo,omitempty"` + LocationOSM string `xml:"locationOsm,attr,omitempty" json:"locationOsm,omitempty"` + License string `xml:"license,attr,omitempty" json:"license,omitempty"` + PublisherName string `xml:"publisherName,attr,omitempty" json:"publisherName,omitempty"` + PublisherURL string `xml:"publisherUrl,attr,omitempty" json:"publisherUrl,omitempty"` + Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` + Funding []PodcastFundingResp `xml:"funding,omitempty" json:"funding,omitempty"` + Images []PodcastImageResp `xml:"image,omitempty" json:"images,omitempty"` + + // Podcasting 2.0 Tier 3 + UsesPodping bool `xml:"usesPodping,attr,omitempty" json:"usesPodping,omitempty"` + Podroll []PodcastPodrollResp `xml:"podroll,omitempty" json:"podroll,omitempty"` + LiveItem *PodcastLiveItemResp `xml:"liveItem,omitempty" json:"liveItem,omitempty"` +} + +// PodcastPodrollResp represents a single recommended feed in a podcast:podroll. +type PodcastPodrollResp struct { + FeedGUID string `xml:"feedGuid,attr,omitempty" json:"feedGuid,omitempty"` + FeedURL string `xml:"feedUrl,attr,omitempty" json:"feedUrl,omitempty"` + Title string `xml:"title,attr,omitempty" json:"title,omitempty"` +} + +// PodcastLiveItemResp represents a podcast:liveItem in the API response. +type PodcastLiveItemResp struct { + Status string `xml:"status,attr" json:"status"` + StartTime string `xml:"startTime,attr,omitempty" json:"startTime,omitempty"` + EndTime string `xml:"endTime,attr,omitempty" json:"endTime,omitempty"` + Title string `xml:"title,attr,omitempty" json:"title,omitempty"` + GUID string `xml:"guid,attr,omitempty" json:"guid,omitempty"` + EnclosureURL string `xml:"enclosureUrl,attr,omitempty" json:"enclosureUrl,omitempty"` + EnclosureType string `xml:"enclosureType,attr,omitempty" json:"enclosureType,omitempty"` + ContentLinkURL string `xml:"contentLinkUrl,attr,omitempty" json:"contentLinkUrl,omitempty"` + ContentLinkText string `xml:"contentLinkText,attr,omitempty" json:"contentLinkText,omitempty"` +} + +type PodcastEpisode struct { + ID string `xml:"id,attr" json:"id"` + StreamId string `xml:"streamId,attr,omitempty" json:"streamId,omitempty"` + ChannelId string `xml:"channelId,attr,omitempty" json:"channelId,omitempty"` + Title string `xml:"title,attr,omitempty" json:"title,omitempty"` + Description string `xml:"description,attr,omitempty" json:"description,omitempty"` + PublishDate string `xml:"publishDate,attr,omitempty" json:"publishDate,omitempty"` + Status string `xml:"status,attr" json:"status"` + ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"` + Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"` + Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"` + Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"` + ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"` + BitRate int `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"` + DownloadedBytes int64 `xml:"downloadedBytes,attr,omitempty" json:"downloadedBytes,omitempty"` + + // Podcasting 2.0 + Season int `xml:"season,attr,omitempty" json:"season,omitempty"` + SeasonName string `xml:"seasonName,attr,omitempty" json:"seasonName,omitempty"` + EpisodeNumber string `xml:"episode,attr,omitempty" json:"episode,omitempty"` + EpisodeDisplay string `xml:"episodeDisplay,attr,omitempty" json:"episodeDisplay,omitempty"` + ChaptersUrl string `xml:"chaptersUrl,attr,omitempty" json:"chaptersUrl,omitempty"` + SoundbiteStart float64 `xml:"soundbiteStart,attr,omitempty" json:"soundbiteStart,omitempty"` + SoundbiteDur float64 `xml:"soundbiteDur,attr,omitempty" json:"soundbiteDur,omitempty"` + LocationName string `xml:"locationName,attr,omitempty" json:"locationName,omitempty"` + LocationGeo string `xml:"locationGeo,attr,omitempty" json:"locationGeo,omitempty"` + LocationOSM string `xml:"locationOsm,attr,omitempty" json:"locationOsm,omitempty"` + License string `xml:"license,attr,omitempty" json:"license,omitempty"` + Transcript []PodcastTranscriptResp `xml:"transcript,omitempty" json:"transcript,omitempty"` + Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` + Images []PodcastImageResp `xml:"image,omitempty" json:"images,omitempty"` +} + +type PodcastTranscriptResp struct { + URL string `xml:"url,attr" json:"url"` + Type string `xml:"type,attr" json:"type"` + Language string `xml:"language,attr,omitempty" json:"language,omitempty"` + Rel string `xml:"rel,attr,omitempty" json:"rel,omitempty"` +} + +type PodcastPersonResp struct { + Name string `xml:"name,attr" json:"name"` + Role string `xml:"role,attr,omitempty" json:"role,omitempty"` + Group string `xml:"group,attr,omitempty" json:"group,omitempty"` + Img string `xml:"img,attr,omitempty" json:"img,omitempty"` + Href string `xml:"href,attr,omitempty" json:"href,omitempty"` +} + // StreamDetails describes audio stream properties for transcoding decisions type StreamDetails struct { Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"` diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index d31a50cfa..21776b5d5 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -23,7 +23,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 8d5cbb974..b22c26abc 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -32,7 +32,7 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo = &tests.MockMediaFileRepo{} ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo} mockTD = &mockTranscodeDecision{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil) w = httptest.NewRecorder() }) @@ -467,7 +467,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for direct play", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) mockTD.resolvedReq = stream.Request{} @@ -484,7 +484,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for transcoding", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) mockTD.resolvedReq = stream.Request{ Format: "mp3", diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 32f56a4f0..aad7261c0 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -8,30 +8,38 @@ import ( ) type MockDataStore struct { - RealDS model.DataStore - MockedLibrary model.LibraryRepository - MockedFolder model.FolderRepository - MockedGenre model.GenreRepository - MockedAlbum model.AlbumRepository - MockedArtist model.ArtistRepository - MockedMediaFile model.MediaFileRepository - MockedTag model.TagRepository - MockedUser model.UserRepository - MockedProperty model.PropertyRepository - MockedPlayer model.PlayerRepository - MockedPlaylist model.PlaylistRepository - MockedPlayQueue model.PlayQueueRepository - MockedShare model.ShareRepository - MockedTranscoding model.TranscodingRepository - MockedUserProps model.UserPropsRepository - MockedScrobbleBuffer model.ScrobbleBufferRepository - MockedScrobble model.ScrobbleRepository - MockedRadio model.RadioRepository - MockedPlugin model.PluginRepository - MockedArtwork model.ArtworkRepository - MockedArtworkQueue model.ArtworkQueueRepository - scrobbleBufferMu sync.Mutex - repoMu sync.Mutex + RealDS model.DataStore + MockedLibrary model.LibraryRepository + MockedFolder model.FolderRepository + MockedGenre model.GenreRepository + MockedAlbum model.AlbumRepository + MockedArtist model.ArtistRepository + MockedMediaFile model.MediaFileRepository + MockedTag model.TagRepository + MockedUser model.UserRepository + MockedProperty model.PropertyRepository + MockedPlayer model.PlayerRepository + MockedPlaylist model.PlaylistRepository + MockedPlayQueue model.PlayQueueRepository + MockedShare model.ShareRepository + MockedTranscoding model.TranscodingRepository + MockedUserProps model.UserPropsRepository + MockedScrobbleBuffer model.ScrobbleBufferRepository + MockedScrobble model.ScrobbleRepository + MockedRadio model.RadioRepository + MockedPlugin model.PluginRepository + MockedArtwork model.ArtworkRepository + MockedArtworkQueue model.ArtworkQueueRepository + MockedPodcastChannel model.PodcastChannelRepository + MockedPodcastEpisode model.PodcastEpisodeRepository + MockedPodcastTranscript model.PodcastTranscriptRepository + MockedPodcastPerson model.PodcastPersonRepository + MockedPodcastPodroll model.PodcastPodrollRepository + MockedPodcastLiveItem model.PodcastLiveItemRepository + MockedPodcastFunding model.PodcastFundingRepository + MockedPodcastImage model.PodcastImageRepository + scrobbleBufferMu sync.Mutex + repoMu sync.Mutex // GC tracking GCCalled bool @@ -321,6 +329,106 @@ func (db *MockDataStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRep return db.MockedArtworkQueue } +func (db *MockDataStore) PodcastChannel(ctx context.Context) model.PodcastChannelRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedPodcastChannel != nil { + return db.MockedPodcastChannel + } + if db.RealDS != nil { + return db.RealDS.PodcastChannel(ctx) + } + db.MockedPodcastChannel = CreateMockPodcastChannelRepo() + return db.MockedPodcastChannel +} + +func (db *MockDataStore) PodcastEpisode(ctx context.Context) model.PodcastEpisodeRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedPodcastEpisode != nil { + return db.MockedPodcastEpisode + } + if db.RealDS != nil { + return db.RealDS.PodcastEpisode(ctx) + } + db.MockedPodcastEpisode = CreateMockPodcastEpisodeRepo() + return db.MockedPodcastEpisode +} + +func (db *MockDataStore) PodcastTranscript(ctx context.Context) model.PodcastTranscriptRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedPodcastTranscript != nil { + return db.MockedPodcastTranscript + } + if db.RealDS != nil { + return db.RealDS.PodcastTranscript(ctx) + } + db.MockedPodcastTranscript = CreateMockPodcastTranscriptRepo() + return db.MockedPodcastTranscript +} + +func (db *MockDataStore) PodcastPerson(ctx context.Context) model.PodcastPersonRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedPodcastPerson != nil { + return db.MockedPodcastPerson + } + if db.RealDS != nil { + return db.RealDS.PodcastPerson(ctx) + } + db.MockedPodcastPerson = CreateMockPodcastPersonRepo() + return db.MockedPodcastPerson +} + +func (db *MockDataStore) PodcastPodroll(ctx context.Context) model.PodcastPodrollRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedPodcastPodroll != nil { + return db.MockedPodcastPodroll + } + if db.RealDS != nil { + return db.RealDS.PodcastPodroll(ctx) + } + db.MockedPodcastPodroll = CreateMockPodcastPodrollRepo() + return db.MockedPodcastPodroll +} + +func (db *MockDataStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveItemRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedPodcastLiveItem != nil { + return db.MockedPodcastLiveItem + } + if db.RealDS != nil { + return db.RealDS.PodcastLiveItem(ctx) + } + db.MockedPodcastLiveItem = CreateMockPodcastLiveItemRepo() + return db.MockedPodcastLiveItem +} + +func (db *MockDataStore) PodcastFunding(ctx context.Context) model.PodcastFundingRepository { + if db.MockedPodcastFunding != nil { + return db.MockedPodcastFunding + } + if db.RealDS != nil { + return db.RealDS.PodcastFunding(ctx) + } + db.MockedPodcastFunding = CreateMockPodcastFundingRepo() + return db.MockedPodcastFunding +} + +func (db *MockDataStore) PodcastImage(ctx context.Context) model.PodcastImageRepository { + if db.MockedPodcastImage != nil { + return db.MockedPodcastImage + } + if db.RealDS != nil { + return db.RealDS.PodcastImage(ctx) + } + db.MockedPodcastImage = CreateMockPodcastImageRepo() + return db.MockedPodcastImage +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } @@ -343,6 +451,8 @@ func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepo return db.Playlist(ctx).(model.ResourceRepository) case model.Radio, *model.Radio: return db.Radio(ctx).(model.ResourceRepository) + case model.PodcastChannel, *model.PodcastChannel: + return db.PodcastChannel(ctx).(model.ResourceRepository) case model.Share, *model.Share: return db.Share(ctx).(model.ResourceRepository) case model.Genre, *model.Genre: diff --git a/tests/mock_podcast_channel_repo.go b/tests/mock_podcast_channel_repo.go new file mode 100644 index 000000000..0e5349570 --- /dev/null +++ b/tests/mock_podcast_channel_repo.go @@ -0,0 +1,81 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastChannelRepo struct { + model.PodcastChannelRepository + Data map[string]*model.PodcastChannel + Err bool +} + +func CreateMockPodcastChannelRepo() *MockPodcastChannelRepo { + return &MockPodcastChannelRepo{Data: map[string]*model.PodcastChannel{}} +} + +func (m *MockPodcastChannelRepo) Get(chanID string) (*model.PodcastChannel, error) { + if m.Err { + return nil, errors.New("error") + } + if c, ok := m.Data[chanID]; ok { + return c, nil + } + return nil, model.ErrNotFound +} + +func (m *MockPodcastChannelRepo) GetAll(withEpisodes bool) (model.PodcastChannels, error) { + if m.Err { + return nil, errors.New("error") + } + result := make(model.PodcastChannels, 0, len(m.Data)) + for _, c := range m.Data { + result = append(result, *c) + } + return result, nil +} + +func (m *MockPodcastChannelRepo) Create(channel *model.PodcastChannel) error { + if m.Err { + return errors.New("error") + } + if channel.ID == "" { + channel.ID = id.NewRandom() + } + m.Data[channel.ID] = channel + return nil +} + +func (m *MockPodcastChannelRepo) UpdateChannel(channel *model.PodcastChannel) error { + if m.Err { + return errors.New("error") + } + m.Data[channel.ID] = channel + return nil +} + +func (m *MockPodcastChannelRepo) ExistsByURL(url string) (bool, error) { + if m.Err { + return false, errors.New("error") + } + for _, c := range m.Data { + if c.URL == url { + return true, nil + } + } + return false, nil +} + +func (m *MockPodcastChannelRepo) Delete(chanID string) error { + if m.Err { + return errors.New("error") + } + if _, ok := m.Data[chanID]; !ok { + return model.ErrNotFound + } + delete(m.Data, chanID) + return nil +} diff --git a/tests/mock_podcast_episode_repo.go b/tests/mock_podcast_episode_repo.go new file mode 100644 index 000000000..ebc7d8c79 --- /dev/null +++ b/tests/mock_podcast_episode_repo.go @@ -0,0 +1,115 @@ +package tests + +import ( + "errors" + "sort" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastEpisodeRepo struct { + model.PodcastEpisodeRepository + Data map[string]*model.PodcastEpisode + Err bool +} + +func CreateMockPodcastEpisodeRepo() *MockPodcastEpisodeRepo { + return &MockPodcastEpisodeRepo{Data: map[string]*model.PodcastEpisode{}} +} + +func (m *MockPodcastEpisodeRepo) Get(epID string) (*model.PodcastEpisode, error) { + if m.Err { + return nil, errors.New("error") + } + if ep, ok := m.Data[epID]; ok { + return ep, nil + } + return nil, model.ErrNotFound +} + +func (m *MockPodcastEpisodeRepo) GetNewest(count int) (model.PodcastEpisodes, error) { + if m.Err { + return nil, errors.New("error") + } + all := make(model.PodcastEpisodes, 0, len(m.Data)) + for _, ep := range m.Data { + all = append(all, *ep) + } + sort.Slice(all, func(i, j int) bool { + return all[i].PublishDate.After(all[j].PublishDate) + }) + if count > 0 && len(all) > count { + all = all[:count] + } + return all, nil +} + +func (m *MockPodcastEpisodeRepo) GetByChannels(channelIDs []string) (model.PodcastEpisodes, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(channelIDs)) + for _, id := range channelIDs { + ids[id] = true + } + var result model.PodcastEpisodes + for _, ep := range m.Data { + if ids[ep.ChannelID] { + result = append(result, *ep) + } + } + return result, nil +} + +func (m *MockPodcastEpisodeRepo) GetByChannel(channelID string) (model.PodcastEpisodes, error) { + if m.Err { + return nil, errors.New("error") + } + result := model.PodcastEpisodes{} + for _, ep := range m.Data { + if ep.ChannelID == channelID { + result = append(result, *ep) + } + } + return result, nil +} + +func (m *MockPodcastEpisodeRepo) GetByGUID(channelID, guid string) (*model.PodcastEpisode, error) { + if m.Err { + return nil, errors.New("error") + } + for _, ep := range m.Data { + if ep.ChannelID == channelID && ep.GUID == guid { + return ep, nil + } + } + return nil, model.ErrNotFound +} + +func (m *MockPodcastEpisodeRepo) Create(ep *model.PodcastEpisode) error { + if m.Err { + return errors.New("error") + } + if ep.ID == "" { + ep.ID = id.NewRandom() + } + m.Data[ep.ID] = ep + return nil +} + +func (m *MockPodcastEpisodeRepo) Update(ep *model.PodcastEpisode) error { + if m.Err { + return errors.New("error") + } + m.Data[ep.ID] = ep + return nil +} + +func (m *MockPodcastEpisodeRepo) Delete(epID string) error { + if m.Err { + return errors.New("error") + } + delete(m.Data, epID) + return nil +} diff --git a/tests/mock_podcast_funding_repo.go b/tests/mock_podcast_funding_repo.go new file mode 100644 index 000000000..2d6bada04 --- /dev/null +++ b/tests/mock_podcast_funding_repo.go @@ -0,0 +1,66 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastFundingRepo struct { + model.PodcastFundingRepository + Data map[string]*model.PodcastFundingItem + Err bool +} + +func CreateMockPodcastFundingRepo() *MockPodcastFundingRepo { + return &MockPodcastFundingRepo{Data: map[string]*model.PodcastFundingItem{}} +} + +func (m *MockPodcastFundingRepo) GetByChannel(channelID string) (model.PodcastFundingItems, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastFundingItems + for _, f := range m.Data { + if f.ChannelID == channelID { + result = append(result, *f) + } + } + return result, nil +} + +func (m *MockPodcastFundingRepo) GetByChannels(channelIDs []string) (model.PodcastFundingItems, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(channelIDs)) + for _, id := range channelIDs { + ids[id] = true + } + var result model.PodcastFundingItems + for _, f := range m.Data { + if ids[f.ChannelID] { + result = append(result, *f) + } + } + return result, nil +} + +func (m *MockPodcastFundingRepo) SaveForChannel(channelID string, items []model.PodcastFundingItem) error { + if m.Err { + return errors.New("error") + } + for k, f := range m.Data { + if f.ChannelID == channelID { + delete(m.Data, k) + } + } + for i := range items { + items[i].ID = id.NewRandom() + items[i].ChannelID = channelID + f := items[i] + m.Data[f.ID] = &f + } + return nil +} diff --git a/tests/mock_podcast_image_repo.go b/tests/mock_podcast_image_repo.go new file mode 100644 index 000000000..b0acb2791 --- /dev/null +++ b/tests/mock_podcast_image_repo.go @@ -0,0 +1,114 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastImageRepo struct { + model.PodcastImageRepository + Data map[string]*model.PodcastImage + Err bool +} + +func CreateMockPodcastImageRepo() *MockPodcastImageRepo { + return &MockPodcastImageRepo{Data: map[string]*model.PodcastImage{}} +} + +func (m *MockPodcastImageRepo) GetByChannel(channelID string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastImages + for _, img := range m.Data { + if img.ChannelID == channelID { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) GetByChannels(channelIDs []string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(channelIDs)) + for _, id := range channelIDs { + ids[id] = true + } + var result model.PodcastImages + for _, img := range m.Data { + if ids[img.ChannelID] { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) GetByEpisode(episodeID string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastImages + for _, img := range m.Data { + if img.EpisodeID == episodeID { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) GetByEpisodes(episodeIDs []string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(episodeIDs)) + for _, id := range episodeIDs { + ids[id] = true + } + var result model.PodcastImages + for _, img := range m.Data { + if ids[img.EpisodeID] { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) SaveForChannel(channelID string, images []model.PodcastImage) error { + if m.Err { + return errors.New("error") + } + for k, img := range m.Data { + if img.ChannelID == channelID { + delete(m.Data, k) + } + } + for i := range images { + images[i].ID = id.NewRandom() + images[i].ChannelID = channelID + img := images[i] + m.Data[img.ID] = &img + } + return nil +} + +func (m *MockPodcastImageRepo) SaveForEpisode(episodeID string, images []model.PodcastImage) error { + if m.Err { + return errors.New("error") + } + for k, img := range m.Data { + if img.EpisodeID == episodeID { + delete(m.Data, k) + } + } + for i := range images { + images[i].ID = id.NewRandom() + images[i].EpisodeID = episodeID + img := images[i] + m.Data[img.ID] = &img + } + return nil +} diff --git a/tests/mock_podcast_live_item_repo.go b/tests/mock_podcast_live_item_repo.go new file mode 100644 index 000000000..970ea8419 --- /dev/null +++ b/tests/mock_podcast_live_item_repo.go @@ -0,0 +1,53 @@ +package tests + +import ( + "errors" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +// MockPodcastLiveItemRepo is a manual in-memory mock for model.PodcastLiveItemRepository. +type MockPodcastLiveItemRepo struct { + model.PodcastLiveItemRepository + Data map[string]*model.PodcastLiveItem // keyed by channelID + Err bool +} + +// CreateMockPodcastLiveItemRepo returns an initialized MockPodcastLiveItemRepo. +func CreateMockPodcastLiveItemRepo() *MockPodcastLiveItemRepo { + return &MockPodcastLiveItemRepo{Data: map[string]*model.PodcastLiveItem{}} +} + +func (m *MockPodcastLiveItemRepo) GetByChannel(channelID string) (*model.PodcastLiveItem, error) { + if m.Err { + return nil, errors.New("error") + } + if item, ok := m.Data[channelID]; ok { + return item, nil + } + return nil, model.ErrNotFound +} + +func (m *MockPodcastLiveItemRepo) Upsert(item *model.PodcastLiveItem) error { + if m.Err { + return errors.New("error") + } + if _, ok := m.Data[item.ChannelID]; !ok { + item.ID = id.NewRandom() + item.CreatedAt = time.Now() + } + item.UpdatedAt = time.Now() + cp := *item + m.Data[item.ChannelID] = &cp + return nil +} + +func (m *MockPodcastLiveItemRepo) DeleteByChannel(channelID string) error { + if m.Err { + return errors.New("error") + } + delete(m.Data, channelID) + return nil +} diff --git a/tests/mock_podcast_person_repo.go b/tests/mock_podcast_person_repo.go new file mode 100644 index 000000000..b1a701d50 --- /dev/null +++ b/tests/mock_podcast_person_repo.go @@ -0,0 +1,97 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastPersonRepo struct { + model.PodcastPersonRepository + Data map[string]*model.PodcastPerson + Err bool +} + +func CreateMockPodcastPersonRepo() *MockPodcastPersonRepo { + return &MockPodcastPersonRepo{Data: map[string]*model.PodcastPerson{}} +} + +func (m *MockPodcastPersonRepo) GetByChannel(channelID string) (model.PodcastPersons, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastPersons + for _, p := range m.Data { + if p.ChannelID == channelID { + result = append(result, *p) + } + } + return result, nil +} + +func (m *MockPodcastPersonRepo) GetByEpisode(episodeID string) (model.PodcastPersons, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastPersons + for _, p := range m.Data { + if p.EpisodeID == episodeID { + result = append(result, *p) + } + } + return result, nil +} + +func (m *MockPodcastPersonRepo) GetByEpisodes(episodeIDs []string) (model.PodcastPersons, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(episodeIDs)) + for _, id := range episodeIDs { + ids[id] = true + } + var result model.PodcastPersons + for _, p := range m.Data { + if ids[p.EpisodeID] { + result = append(result, *p) + } + } + return result, nil +} + +func (m *MockPodcastPersonRepo) SaveForChannel(channelID string, persons []model.PodcastPerson) error { + if m.Err { + return errors.New("error") + } + for k, p := range m.Data { + if p.ChannelID == channelID { + delete(m.Data, k) + } + } + for i := range persons { + persons[i].ID = id.NewRandom() + persons[i].ChannelID = channelID + p := persons[i] + m.Data[p.ID] = &p + } + return nil +} + +func (m *MockPodcastPersonRepo) SaveForEpisode(episodeID string, persons []model.PodcastPerson) error { + if m.Err { + return errors.New("error") + } + for k, p := range m.Data { + if p.EpisodeID == episodeID { + delete(m.Data, k) + } + } + for i := range persons { + persons[i].ID = id.NewRandom() + persons[i].EpisodeID = episodeID + p := persons[i] + m.Data[p.ID] = &p + } + return nil +} diff --git a/tests/mock_podcast_podroll_repo.go b/tests/mock_podcast_podroll_repo.go new file mode 100644 index 000000000..31ba6a2d5 --- /dev/null +++ b/tests/mock_podcast_podroll_repo.go @@ -0,0 +1,70 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +// MockPodcastPodrollRepo is a manual in-memory mock for model.PodcastPodrollRepository. +type MockPodcastPodrollRepo struct { + model.PodcastPodrollRepository + Data map[string]*model.PodcastPodrollItem // keyed by item ID + Err bool +} + +// CreateMockPodcastPodrollRepo returns an initialized MockPodcastPodrollRepo. +func CreateMockPodcastPodrollRepo() *MockPodcastPodrollRepo { + return &MockPodcastPodrollRepo{Data: map[string]*model.PodcastPodrollItem{}} +} + +func (m *MockPodcastPodrollRepo) GetByChannel(channelID string) (model.PodcastPodrollItems, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastPodrollItems + for _, item := range m.Data { + if item.ChannelID == channelID { + result = append(result, *item) + } + } + return result, nil +} + +func (m *MockPodcastPodrollRepo) GetByChannels(channelIDs []string) (model.PodcastPodrollItems, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool) + for _, cid := range channelIDs { + ids[cid] = true + } + var result model.PodcastPodrollItems + for _, item := range m.Data { + if ids[item.ChannelID] { + result = append(result, *item) + } + } + return result, nil +} + +func (m *MockPodcastPodrollRepo) SaveForChannel(channelID string, items []model.PodcastPodrollItem) error { + if m.Err { + return errors.New("error") + } + // Remove old items for this channel. + for k, v := range m.Data { + if v.ChannelID == channelID { + delete(m.Data, k) + } + } + for i := range items { + items[i].ID = id.NewRandom() + items[i].ChannelID = channelID + items[i].SortOrder = i + cp := items[i] + m.Data[cp.ID] = &cp + } + return nil +} diff --git a/tests/mock_podcast_transcript_repo.go b/tests/mock_podcast_transcript_repo.go new file mode 100644 index 000000000..6d43c7241 --- /dev/null +++ b/tests/mock_podcast_transcript_repo.go @@ -0,0 +1,74 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastTranscriptRepo struct { + model.PodcastTranscriptRepository + Data map[string]*model.PodcastTranscript + Err bool +} + +func CreateMockPodcastTranscriptRepo() *MockPodcastTranscriptRepo { + return &MockPodcastTranscriptRepo{Data: map[string]*model.PodcastTranscript{}} +} + +func (m *MockPodcastTranscriptRepo) GetByEpisode(episodeID string) (model.PodcastTranscripts, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastTranscripts + for _, t := range m.Data { + if t.EpisodeID == episodeID { + result = append(result, *t) + } + } + return result, nil +} + +func (m *MockPodcastTranscriptRepo) GetByEpisodes(episodeIDs []string) (model.PodcastTranscripts, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(episodeIDs)) + for _, id := range episodeIDs { + ids[id] = true + } + var result model.PodcastTranscripts + for _, t := range m.Data { + if ids[t.EpisodeID] { + result = append(result, *t) + } + } + return result, nil +} + +func (m *MockPodcastTranscriptRepo) Save(transcripts []model.PodcastTranscript) error { + if m.Err { + return errors.New("error") + } + for i := range transcripts { + if transcripts[i].ID == "" { + transcripts[i].ID = id.NewRandom() + } + t := transcripts[i] + m.Data[t.ID] = &t + } + return nil +} + +func (m *MockPodcastTranscriptRepo) DeleteByEpisode(episodeID string) error { + if m.Err { + return errors.New("error") + } + for k, t := range m.Data { + if t.EpisodeID == episodeID { + delete(m.Data, k) + } + } + return nil +} diff --git a/ui/src/App.jsx b/ui/src/App.jsx index d10aa5a33..c0ff3e0d0 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -19,6 +19,7 @@ import album from './album' import artist from './artist' import playlist from './playlist' import radio from './radio' +import podcast from './podcast' import share from './share' import library from './library' import plugin from './plugin' @@ -33,6 +34,7 @@ import { saveQueueDialogReducer, playerReducer, albumViewReducer, + podcastViewReducer, activityReducer, settingsReducer, replayGainReducer, @@ -69,6 +71,7 @@ const adminStore = createAdminStore({ library: libraryReducer, player: playerReducer, albumView: albumViewReducer, + podcastView: podcastViewReducer, theme: themeReducer, addToPlaylistDialog: addToPlaylistDialogReducer, downloadMenuDialog: downloadMenuDialogReducer, @@ -131,6 +134,10 @@ const Admin = (props) => { name="radio" {...(permissions === 'admin' ? radio.admin : radio.all)} />, + , config.enableSharing && , ({ type: PODCAST_MODE_GRID }) +export const podcastViewTable = () => ({ type: PODCAST_MODE_TABLE }) diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx index df37edfbb..8c5f5c63c 100644 --- a/ui/src/audioplayer/AudioTitle.jsx +++ b/ui/src/audioplayer/AudioTitle.jsx @@ -49,9 +49,11 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { const linkTo = audioInfo.isRadio ? `/radio/${audioInfo.trackId}/show` - : song.playlistId - ? `/playlist/${song.playlistId}/show` - : `/album/${song.albumId}/show` + : song.isPodcast + ? `/podcast/${song.channelId}/show` + : song.playlistId + ? `/playlist/${song.playlistId}/show` + : `/album/${song.albumId}/show` return ( diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js index c91dae875..1db4f1ece 100644 --- a/ui/src/eventStream.js +++ b/ui/src/eventStream.js @@ -24,6 +24,9 @@ const setupHandlers = (stream, dispatchFn) => { stream.addEventListener('nowPlayingCount', eventHandler(dispatchFn)) } stream.addEventListener('keepAlive', eventHandler(dispatchFn)) + stream.addEventListener('podcastEpisodeProgress', (event) => { + window.dispatchEvent(new CustomEvent('podcastEpisodeProgress', { detail: JSON.parse(event.data) })) + }) stream.onerror = (e) => { // eslint-disable-next-line no-console console.log('EventStream error', e) @@ -80,6 +83,9 @@ const startEventStreamLegacy = async (dispatchFn) => { newStream.addEventListener('nowPlayingCount', eventHandler(dispatchFn)) } newStream.addEventListener('keepAlive', eventHandler(dispatchFn)) + newStream.addEventListener('podcastEpisodeProgress', (event) => { + window.dispatchEvent(new CustomEvent('podcastEpisodeProgress', { detail: JSON.parse(event.data) })) + }) newStream.onerror = (e) => { // eslint-disable-next-line no-console console.log('EventStream error', e) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index de96d47c0..c1bc2713f 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -138,7 +138,8 @@ "actions": { "topSongs": "Top Songs", "shuffle": "Shuffle", - "radio": "Radio" + "radio": "Radio", + "podcast": "Podcasts" } }, "user": { @@ -248,6 +249,42 @@ "playNow": "Play Now" } }, + "podcast": { + "name": "Podcast |||| Podcasts", + "fields": { + "title": "Title", + "url": "Feed URL", + "description": "Description", + "status": "Status", + "publishDate": "Published", + "duration": "Duration", + "episodeCount": "Episodes" + }, + "actions": { + "refresh": "Refresh All", + "download": "Download", + "addChannel": "Add Channel", + "fetchFeed": "Fetch Feed Info", + "copyUrl": "Copy Feed URL" + }, + "notifications": { + "refreshStarted": "Podcast refresh started", + "downloadStarted": "Download started", + "channelAdded": "Channel added", + "urlCopied": "Feed URL copied to clipboard", + "alreadyExists": "This channel is already registered.", + "channelDeleted": "Channel deleted", + "episodeDeleted": "Episode deleted" + }, + "status": { + "new": "New", + "downloading": "Downloading", + "completed": "Downloaded", + "error": "Error", + "skipped": "Skipped", + "deleted": "Deleted" + } + }, "share": { "name": "Share |||| Shares", "fields": { diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx index 4718a7cf4..9c614a8dc 100644 --- a/ui/src/playlist/PlaylistSongs.jsx +++ b/ui/src/playlist/PlaylistSongs.jsx @@ -1,14 +1,15 @@ import React, { useCallback, useEffect, useMemo } from 'react' import { BulkActionsToolbar, + FunctionField, + Link, ListToolbar, - TextField, NumberField, + TextField, useDataProvider, + useListContext, useNotify, useVersion, - useListContext, - FunctionField, } from 'react-admin' import clsx from 'clsx' import { useDispatch } from 'react-redux' @@ -16,24 +17,56 @@ import { Card, useMediaQuery } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import ReactDragListView from 'react-drag-listview' import { + ArtistLinkField, + DateField, DurationField, - SongInfo, + QualityInfo, + RatingField, SongContextMenu, SongDatagrid, + SongInfo, SongTitleField, - QualityInfo, - useSelectedFields, useResourceRefresh, - DateField, - ArtistLinkField, - RatingField, + useSelectedFields, } from '../common' -import { AlbumLinkField } from '../song/AlbumLinkField' -import { playTracks } from '../actions' +import { closeExtendedInfoDialog, playTracks } from '../actions' import PlaylistSongBulkActions from './PlaylistSongBulkActions' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import config from '../config' +const AlbumOrChannelLinkField = (props) => { + const dispatch = useDispatch() + const record = props.record + const isPodcast = record?.genre === 'Podcast' + const linkTo = isPodcast + ? `/podcast/${record.albumId}/show` + : `/album/${record.albumId}/show` + return ( + { + e.stopPropagation() + dispatch(closeExtendedInfoDialog()) + }} + > + {record?.album} + + ) +} +AlbumOrChannelLinkField.defaultProps = { addLabel: true, sortBy: 'album' } + +const PodcastAwareArtistField = (props) => { + const record = props.record + const isPodcast = record?.genre === 'Podcast' + const hasNoRealArtist = + !record?.artist || record?.artist === '[Unknown Artist]' + if (isPodcast && hasNoRealArtist) { + return - + } + return +} +PodcastAwareArtistField.defaultProps = { addLabel: true, sortBy: 'artist' } + const useStyles = makeStyles( (theme) => ({ root: {}, @@ -148,8 +181,8 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { return { trackNumber: isDesktop && , title: , - album: isDesktop && , - artist: isDesktop && , + album: isDesktop && , + artist: isDesktop && , albumArtist: isDesktop && , duration: ( diff --git a/ui/src/podcast/EpisodeActions.jsx b/ui/src/podcast/EpisodeActions.jsx new file mode 100644 index 000000000..edea9bd34 --- /dev/null +++ b/ui/src/podcast/EpisodeActions.jsx @@ -0,0 +1,73 @@ +import React from 'react' +import { useDispatch } from 'react-redux' +import { IconButton, CircularProgress } from '@material-ui/core' +import GetAppIcon from '@material-ui/icons/GetApp' +import DeleteIcon from '@material-ui/icons/Delete' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' +import subsonic from '../subsonic' +import { setTrack } from '../actions' + +const EpisodeActions = ({ episode, onRefresh, channelTitle }) => { + const dispatch = useDispatch() + + const handleDownload = async () => { + await subsonic.downloadPodcastEpisode(episode.id) + onRefresh?.() + } + + const handleDelete = async (e) => { + e.stopPropagation() + await subsonic.deletePodcastEpisode(episode.id) + onRefresh?.() + } + + const handlePlay = (e) => { + e.stopPropagation() + dispatch( + setTrack({ + id: episode.streamId, + title: episode.title, + album: channelTitle || episode.channelId, + artist: '', + duration: episode.duration, + suffix: episode.suffix, + isPodcast: true, + channelId: episode.channelId, + }), + ) + } + + if (episode.status === 'downloading') { + return + } + + if (episode.status === 'completed') { + return ( + <> + + + + + + + + ) + } + + if (episode.status === 'new' || episode.status === 'error') { + return ( + <> + + + + + + + + ) + } + + return null +} + +export default EpisodeActions diff --git a/ui/src/podcast/EpisodeActions.test.jsx b/ui/src/podcast/EpisodeActions.test.jsx new file mode 100644 index 000000000..afa6bd745 --- /dev/null +++ b/ui/src/podcast/EpisodeActions.test.jsx @@ -0,0 +1,83 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../subsonic', () => ({ + default: { + downloadPodcastEpisode: vi.fn().mockResolvedValue({}), + deletePodcastEpisode: vi.fn().mockResolvedValue({}), + }, +})) + +vi.mock('react-redux', () => ({ + useDispatch: () => vi.fn(), +})) + +import subsonic from '../subsonic' +import EpisodeActions from './EpisodeActions' + +describe('EpisodeActions', () => { + const onRefresh = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows play and delete buttons for completed episode', () => { + render( + , + ) + expect(screen.getByLabelText('play')).toBeTruthy() + expect(screen.getByLabelText('delete')).toBeTruthy() + expect(screen.queryByLabelText('download')).toBeNull() + }) + + it('shows download and delete buttons for new episode', () => { + render( + , + ) + expect(screen.getByLabelText('download')).toBeTruthy() + expect(screen.getByLabelText('delete')).toBeTruthy() + expect(screen.queryByLabelText('play')).toBeNull() + }) + + it('shows download and delete buttons for error episode', () => { + render( + , + ) + expect(screen.getByLabelText('download')).toBeTruthy() + expect(screen.getByLabelText('delete')).toBeTruthy() + }) + + it('shows spinner only for downloading episode', () => { + render( + , + ) + expect(screen.getByRole('progressbar')).toBeTruthy() + expect(screen.queryByLabelText('play')).toBeNull() + expect(screen.queryByLabelText('download')).toBeNull() + }) + + it('renders nothing for deleted episode', () => { + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) + + it('calls downloadPodcastEpisode when download clicked', () => { + render( + , + ) + fireEvent.click(screen.getByLabelText('download')) + expect(subsonic.downloadPodcastEpisode).toHaveBeenCalledWith('ep-1') + }) + + it('calls deletePodcastEpisode when delete clicked on completed episode', () => { + render( + , + ) + fireEvent.click(screen.getByLabelText('delete')) + expect(subsonic.deletePodcastEpisode).toHaveBeenCalledWith('ep-1') + }) +}) diff --git a/ui/src/podcast/PodcastCreate.jsx b/ui/src/podcast/PodcastCreate.jsx new file mode 100644 index 000000000..8531b8517 --- /dev/null +++ b/ui/src/podcast/PodcastCreate.jsx @@ -0,0 +1,170 @@ +import React, { useState } from 'react' +import { useTranslate, useNotify, useRedirect, useRefresh, Title } from 'react-admin' +import { + Avatar, + Card, + CardContent, + CircularProgress, + Divider, + InputAdornment, + TextField, + Typography, + makeStyles, +} from '@material-ui/core' +import { Button } from 'react-admin' +import MicIcon from '@material-ui/icons/Mic' +import SearchIcon from '@material-ui/icons/Search' +import AddIcon from '@material-ui/icons/Add' +import subsonic from '../subsonic' + +const useStyles = makeStyles((theme) => ({ + root: { marginTop: theme.spacing(2) }, + urlRow: { display: 'flex', gap: theme.spacing(1), alignItems: 'flex-start' }, + urlInput: { flex: 1 }, + preview: { + marginTop: theme.spacing(3), + display: 'flex', + gap: theme.spacing(2), + alignItems: 'flex-start', + }, + previewImage: { width: 120, height: 120, borderRadius: 4, flexShrink: 0 }, + previewInfo: { flex: 1 }, + previewTitle: { fontWeight: 600, marginBottom: theme.spacing(0.5) }, + previewDesc: { color: theme.palette.text.secondary, marginBottom: theme.spacing(1) }, + addButton: { marginTop: theme.spacing(2) }, +})) + +const PodcastCreate = () => { + const translate = useTranslate() + const notify = useNotify() + const redirect = useRedirect() + const refresh = useRefresh() + const classes = useStyles() + const [feedUrl, setFeedUrl] = useState('') + const [fetching, setFetching] = useState(false) + const [adding, setAdding] = useState(false) + const [preview, setPreview] = useState(null) + + const title = translate('ra.page.create', { + name: translate('resources.podcast.name', { smart_count: 1 }), + }) + + const handleFetch = async () => { + if (!feedUrl) return + setFetching(true) + setPreview(null) + try { + const res = await subsonic.previewPodcastFeed(feedUrl) + setPreview(res.json) + } catch { + notify('ra.notification.http_error', { type: 'error' }) + } finally { + setFetching(false) + } + } + + const handleAdd = async () => { + setAdding(true) + try { + await subsonic.createPodcastChannel(feedUrl) + notify('resources.podcast.notifications.channelAdded') + redirect('/podcast') + refresh() + } catch { + notify('ra.notification.http_error', { type: 'error' }) + } finally { + setAdding(false) + } + } + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleFetch() + } + } + + return ( + + + <CardContent> + <div className={classes.urlRow}> + <TextField + className={classes.urlInput} + label={translate('resources.podcast.fields.url')} + value={feedUrl} + onChange={(e) => { setFeedUrl(e.target.value); setPreview(null) }} + onKeyDown={handleKeyDown} + type="url" + variant="outlined" + InputProps={{ + startAdornment: ( + <InputAdornment position="start"> + <MicIcon color="action" /> + </InputAdornment> + ), + }} + /> + <Button + variant="contained" + color="primary" + onClick={handleFetch} + disabled={fetching || !feedUrl} + label="resources.podcast.actions.fetchFeed" + style={{ marginTop: 8 }} + > + {fetching ? <CircularProgress size={18} color="inherit" /> : <SearchIcon />} + </Button> + </div> + + {preview && ( + <> + <Divider style={{ marginTop: 24, marginBottom: 8 }} /> + <div className={classes.preview}> + {preview.imageUrl ? ( + <img src={preview.imageUrl} alt={preview.title} className={classes.previewImage} /> + ) : ( + <Avatar variant="rounded" className={classes.previewImage}> + <MicIcon style={{ fontSize: 48 }} /> + </Avatar> + )} + <div className={classes.previewInfo}> + <Typography variant="h6" className={classes.previewTitle}> + {preview.title} + </Typography> + {preview.episodeCount > 0 && ( + <Typography variant="body2" color="textSecondary"> + {translate('resources.podcast.fields.episodeCount')}: {preview.episodeCount} + </Typography> + )} + {preview.description && ( + <Typography variant="body2" className={classes.previewDesc}> + {preview.description} + </Typography> + )} + {preview.alreadyExists ? ( + <Typography variant="body2" color="error" style={{ marginTop: 8 }}> + {translate('resources.podcast.notifications.alreadyExists')} + </Typography> + ) : ( + <Button + className={classes.addButton} + variant="contained" + color="primary" + onClick={handleAdd} + disabled={adding} + label="resources.podcast.actions.addChannel" + > + {adding ? <CircularProgress size={18} color="inherit" /> : <AddIcon />} + </Button> + )} + </div> + </div> + </> + )} + </CardContent> + </Card> + ) +} + +export default PodcastCreate diff --git a/ui/src/podcast/PodcastCreate.test.jsx b/ui/src/podcast/PodcastCreate.test.jsx new file mode 100644 index 000000000..948594439 --- /dev/null +++ b/ui/src/podcast/PodcastCreate.test.jsx @@ -0,0 +1,103 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../subsonic', () => ({ + default: { + createPodcastChannel: vi.fn().mockResolvedValue({}), + previewPodcastFeed: vi.fn().mockResolvedValue({ + json: { + title: 'Example Podcast', + description: 'An example feed', + episodeCount: 3, + alreadyExists: false, + }, + }), + }, +})) + +const mockRedirect = vi.fn() +const mockRefresh = vi.fn() +const mockNotify = vi.fn() + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslate: () => (key) => key, + useNotify: () => mockNotify, + useRedirect: () => mockRedirect, + useRefresh: () => mockRefresh, + Title: () => null, + Button: ({ children, onClick, label, disabled }) => ( + <button aria-label={label} onClick={onClick} disabled={disabled}> + {children} + </button> + ), + } +}) + +import subsonic from '../subsonic' +import PodcastCreate from './PodcastCreate' + +const fetchPreview = async (url) => { + fireEvent.change(screen.getByRole('textbox'), { target: { value: url } }) + fireEvent.click( + screen.getByLabelText('resources.podcast.actions.fetchFeed'), + ) + await waitFor(() => { + expect(subsonic.previewPodcastFeed).toHaveBeenCalledWith(url) + }) +} + +describe('PodcastCreate', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders a URL input field', () => { + render(<PodcastCreate />) + expect(screen.getByRole('textbox')).toBeTruthy() + }) + + it('fetches a preview of the feed for the entered URL', async () => { + render(<PodcastCreate />) + await fetchPreview('https://example.com/feed.xml') + expect(await screen.findByText('Example Podcast')).toBeTruthy() + }) + + it('calls createPodcastChannel with the entered URL when adding the previewed channel', async () => { + render(<PodcastCreate />) + await fetchPreview('https://example.com/feed.xml') + fireEvent.click( + await screen.findByLabelText('resources.podcast.actions.addChannel'), + ) + await waitFor(() => { + expect(subsonic.createPodcastChannel).toHaveBeenCalledWith( + 'https://example.com/feed.xml', + ) + }) + }) + + it('redirects to /podcast after successfully adding the channel', async () => { + render(<PodcastCreate />) + await fetchPreview('https://example.com/feed.xml') + fireEvent.click( + await screen.findByLabelText('resources.podcast.actions.addChannel'), + ) + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalledWith('/podcast') + }) + }) + + it('notifies on success', async () => { + render(<PodcastCreate />) + await fetchPreview('https://example.com/feed.xml') + fireEvent.click( + await screen.findByLabelText('resources.podcast.actions.addChannel'), + ) + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith( + 'resources.podcast.notifications.channelAdded', + ) + }) + }) +}) diff --git a/ui/src/podcast/PodcastGridView.jsx b/ui/src/podcast/PodcastGridView.jsx new file mode 100644 index 000000000..ff5bd27b7 --- /dev/null +++ b/ui/src/podcast/PodcastGridView.jsx @@ -0,0 +1,101 @@ +import React from 'react' +import { + GridList, + GridListTile, + GridListTileBar, + Typography, + useMediaQuery, +} from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import MicIcon from '@material-ui/icons/Mic' +import { useListContext, linkToRecord } from 'react-admin' +import { Link } from 'react-router-dom' +import withWidth from '@material-ui/core/withWidth' + +const useStyles = makeStyles((theme) => ({ + root: { margin: theme.spacing(1) }, + tileContainer: { cursor: 'pointer' }, + link: { display: 'block', textDecoration: 'none', color: 'inherit' }, + cover: { width: '100%', display: 'block', objectFit: 'cover' }, + placeholder: { + width: '100%', + paddingBottom: '100%', + position: 'relative', + backgroundColor: theme.palette.grey[300], + }, + placeholderIcon: { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }, + tileBar: { + background: 'linear-gradient(to top, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 100%)', + }, + title: { + fontSize: '0.85rem', + fontWeight: 500, + marginTop: theme.spacing(0.5), + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, +})) + +const buildSrcSet = (images) => { + if (!images || images.length === 0) return undefined + return images.map((img) => `${img.url} ${img.width}w`).join(', ') +} + +const getColsForWidth = (width) => { + if (width === 'xs') return 2 + if (width === 'sm') return 3 + if (width === 'md') return 4 + if (width === 'lg') return 5 + return 6 +} + +const PodcastGridView = ({ width, ...props }) => { + const classes = useStyles() + const { ids, data, basePath } = useListContext(props) + + if (!ids || !data) return null + + return ( + <div className={classes.root}> + <GridList cellHeight="auto" cols={getColsForWidth(width)} spacing={16}> + {ids.map((id) => { + const record = data[id] + if (!record) return null + return ( + <GridListTile key={id}> + <div className={classes.tileContainer}> + <Link className={classes.link} to={linkToRecord(basePath, id, 'show')}> + {record.imageUrl ? ( + <img + src={record.imageUrl} + srcSet={buildSrcSet(record.images)} + sizes="(max-width: 600px) 50vw, (max-width: 960px) 33vw, (max-width: 1280px) 25vw, 20vw" + alt={record.title} + className={classes.cover} + /> + ) : ( + <div className={classes.placeholder}> + <MicIcon className={classes.placeholderIcon} style={{ fontSize: 48, color: '#888' }} /> + </div> + )} + <GridListTileBar className={classes.tileBar} title="" /> + </Link> + <Link className={classes.link} to={linkToRecord(basePath, id, 'show')}> + <Typography className={classes.title}>{record.title}</Typography> + </Link> + </div> + </GridListTile> + ) + })} + </GridList> + </div> + ) +} + +export default withWidth()(PodcastGridView) diff --git a/ui/src/podcast/PodcastList.jsx b/ui/src/podcast/PodcastList.jsx new file mode 100644 index 000000000..d67984827 --- /dev/null +++ b/ui/src/podcast/PodcastList.jsx @@ -0,0 +1,199 @@ +import React from 'react' +import { Avatar, ButtonGroup, IconButton, makeStyles, Tooltip, Typography, useMediaQuery } from '@material-ui/core' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import MicIcon from '@material-ui/icons/Mic' +import RefreshIcon from '@material-ui/icons/Refresh' +import ViewModuleIcon from '@material-ui/icons/ViewModule' +import ViewHeadlineIcon from '@material-ui/icons/ViewHeadline' +import { + Button, + CreateButton, + Datagrid, + DateField, + Filter, + sanitizeListRestProps, + SearchInput, + SimpleList, + TextField, + TopToolbar, + useNotify, + useRefresh, + useTranslate, +} from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' +import { List } from '../common' +import subsonic from '../subsonic' +import StatusBadge from './StatusBadge' +import PodcastGridView from './PodcastGridView' +import { podcastViewGrid, podcastViewTable } from '../actions' + +const useStyles = makeStyles({ + row: { '&:hover': { '& $contextMenu': { visibility: 'visible' } } }, + contextMenu: { visibility: 'hidden' }, + toggleTitle: { margin: '1rem' }, + buttonGroup: { width: '100%', justifyContent: 'center' }, + leftButton: { paddingRight: '0.5rem' }, + rightButton: { paddingLeft: '0.5rem' }, +}) + +const PodcastFilter = (props) => ( + <Filter {...props} variant="outlined"> + <SearchInput id="search" source="title" alwaysOn /> + </Filter> +) + +const bestImageUrl = (record, targetWidth) => { + if (!record.images || record.images.length === 0) return record.imageUrl + const sorted = [...record.images].sort((a, b) => a.width - b.width) + const best = sorted.find((img) => img.width >= targetWidth) || sorted[sorted.length - 1] + return best ? best.url : record.imageUrl +} + +const CoverArtField = ({ record }) => { + if (!record) return null + if (record.imageUrl) { + return ( + <Avatar src={bestImageUrl(record, 55)} variant="rounded" style={{ width: 55, height: 55 }} alt={record.title} /> + ) + } + return ( + <Avatar variant="rounded" style={{ width: 55, height: 55 }}> + <MicIcon /> + </Avatar> + ) +} +CoverArtField.defaultProps = { label: '', sortable: false } + +const FeedUrlField = ({ record }) => { + const notify = useNotify() + const translate = useTranslate() + if (!record?.url) return null + const handleCopy = (e) => { + e.stopPropagation() + navigator.clipboard.writeText(record.url) + notify('resources.podcast.notifications.urlCopied') + } + return ( + <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}> + <span style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> + {record.url} + </span> + <Tooltip title={translate('resources.podcast.actions.copyUrl', { _: 'Copy URL' })}> + <IconButton size="small" onClick={handleCopy}> + <FileCopyIcon fontSize="small" /> + </IconButton> + </Tooltip> + </span> + ) +} +FeedUrlField.defaultProps = { label: 'resources.podcast.fields.url', sortable: false } + +const StatusField = ({ record }) => { + if (!record || record.status !== 'error') return null + return <StatusBadge status="error" errorMessage={record.errorMessage} /> +} +StatusField.defaultProps = { label: '' } + +const RefreshButton = () => { + const notify = useNotify() + const refresh = useRefresh() + + const handleClick = async () => { + await subsonic.refreshPodcasts() + notify('resources.podcast.notifications.refreshStarted') + refresh() + } + + return ( + <Button onClick={handleClick} label="resources.podcast.actions.refresh"> + <RefreshIcon /> + </Button> + ) +} + +const PodcastViewToggler = React.forwardRef(({ showTitle = true }, ref) => { + const dispatch = useDispatch() + const podcastView = useSelector((state) => state.podcastView) + const classes = useStyles() + const translate = useTranslate() + return ( + <div ref={ref}> + {showTitle && ( + <Typography className={classes.toggleTitle}> + {translate('ra.toggleFieldsMenu.layout')} + </Typography> + )} + <ButtonGroup variant="text" color="primary" className={classes.buttonGroup}> + <Button + size="small" + className={classes.leftButton} + label={translate('ra.toggleFieldsMenu.grid')} + color={podcastView.grid ? 'primary' : 'secondary'} + onClick={() => dispatch(podcastViewGrid())} + > + <ViewModuleIcon fontSize="inherit" /> + </Button> + <Button + size="small" + className={classes.rightButton} + label={translate('ra.toggleFieldsMenu.table')} + color={podcastView.grid ? 'secondary' : 'primary'} + onClick={() => dispatch(podcastViewTable())} + > + <ViewHeadlineIcon fontSize="inherit" /> + </Button> + </ButtonGroup> + </div> + ) +}) +PodcastViewToggler.displayName = 'PodcastViewToggler' + +const PodcastListActions = ({ className, filters, resource, showFilter, displayedFilters, filterValues, isAdmin, ...rest }) => { + return ( + <TopToolbar className={className} {...sanitizeListRestProps(rest)}> + {isAdmin && <RefreshButton />} + {isAdmin && <CreateButton basePath="/podcast" />} + {filters && React.cloneElement(filters, { resource, showFilter, displayedFilters, filterValues, context: 'button' })} + <PodcastViewToggler showTitle={false} /> + </TopToolbar> + ) +} + +const PodcastList = ({ permissions, ...props }) => { + const classes = useStyles() + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const isAdmin = permissions === 'admin' + const podcastView = useSelector((state) => state.podcastView) + + return ( + <List + {...props} + exporter={false} + sort={{ field: 'title', order: 'ASC' }} + bulkActionButtons={isAdmin ? undefined : false} + hasCreate={isAdmin} + actions={<PodcastListActions isAdmin={isAdmin} />} + filters={<PodcastFilter />} + > + {isXsmall ? ( + <SimpleList + leftAvatar={(r) => <CoverArtField record={r} />} + primaryText={(r) => r.title} + secondaryText={(r) => r.url} + /> + ) : podcastView.grid ? ( + <PodcastGridView /> + ) : ( + <Datagrid rowClick="show" classes={{ row: classes.row }}> + <CoverArtField source="id" /> + <TextField source="title" /> + <FeedUrlField source="url" /> + <StatusField source="status" sortable={false} /> + <DateField source="updatedAt" showTime /> + </Datagrid> + )} + </List> + ) +} + +export default PodcastList diff --git a/ui/src/podcast/PodcastShow.jsx b/ui/src/podcast/PodcastShow.jsx new file mode 100644 index 000000000..2a462effc --- /dev/null +++ b/ui/src/podcast/PodcastShow.jsx @@ -0,0 +1,231 @@ +import React, { useEffect, useState } from 'react' +import { + Card, + CardContent, + Typography, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + makeStyles, + Link, +} from '@material-ui/core' +import { Button, useTranslate, useShowController, Title } from 'react-admin' +import { useDispatch } from 'react-redux' +import MicIcon from '@material-ui/icons/Mic' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' +import ShuffleIcon from '@material-ui/icons/Shuffle' +import { RiPlayList2Fill, RiPlayListAddFill } from 'react-icons/ri' +import StatusBadge from './StatusBadge' +import EpisodeActions from './EpisodeActions' +import subsonic from '../subsonic' +import { setTrack, playTracks, shuffleTracks, playNext, addTracks } from '../actions' + +const songFromEpisode = (episode, channelTitle) => ({ + id: episode.streamId, + title: episode.title, + album: channelTitle || episode.channelId, + artist: '', + duration: episode.duration, + suffix: episode.suffix, + isPodcast: true, + channelId: episode.channelId, +}) + +const buildTracksData = (episodes, channelTitle) => { + const data = {} + const ids = [] + episodes + .filter((ep) => ep.status === 'completed' && ep.streamId) + .forEach((ep) => { + const song = songFromEpisode(ep, channelTitle) + data[song.id] = song + ids.push(song.id) + }) + return { data, ids } +} + +const EpisodePlayButtons = ({ episodes, channelTitle }) => { + const dispatch = useDispatch() + const translate = useTranslate() + const { data, ids } = buildTracksData(episodes, channelTitle) + if (!ids.length) return null + return ( + <div style={{ marginBottom: 8 }}> + <Button onClick={() => dispatch(playTracks(data, ids))} label={translate('resources.album.actions.playAll')}> + <PlayArrowIcon /> + </Button> + <Button onClick={() => dispatch(shuffleTracks(data, ids))} label={translate('resources.album.actions.shuffle')}> + <ShuffleIcon /> + </Button> + <Button onClick={() => dispatch(playNext(data, ids))} label={translate('resources.album.actions.playNext')}> + <RiPlayList2Fill /> + </Button> + <Button onClick={() => dispatch(addTracks(data, ids))} label={translate('resources.album.actions.addToQueue')}> + <RiPlayListAddFill /> + </Button> + </div> + ) +} + +const useStyles = makeStyles((theme) => ({ + card: { marginTop: theme.spacing(2) }, + header: { display: 'flex', gap: theme.spacing(2), marginBottom: theme.spacing(3) }, + playableRow: { cursor: 'pointer', '&:hover': { backgroundColor: theme.palette.action.hover } }, + avatar: { + width: 192, + height: 192, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.palette.grey[300], + borderRadius: 4, + flexShrink: 0, + }, + meta: { flex: 1 }, + description: { marginTop: theme.spacing(1), color: theme.palette.text.secondary }, + tableWrapper: { marginTop: theme.spacing(2), overflowX: 'auto' }, +})) + +const buildSrcSet = (images) => { + if (!images || images.length === 0) return undefined + return images.map((img) => `${img.url} ${img.width}w`).join(', ') +} + +const formatDuration = (seconds) => { + if (!seconds) return '' + const h = Math.floor(seconds / 3600) + const m = Math.floor((seconds % 3600) / 60) + const s = seconds % 60 + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` + return `${m}:${String(s).padStart(2, '0')}` +} + + +const PodcastShow = (props) => { + const classes = useStyles() + const translate = useTranslate() + const dispatch = useDispatch() + const { record } = useShowController(props) + const [episodes, setEpisodes] = useState([]) + + const loadEpisodes = () => { + if (!record?.id) return + subsonic + .getPodcasts(record.id, true) + .then((res) => { + const channels = res?.json?.['subsonic-response']?.podcasts?.channel || [] + const ch = channels.find((c) => c.id === record.id) + setEpisodes(ch?.episode || []) + }) + .catch(() => {}) + } + + useEffect(loadEpisodes, [record?.id]) + + // Subscribe to SSE progress only while episodes are downloading + const hasDownloading = episodes.some((ep) => ep.status === 'downloading') + useEffect(() => { + if (!hasDownloading) return + const handler = (e) => { + const { episodeId, downloadedBytes, size, duration, status } = e.detail + if (status === 'completed' || status === 'error') { + // Reload to get updated streamId and full episode data + loadEpisodes() + return + } + setEpisodes((prev) => + prev.map((ep) => + ep.id === episodeId + ? { ...ep, downloadedBytes, size, ...(duration ? { duration } : {}) } + : ep, + ), + ) + } + window.addEventListener('podcastEpisodeProgress', handler) + return () => window.removeEventListener('podcastEpisodeProgress', handler) + }, [hasDownloading]) + + if (!record) return null + + return ( + <Card className={classes.card}> + <Title subTitle={record.title} /> + <CardContent> + <div className={classes.header}> + <div className={classes.avatar}> + {record.imageUrl ? ( + <img + src={record.imageUrl} + srcSet={buildSrcSet(record.images)} + sizes="192px" + alt={record.title} + style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 4 }} + /> + ) : ( + <MicIcon style={{ fontSize: 40, color: '#888' }} /> + )} + </div> + <div className={classes.meta}> + <Typography variant="h5">{record.title}</Typography> + <Link href={record.url} target="_blank" rel="noopener noreferrer" variant="body2"> + {record.url} + </Link> + {record.description && ( + <Typography variant="body2" className={classes.description}> + {record.description} + </Typography> + )} + {record.status === 'error' && ( + <StatusBadge status="error" errorMessage={record.errorMessage} /> + )} + </div> + </div> + + <EpisodePlayButtons episodes={episodes} channelTitle={record.title} /> + + <div className={classes.tableWrapper}> + <Table size="small"> + <TableHead> + <TableRow> + <TableCell>{translate('resources.podcast.fields.title')}</TableCell> + <TableCell>{translate('resources.podcast.fields.publishDate')}</TableCell> + <TableCell>{translate('resources.podcast.fields.duration')}</TableCell> + <TableCell>{translate('resources.podcast.fields.status')}</TableCell> + <TableCell /> + </TableRow> + </TableHead> + <TableBody> + {episodes.map((ep) => ( + <TableRow + key={ep.id} + className={ep.status === 'completed' ? classes.playableRow : undefined} + onClick={() => ep.status === 'completed' && dispatch(setTrack(songFromEpisode(ep, record.title)))} + > + <TableCell>{ep.title}</TableCell> + <TableCell> + {ep.publishDate ? new Date(ep.publishDate).toLocaleDateString() : ''} + </TableCell> + <TableCell>{formatDuration(ep.duration)}</TableCell> + <TableCell> + <StatusBadge status={ep.status} errorMessage={ep.errorMessage} downloadedBytes={ep.downloadedBytes} size={ep.size} /> + </TableCell> + <TableCell> + <EpisodeActions + episode={ep} + onRefresh={loadEpisodes} + channelTitle={record.title} + /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </CardContent> + </Card> + ) +} + +export default PodcastShow diff --git a/ui/src/podcast/StatusBadge.jsx b/ui/src/podcast/StatusBadge.jsx new file mode 100644 index 000000000..a78a6f9b5 --- /dev/null +++ b/ui/src/podcast/StatusBadge.jsx @@ -0,0 +1,48 @@ +import React from 'react' +import { Chip, CircularProgress, Tooltip, Typography, Box, makeStyles } from '@material-ui/core' +import { useTranslate } from 'react-admin' + +const useStyles = makeStyles((theme) => ({ + completed: { backgroundColor: theme.palette.success?.main || '#4caf50', color: '#fff' }, + error: { backgroundColor: theme.palette.error.main, color: '#fff', cursor: 'default' }, + new: {}, + skipped: { backgroundColor: theme.palette.warning?.main || '#ff9800', color: '#fff' }, +})) + +const StatusBadge = ({ status, errorMessage, downloadedBytes, size }) => { + const translate = useTranslate() + const classes = useStyles() + + if (!status || status === 'deleted') return null + + const label = translate(`resources.podcast.status.${status}`, { _: status }) + + if (status === 'downloading') { + const pct = size > 0 && downloadedBytes > 0 ? Math.round((downloadedBytes / size) * 100) : null + return ( + <Box display="flex" alignItems="center" style={{ gap: 6 }}> + <CircularProgress size={14} /> + <Typography variant="caption">{label}</Typography> + {pct !== null && <Typography variant="caption">{`${pct}%`}</Typography>} + </Box> + ) + } + + if (status === 'error' && errorMessage) { + return ( + <Tooltip title={errorMessage}> + <Chip className={classes.error} label={label} size="small" /> + </Tooltip> + ) + } + + return ( + <Chip + className={classes[status] || classes.new} + label={label} + size="small" + /> + ) +} + +export default StatusBadge diff --git a/ui/src/podcast/StatusBadge.test.jsx b/ui/src/podcast/StatusBadge.test.jsx new file mode 100644 index 000000000..0e84a062e --- /dev/null +++ b/ui/src/podcast/StatusBadge.test.jsx @@ -0,0 +1,53 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import StatusBadge from './StatusBadge' + +vi.mock('react-admin', () => ({ + useTranslate: () => (key) => key, +})) + +vi.mock('@material-ui/core', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Tooltip: ({ children }) => children, + } +}) + +describe('StatusBadge', () => { + it('renders label for completed status', () => { + render(<StatusBadge status="completed" />) + expect(screen.getByText('resources.podcast.status.completed')).toBeTruthy() + }) + + it('renders label for downloading status', () => { + render(<StatusBadge status="downloading" />) + expect(screen.getByText('resources.podcast.status.downloading')).toBeTruthy() + }) + + it('renders label for error status', () => { + render(<StatusBadge status="error" errorMessage="Connection refused" />) + expect(screen.getByText('resources.podcast.status.error')).toBeTruthy() + }) + + it('renders label for new status', () => { + render(<StatusBadge status="new" />) + expect(screen.getByText('resources.podcast.status.new')).toBeTruthy() + }) + + it('renders label for skipped status', () => { + render(<StatusBadge status="skipped" />) + expect(screen.getByText('resources.podcast.status.skipped')).toBeTruthy() + }) + + it('renders nothing for deleted status', () => { + const { container } = render(<StatusBadge status="deleted" />) + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing when status is undefined', () => { + const { container } = render(<StatusBadge />) + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/ui/src/podcast/index.jsx b/ui/src/podcast/index.jsx new file mode 100644 index 000000000..ef40c38ff --- /dev/null +++ b/ui/src/podcast/index.jsx @@ -0,0 +1,26 @@ +import React from 'react' +import MicIcon from '@material-ui/icons/Mic' +import MicNoneIcon from '@material-ui/icons/MicNone' +import DynamicMenuIcon from '../layout/DynamicMenuIcon' +import PodcastList from './PodcastList' +import PodcastShow from './PodcastShow' +import PodcastCreate from './PodcastCreate' + +const all = { + list: PodcastList, + show: PodcastShow, + icon: ( + <DynamicMenuIcon + path={'podcast'} + icon={MicNoneIcon} + activeIcon={MicIcon} + /> + ), +} + +const admin = { + ...all, + create: PodcastCreate, +} + +export default { all, admin } diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js index 64a0049b7..7102e232e 100644 --- a/ui/src/reducers/index.js +++ b/ui/src/reducers/index.js @@ -3,6 +3,7 @@ export * from './themeReducer' export * from './dialogReducer' export * from './playerReducer' export * from './albumView' +export * from './podcastView' export * from './activityReducer' export * from './settingsReducer' export * from './replayGainReducer' diff --git a/ui/src/reducers/podcastView.js b/ui/src/reducers/podcastView.js new file mode 100644 index 000000000..bcadf2167 --- /dev/null +++ b/ui/src/reducers/podcastView.js @@ -0,0 +1,15 @@ +import { PODCAST_MODE_GRID, PODCAST_MODE_TABLE } from '../actions' + +export const podcastViewReducer = ( + previousState = { grid: true }, + payload, +) => { + const { type } = payload + switch (type) { + case PODCAST_MODE_GRID: + case PODCAST_MODE_TABLE: + return { ...previousState, grid: type === PODCAST_MODE_GRID } + default: + return previousState + } +} diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 9f0ad28d7..ef82903f1 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -131,6 +131,29 @@ const getTopSongs = (artist, count = 50) => { return httpClient(url('getTopSongs', null, { artist, count })) } +const getPodcasts = (id = null, includeEpisodes = true) => + httpClient(url('getPodcasts', id, { includeEpisodes })) + +const getNewestPodcasts = (count = 20) => + httpClient(url('getNewestPodcasts', null, { count })) + +const createPodcastChannel = (feedUrl) => + httpClient(url('createPodcastChannel', null, { url: feedUrl })) + +const previewPodcastFeed = (feedUrl) => + httpClient(baseUrl(`/api/podcast/preview?url=${encodeURIComponent(feedUrl)}`)) + +const refreshPodcasts = () => httpClient(url('refreshPodcasts')) + +const deletePodcastChannel = (id) => + httpClient(url('deletePodcastChannel', id)) + +const deletePodcastEpisode = (id) => + httpClient(url('deletePodcastEpisode', id)) + +const downloadPodcastEpisode = (id) => + httpClient(url('downloadPodcastEpisode', id)) + const streamUrl = (id, options) => { return baseUrl( url('stream', id, { @@ -160,4 +183,12 @@ export default { getArtistInfo, getTopSongs, getSimilarSongs2, + getPodcasts, + getNewestPodcasts, + createPodcastChannel, + previewPodcastFeed, + refreshPodcasts, + deletePodcastChannel, + deletePodcastEpisode, + downloadPodcastEpisode, } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index 32c699a41..f68e933f6 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -265,3 +265,61 @@ describe('reportPlayback', () => { expect(url).toContain('state=playing') }) }) + +describe('podcast API URL builders', () => { + beforeEach(() => { + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('getPodcasts includes includeEpisodes param', () => { + const result = subsonic.url('getPodcasts', null, { includeEpisodes: true }) + expect(result).toContain('getPodcasts') + expect(result).toContain('includeEpisodes=true') + }) + + it('getPodcasts includes id when provided', () => { + const result = subsonic.url('getPodcasts', 'ch-1', { includeEpisodes: true }) + expect(result).toContain('id=ch-1') + }) + + it('getNewestPodcasts includes count', () => { + const result = subsonic.url('getNewestPodcasts', null, { count: 5 }) + expect(result).toContain('getNewestPodcasts') + expect(result).toContain('count=5') + }) + + it('createPodcastChannel includes url param', () => { + const feedUrl = 'https://example.com/feed.xml' + const result = subsonic.url('createPodcastChannel', null, { url: feedUrl }) + expect(result).toContain('createPodcastChannel') + expect(result).toContain(encodeURIComponent(feedUrl)) + }) + + it('deletePodcastChannel includes id', () => { + const result = subsonic.url('deletePodcastChannel', 'ch-1') + expect(result).toContain('deletePodcastChannel') + expect(result).toContain('id=ch-1') + }) + + it('deletePodcastEpisode includes id', () => { + const result = subsonic.url('deletePodcastEpisode', 'ep-1') + expect(result).toContain('deletePodcastEpisode') + expect(result).toContain('id=ep-1') + }) + + it('downloadPodcastEpisode includes id', () => { + const result = subsonic.url('downloadPodcastEpisode', 'ep-1') + expect(result).toContain('downloadPodcastEpisode') + expect(result).toContain('id=ep-1') + }) +})