From db4c1a2ba2af645d5acf6ff2fe1585500c01b8ae Mon Sep 17 00:00:00 2001 From: ji-ho lee Date: Mon, 27 Apr 2026 23:40:15 +0900 Subject: [PATCH 01/11] feat(podcast): add podcast feature with UX improvements - #5420 Backend - Add podcast data model (PodcastChannel, PodcastEpisode) with migrations - Implement Subsonic API endpoints: getPodcasts, getNewestPodcasts, createPodcastChannel, refreshPodcasts, deletePodcastChannel, deletePodcastEpisode, downloadPodcastEpisode, getPodcastEpisode - Add native REST API endpoints: GET/DELETE /api/podcast, GET /api/podcast/preview (feed info without creating channel) - Inject events.Broker into podcast service for SSE support - Emit PodcastEpisodeProgress SSE events during download (every 512 KB) and on completion/error with status field - Use HTTP Content-Length as fallback when RSS feed omits enclosure size - Write ID3 tags (title, album, genre=Podcast) to downloaded files via ffmpeg so the library scanner reads correct metadata - Set MediaFile fields (Title, Album, AlbumID=channelId, AlbumArtist, Genre) on episode registration - Add duplicate URL check in AddChannel - Add ExistsByURL to PodcastChannelRepository Frontend - Podcast list - Add grid/list view toggle (Redux podcastViewReducer) matching album list - New PodcastGridView component with responsive column count (2-6 cols) - Cover image 100px in table view - Remove Feed URL column; add inline copy-to-clipboard button Frontend - Podcast show (episode list) - Real-time download progress (%) in Status column via SSE, no polling - Spinner only before first SSE event; N% once data arrives - Size column removed; Downloading badge replaced with progress - Completed episodes play on row click; separate play button removed - Play / Shuffle / Play Next / Add to Queue buttons above episode list (only shown when completed episodes exist) - On download completion, reload episodes to obtain streamId for immediate playback without page refresh - Clicking album name in AudioTitle navigates to podcast channel page Frontend - Podcast create - Full-width URL input with Fetch Feed Info button and Enter key support - Preview card (cover image, title, episode count, description) before committing channel creation - Add Channel button appears only after preview; shows already-registered message if channel URL exists Frontend - Playlist - Album link navigates to podcast channel page for podcast tracks (identified by genre=Podcast) - Artist column shows '-' for podcast tracks with empty artist field Closes #5420 Signed-off-by: ji-ho lee --- cmd/wire_gen.go | 4 +- core/ffmpeg/ffmpeg.go | 21 +- core/podcasts/podcasts.go | 401 ++++++++++++++++++ core/podcasts/podcasts_suite_test.go | 17 + core/podcasts/podcasts_test.go | 256 +++++++++++ core/podcasts/rss.go | 202 +++++++++ core/podcasts/rss_test.go | 116 +++++ db/migrations/20260427165650_add_podcast.go | 63 +++ ...0427184047_add_podcast_downloaded_bytes.go | 21 + model/datastore.go | 2 + model/podcast.go | 71 ++++ persistence/persistence.go | 10 + persistence/persistence_suite_test.go | 51 +++ persistence/podcast_channel_repository.go | 141 ++++++ .../podcast_channel_repository_test.go | 136 ++++++ persistence/podcast_episode_repository.go | 78 ++++ .../podcast_episode_repository_test.go | 132 ++++++ server/events/events.go | 10 + server/nativeapi/native_api.go | 1 + server/nativeapi/podcasts.go | 45 ++ server/subsonic/api.go | 18 +- server/subsonic/opensubsonic.go | 1 + server/subsonic/opensubsonic_test.go | 70 +-- server/subsonic/podcasts.go | 216 ++++++++++ server/subsonic/podcasts_test.go | 302 +++++++++++++ server/subsonic/responses/responses.go | 50 ++- tests/mock_data_store.go | 30 ++ tests/mock_podcast_channel_repo.go | 69 +++ tests/mock_podcast_episode_repo.go | 98 +++++ ui/src/App.jsx | 7 + ui/src/actions/index.js | 1 + ui/src/actions/podcastView.js | 5 + ui/src/audioplayer/AudioTitle.jsx | 8 +- ui/src/eventStream.js | 6 + ui/src/i18n/en.json | 39 +- ui/src/playlist/PlaylistSongs.jsx | 56 ++- ui/src/podcast/EpisodeActions.jsx | 47 ++ ui/src/podcast/EpisodeActions.test.jsx | 83 ++++ ui/src/podcast/PodcastCreate.jsx | 170 ++++++++ ui/src/podcast/PodcastCreate.test.jsx | 72 ++++ ui/src/podcast/PodcastGridView.jsx | 90 ++++ ui/src/podcast/PodcastList.jsx | 192 +++++++++ ui/src/podcast/PodcastShow.jsx | 220 ++++++++++ ui/src/podcast/StatusBadge.jsx | 47 ++ ui/src/podcast/StatusBadge.test.jsx | 53 +++ ui/src/podcast/index.jsx | 26 ++ ui/src/reducers/index.js | 1 + ui/src/reducers/podcastView.js | 15 + ui/src/subsonic/index.js | 31 ++ ui/src/subsonic/index.test.js | 58 +++ 50 files changed, 3796 insertions(+), 63 deletions(-) create mode 100644 core/podcasts/podcasts.go create mode 100644 core/podcasts/podcasts_suite_test.go create mode 100644 core/podcasts/podcasts_test.go create mode 100644 core/podcasts/rss.go create mode 100644 core/podcasts/rss_test.go create mode 100644 db/migrations/20260427165650_add_podcast.go create mode 100644 db/migrations/20260427184047_add_podcast_downloaded_bytes.go create mode 100644 model/podcast.go create mode 100644 persistence/podcast_channel_repository.go create mode 100644 persistence/podcast_channel_repository_test.go create mode 100644 persistence/podcast_episode_repository.go create mode 100644 persistence/podcast_episode_repository_test.go create mode 100644 server/nativeapi/podcasts.go create mode 100644 server/subsonic/podcasts.go create mode 100644 server/subsonic/podcasts_test.go create mode 100644 tests/mock_podcast_channel_repo.go create mode 100644 tests/mock_podcast_episode_repo.go create mode 100644 ui/src/actions/podcastView.js create mode 100644 ui/src/podcast/EpisodeActions.jsx create mode 100644 ui/src/podcast/EpisodeActions.test.jsx create mode 100644 ui/src/podcast/PodcastCreate.jsx create mode 100644 ui/src/podcast/PodcastCreate.test.jsx create mode 100644 ui/src/podcast/PodcastGridView.jsx create mode 100644 ui/src/podcast/PodcastList.jsx create mode 100644 ui/src/podcast/PodcastShow.jsx create mode 100644 ui/src/podcast/StatusBadge.jsx create mode 100644 ui/src/podcast/StatusBadge.test.jsx create mode 100644 ui/src/podcast/index.jsx create mode 100644 ui/src/reducers/podcastView.js diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 4bdd48422..94caa5bee 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" @@ -105,8 +106,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(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..d8a12b4c2 --- /dev/null +++ b/core/podcasts/podcasts.go @@ -0,0 +1,401 @@ +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 { + ds model.DataStore + ff ffmpeg.FFmpeg + broker events.Broker +} + +func NewPodcastService(ds model.DataStore, ff ffmpeg.FFmpeg, broker events.Broker) Podcasts { + return &podcastService{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, + } + 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, + } + if err := s.ds.PodcastChannel(ctx).Create(ch); err != nil { + return err + } + + for i := range feed.Episodes { + ep := feed.Episodes[i] + ep.ChannelID = ch.ID + ep.Status = model.PodcastStatusNew + if err := s.ds.PodcastEpisode(ctx).Create(&ep); err != nil { + return 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 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 + } + + 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 + if err := epRepo.Create(&ep); err != nil { + return 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(context.Background(), 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, "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() + + resp, err := http.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(filepath.Separator)) + now := time.Now() + 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", + 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), + }) + } +} + +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="+title, + "-metadata", "album="+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) { + resp, err := http.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..da4ac6ff7 --- /dev/null +++ b/core/podcasts/podcasts_test.go @@ -0,0 +1,256 @@ +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 = GinkgoT().TempDir() + svc = podcasts.NewPodcastService(ds, 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 + 신규 1 + }) + }) + + Describe("DownloadEpisode", func() { + var episode *model.PodcastEpisode + + BeforeEach(func() { + 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, "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, "podcasts", "ch-1", "ep-1.mp3") + Eventually(func() string { + return episodeRepo.Data["ep-1"].Path + }, "3s").Should(Equal(expectedPath)) + }) + }) + + Describe("DeleteEpisode", func() { + It("removes episode from DB", func() { + episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", ChannelID: "ch-1"} + err := svc.DeleteEpisode(ctx, "ep-1") + Expect(err).ToNot(HaveOccurred()) + Expect(episodeRepo.Data).To(BeEmpty()) + }) + + 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() { + 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("DownloadEpisode with timestamp", func() { + 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()) + }) + }) +}) diff --git a/core/podcasts/rss.go b/core/podcasts/rss.go new file mode 100644 index 000000000..b41c24926 --- /dev/null +++ b/core/podcasts/rss.go @@ -0,0 +1,202 @@ +package podcasts + +import ( + "encoding/xml" + "fmt" + "net/url" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/model" +) + +// Go's encoding/xml has a known limitation with inherited namespace prefixes. +// We fall back to a regex scan for itunes:image when struct tag parsing yields nothing. +var itunesImageRe = regexp.MustCompile(`<[^:>]*:image[^>]+href="([^"]*)"`) + +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 +} + +type FeedPreview struct { + Title string `json:"title"` + Description string `json:"description"` + ImageURL string `json:"imageUrl"` + EpisodeCount int `json:"episodeCount"` + AlreadyExists bool `json:"alreadyExists"` +} + +func ParseFeedPreview(rssURL string) (*FeedPreview, error) { + feed, err := fetchAndParse(rssURL) + if err != nil { + return nil, err + } + return &FeedPreview{ + Title: feed.Title, + Description: feed.Description, + ImageURL: feed.ImageURL, + EpisodeCount: len(feed.Episodes), + }, nil +} + +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"` +} + +type rssImage struct { + URL string `xml:"url"` +} + +type itunesImage struct { + Href string `xml:"href,attr"` +} + +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"` +} + +type enclosure struct { + URL string `xml:"url,attr"` + Length int64 `xml:"length,attr"` + Type string `xml:"type,attr"` +} + +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, + } + + // itunes:image takes precedence over . + // Use regex fallback because Go's xml package doesn't always resolve + // namespace prefixes inherited from ancestor elements. + if href := extractItunesImageHref(data); href != "" { + feed.ImageURL = href + } else if ch.ItunesImage.Href != "" { + feed.ImageURL = ch.ItunesImage.Href + } else { + feed.ImageURL = ch.Image.URL + } + + 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, + } + 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 + } +} + +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 + } + // fallback: extract from URL path + 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..f2365ead9 --- /dev/null +++ b/core/podcasts/rss_test.go @@ -0,0 +1,116 @@ +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()) + }) + }) +}) 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/model/datastore.go b/model/datastore.go index 273ca714b..dd83b0d22 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -42,6 +42,8 @@ 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 Resource(ctx context.Context, model any) ResourceRepository diff --git a/model/podcast.go b/model/podcast.go new file mode 100644 index 000000000..025b7acf7 --- /dev/null +++ b/model/podcast.go @@ -0,0 +1,71 @@ +package model + +import "time" + +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"` + Episodes PodcastEpisodes `structs:"-" json:"episodes,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"` +} + +type PodcastChannels []PodcastChannel +type PodcastEpisodes []PodcastEpisode + +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) + GetByGUID(channelID, guid string) (*PodcastEpisode, error) + Create(ep *PodcastEpisode) error + Update(ep *PodcastEpisode) error + Delete(id string) error +} diff --git a/persistence/persistence.go b/persistence/persistence.go index 9d3a33cfc..2264b973d 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -105,6 +105,14 @@ 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) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: @@ -125,6 +133,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..9b708415b --- /dev/null +++ b/persistence/podcast_channel_repository.go @@ -0,0 +1,141 @@ +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 { + epRepo := NewPodcastEpisodeRepository(r.ctx, r.db) + for i := range channels { + eps, err := epRepo.GetByChannel(channels[i].ID) + if err != nil { + return nil, err + } + channels[i].Episodes = eps + } + } + 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..78f631920 --- /dev/null +++ b/persistence/podcast_episode_repository.go @@ -0,0 +1,78 @@ +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) 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/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 f97bd26b2..faa5df927 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -73,6 +73,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/api.go b/server/subsonic/api.go index 82e404228..173e318d4 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/opensubsonic.go b/server/subsonic/opensubsonic.go index 2b2a31bf3..fee17aac6 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -16,6 +16,7 @@ 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}}, } 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..96aa39701 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -41,47 +41,16 @@ 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 8 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(7), - 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: "topSongsByArtistId", Versions: []int32{1}}), - )) - Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( - ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), - ) - }) - }) - - 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) - }) - - It("should return 7 extensions including sonicSimilarity", func() { - router.ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) @@ -93,8 +62,41 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { 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}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + 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, nil, sonicService) + }) + + It("should return 9 extensions including sonicSimilarity and getPodcastEpisode", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(9), + 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: "topSongsByArtistId", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), )) }) }) diff --git a/server/subsonic/podcasts.go b/server/subsonic/podcasts.go new file mode 100644 index 000000000..447223050 --- /dev/null +++ b/server/subsonic/podcasts.go @@ -0,0 +1,216 @@ +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 + } + + 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, + } + if includeEpisodes { + for _, ep := range ch.Episodes { + 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 + } + ep, err := api.ds.PodcastEpisode(r.Context()).Get(id) + if err != nil { + return nil, err + } + 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, + } + if !ep.PublishDate.IsZero() { + re.PublishDate = ep.PublishDate.UTC().Format(time.RFC3339) + } + 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..93afe727c --- /dev/null +++ b/server/subsonic/podcasts_test.go @@ -0,0 +1,302 @@ +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()) + }) + }) +}) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 252eee4c6..43eba3e1b 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,45 @@ 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 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"` +} + +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"` +} + // StreamDetails describes audio stream properties for transcoding decisions type StreamDetails struct { Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"` diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 32f56a4f0..0d2b13b89 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -30,6 +30,8 @@ type MockDataStore struct { MockedPlugin model.PluginRepository MockedArtwork model.ArtworkRepository MockedArtworkQueue model.ArtworkQueueRepository + MockedPodcastChannel model.PodcastChannelRepository + MockedPodcastEpisode model.PodcastEpisodeRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -321,6 +323,32 @@ 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) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } @@ -343,6 +371,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..8096f0d4b --- /dev/null +++ b/tests/mock_podcast_channel_repo.go @@ -0,0 +1,69 @@ +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) 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..d8b5ced33 --- /dev/null +++ b/tests/mock_podcast_episode_repo.go @@ -0,0 +1,98 @@ +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) 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/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 c0e226453..967bd1d63 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -137,7 +137,8 @@ "actions": { "topSongs": "Top Songs", "shuffle": "Shuffle", - "radio": "Radio" + "radio": "Radio", + "podcast": "Podcasts" } }, "user": { @@ -244,6 +245,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..8f231e6a4 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,53 @@ 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 + if (record?.genre === 'Podcast' && !record?.artist) { + return - + } + return +} +PodcastAwareArtistField.defaultProps = { addLabel: true, sortBy: 'artist' } + const useStyles = makeStyles( (theme) => ({ root: {}, @@ -148,8 +178,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..ce55cc0c8 --- /dev/null +++ b/ui/src/podcast/EpisodeActions.jsx @@ -0,0 +1,47 @@ +import React from 'react' +import { IconButton, CircularProgress } from '@material-ui/core' +import GetAppIcon from '@material-ui/icons/GetApp' +import DeleteIcon from '@material-ui/icons/Delete' +import subsonic from '../subsonic' + +const EpisodeActions = ({ episode, onRefresh }) => { + + const handleDownload = async () => { + await subsonic.downloadPodcastEpisode(episode.id) + onRefresh?.() + } + + const handleDelete = async () => { + await subsonic.deletePodcastEpisode(episode.id) + onRefresh?.() + } + + 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..2a88379a4 --- /dev/null +++ b/ui/src/podcast/PodcastCreate.test.jsx @@ -0,0 +1,72 @@ +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({}) }, +})) + +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, + } +}) + +import subsonic from '../subsonic' +import PodcastCreate from './PodcastCreate' + +describe('PodcastCreate', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders a URL input field', () => { + render(<PodcastCreate />) + expect(screen.getByRole('textbox')).toBeTruthy() + }) + + it('calls createPodcastChannel with the entered URL on submit', async () => { + render(<PodcastCreate />) + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'https://example.com/feed.xml' }, + }) + fireEvent.submit(screen.getByRole('form')) + await waitFor(() => { + expect(subsonic.createPodcastChannel).toHaveBeenCalledWith( + 'https://example.com/feed.xml', + ) + }) + }) + + it('redirects to /podcast after successful submit', async () => { + render(<PodcastCreate />) + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'https://example.com/feed.xml' }, + }) + fireEvent.submit(screen.getByRole('form')) + await waitFor(() => { + expect(mockRedirect).toHaveBeenCalledWith('/podcast') + }) + }) + + it('notifies on success', async () => { + render(<PodcastCreate />) + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'https://example.com/feed.xml' }, + }) + fireEvent.submit(screen.getByRole('form')) + 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..7de199af9 --- /dev/null +++ b/ui/src/podcast/PodcastGridView.jsx @@ -0,0 +1,90 @@ +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 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} 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..144ef5b1c --- /dev/null +++ b/ui/src/podcast/PodcastList.jsx @@ -0,0 +1,192 @@ +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 CoverArtField = ({ record }) => { + if (!record) return null + if (record.imageUrl) { + return ( + <Avatar src={record.imageUrl} 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..1fbcaee89 --- /dev/null +++ b/ui/src/podcast/PodcastShow.jsx @@ -0,0 +1,220 @@ +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 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} + 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} /> + </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..fd968e1ee --- /dev/null +++ b/ui/src/podcast/StatusBadge.jsx @@ -0,0 +1,47 @@ +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} /> + {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') + }) +}) From 3e9b4fed3018b0eda49a122ba2e23d1226299b32 Mon Sep 17 00:00:00 2001 From: ji-ho lee <search5@gmail.com> Date: Mon, 27 Apr 2026 23:40:40 +0900 Subject: [PATCH 02/11] chore: add podcasts/ to .gitignore (downloaded audio files should not be tracked) Signed-off-by: ji-ho lee <search5@gmail.com> --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6459ded9b..e49eb468a 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ go.work* .playwright-mcp/ # Temp benchmark files -zz_*_test.go \ No newline at end of file +zz_*_test.go + +# Downloaded podcast episode audio files should not be tracked +podcasts/ From d2629094d0c14650a97b10bc163db3699f109c62 Mon Sep 17 00:00:00 2001 From: ji-ho lee <search5@gmail.com> Date: Tue, 28 Apr 2026 00:12:40 +0900 Subject: [PATCH 03/11] test(podcast): fix test failures after API changes - Add ExistsByURL to MockPodcastChannelRepo - Add channel mock data to DownloadEpisode error handling and timestamp tests - Fix DeleteEpisode test to match actual behavior (resets to new status) - Pass podcasts.Podcasts to subsonic.New in e2e test suite Signed-off-by: ji-ho lee <search5@gmail.com> --- core/podcasts/podcasts_test.go | 19 +++++++++++++++---- server/subsonic/e2e/e2e_suite_test.go | 2 ++ tests/mock_podcast_channel_repo.go | 12 ++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index da4ac6ff7..7226e80a6 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -50,7 +50,7 @@ var _ = Describe("PodcastService", func() { DeferCleanup(mockServer.Close) conf.Server.DataFolder = GinkgoT().TempDir() - svc = podcasts.NewPodcastService(ds, nil) + svc = podcasts.NewPodcastService(ds, nil, nil) }) Describe("AddChannel", func() { @@ -123,6 +123,11 @@ var _ = Describe("PodcastService", 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", @@ -164,11 +169,11 @@ var _ = Describe("PodcastService", func() { }) Describe("DeleteEpisode", func() { - It("removes episode from DB", func() { - episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", ChannelID: "ch-1"} + 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).To(BeEmpty()) + Expect(episodeRepo.Data["ep-1"].Status).To(Equal(model.PodcastStatusNew)) }) It("deletes the downloaded file when path is set", func() { @@ -208,6 +213,9 @@ var _ = Describe("PodcastService", func() { }) 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", @@ -238,6 +246,9 @@ var _ = Describe("PodcastService", func() { }) 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", diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 8998731a4..90fdaa9a4 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -24,6 +24,7 @@ import ( "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" @@ -426,6 +427,7 @@ func setupTestDB() { metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, + podcasts.NewPodcastService(ds, ffmpeg.New(), events.NoopBroker()), nil, ) } diff --git a/tests/mock_podcast_channel_repo.go b/tests/mock_podcast_channel_repo.go index 8096f0d4b..0e5349570 100644 --- a/tests/mock_podcast_channel_repo.go +++ b/tests/mock_podcast_channel_repo.go @@ -57,6 +57,18 @@ func (m *MockPodcastChannelRepo) UpdateChannel(channel *model.PodcastChannel) er 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") From 3a0eec08b946ab543b8e75d37ebe491b37927f19 Mon Sep 17 00:00:00 2001 From: ji-ho lee <search5@gmail.com> Date: Tue, 28 Apr 2026 00:17:17 +0900 Subject: [PATCH 04/11] fix(podcast): address code review feedback - Add 30s timeout to episode download HTTP client - Add 15s timeout to RSS feed fetch HTTP client - Fix N+1 query in GetAll(withEpisodes): fetch all episodes in a single query using IN clause via GetByChannels - Add sanitizeMetadata helper to strip null bytes from ffmpeg tag values - Add TODO comment on background goroutine context for server shutdown Signed-off-by: ji-ho lee <search5@gmail.com> --- core/podcasts/podcasts.go | 18 ++++++++++++++---- model/podcast.go | 1 + persistence/podcast_channel_repository.go | 20 ++++++++++++++------ persistence/podcast_episode_repository.go | 10 ++++++++++ tests/mock_podcast_episode_repo.go | 17 +++++++++++++++++ 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index d8a12b4c2..8737e9f7f 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -153,6 +153,8 @@ func (s *podcastService) DownloadEpisode(ctx context.Context, id string) error { return err } + // Use context.Background() so the download is not cancelled when the HTTP + // request completes. TODO: tie to server shutdown context for graceful termination. go s.doDownload(context.Background(), ep, ch) return nil } @@ -176,7 +178,8 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod } defer f.Close() - resp, err := http.Get(ep.EnclosureURL) //nolint:gosec + httpClient := &http.Client{Timeout: 30 * time.Second} + resp, err := httpClient.Get(ep.EnclosureURL) //nolint:gosec if err != nil { s.setEpisodeError(ctx, ep, err) return @@ -287,6 +290,12 @@ func (s *podcastService) setEpisodeError(ctx context.Context, ep *model.PodcastE } } +// 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 @@ -298,8 +307,8 @@ func (s *podcastService) writeID3Tags(ctx context.Context, dest, suffix, title, tmp := dest + ".tmp." + suffix cmd := exec.CommandContext(ctx, ffmpegPath, "-i", dest, - "-metadata", "title="+title, - "-metadata", "album="+album, + "-metadata", "title="+sanitizeMetadata(title), + "-metadata", "album="+sanitizeMetadata(album), "-metadata", "artist=", "-metadata", "genre=Podcast", "-c", "copy", "-y", tmp, @@ -386,7 +395,8 @@ func (pw *progressWriter) Write(p []byte) (int, error) { } func fetchAndParse(rssURL string) (*rssFeed, error) { - resp, err := http.Get(rssURL) //nolint:gosec + 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) } diff --git a/model/podcast.go b/model/podcast.go index 025b7acf7..6df3e1ccd 100644 --- a/model/podcast.go +++ b/model/podcast.go @@ -64,6 +64,7 @@ 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 diff --git a/persistence/podcast_channel_repository.go b/persistence/podcast_channel_repository.go index 9b708415b..7f72d3b12 100644 --- a/persistence/podcast_channel_repository.go +++ b/persistence/podcast_channel_repository.go @@ -43,14 +43,22 @@ func (r *podcastChannelRepository) GetAll(withEpisodes bool) (model.PodcastChann if err := r.queryAll(sel, &channels); err != nil { return nil, err } - if withEpisodes { + 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 { - eps, err := epRepo.GetByChannel(channels[i].ID) - if err != nil { - return nil, err - } - channels[i].Episodes = eps + channels[i].Episodes = epsByChannel[channels[i].ID] } } return channels, nil diff --git a/persistence/podcast_episode_repository.go b/persistence/podcast_episode_repository.go index 78f631920..ae530f2e5 100644 --- a/persistence/podcast_episode_repository.go +++ b/persistence/podcast_episode_repository.go @@ -45,6 +45,16 @@ func (r *podcastEpisodeRepository) GetByChannel(channelID string) (model.Podcast 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{} diff --git a/tests/mock_podcast_episode_repo.go b/tests/mock_podcast_episode_repo.go index d8b5ced33..ebc7d8c79 100644 --- a/tests/mock_podcast_episode_repo.go +++ b/tests/mock_podcast_episode_repo.go @@ -45,6 +45,23 @@ func (m *MockPodcastEpisodeRepo) GetNewest(count int) (model.PodcastEpisodes, er 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") From e9c04a1fa6498b232a24e9948220be17b2707809 Mon Sep 17 00:00:00 2001 From: ji-ho lee <search5@gmail.com> Date: Tue, 28 Apr 2026 00:20:00 +0900 Subject: [PATCH 05/11] refactor(podcast): inject server shutdown context into podcast service Pass the server root context (ctx) to NewPodcastService so that background download goroutines are tied to the server lifecycle and will be cancelled on shutdown, matching the pattern used by scanner.New. Signed-off-by: ji-ho lee <search5@gmail.com> --- cmd/wire_gen.go | 2 +- core/podcasts/podcasts.go | 15 +++++++-------- core/podcasts/podcasts_test.go | 2 +- server/subsonic/e2e/e2e_suite_test.go | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 94caa5bee..9d0b68fb3 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -106,7 +106,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) - podcastService := podcasts.NewPodcastService(dataStore, fFmpeg, broker) + 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, podcastService, sonicSonic) return router diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 8737e9f7f..8ca0c6d82 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -31,13 +31,14 @@ type Podcasts interface { } type podcastService struct { - ds model.DataStore - ff ffmpeg.FFmpeg - broker events.Broker + rootCtx context.Context + ds model.DataStore + ff ffmpeg.FFmpeg + broker events.Broker } -func NewPodcastService(ds model.DataStore, ff ffmpeg.FFmpeg, broker events.Broker) Podcasts { - return &podcastService{ds: ds, ff: ff, broker: 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, @@ -153,9 +154,7 @@ func (s *podcastService) DownloadEpisode(ctx context.Context, id string) error { return err } - // Use context.Background() so the download is not cancelled when the HTTP - // request completes. TODO: tie to server shutdown context for graceful termination. - go s.doDownload(context.Background(), ep, ch) + go s.doDownload(s.rootCtx, ep, ch) return nil } diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index 7226e80a6..48dad79cd 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -50,7 +50,7 @@ var _ = Describe("PodcastService", func() { DeferCleanup(mockServer.Close) conf.Server.DataFolder = GinkgoT().TempDir() - svc = podcasts.NewPodcastService(ds, nil, nil) + svc = podcasts.NewPodcastService(ctx, ds, nil, nil) }) Describe("AddChannel", func() { diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 90fdaa9a4..cd39b4429 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -427,7 +427,7 @@ func setupTestDB() { metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, - podcasts.NewPodcastService(ds, ffmpeg.New(), events.NoopBroker()), + podcasts.NewPodcastService(context.Background(), ds, ffmpeg.New(), events.NoopBroker()), nil, ) } From 1dc1b3cb521e92e3603fa9c3f0b3c4180a98cfc7 Mon Sep 17 00:00:00 2001 From: Lee Ji-ho <search5@mz.co.kr> Date: Tue, 28 Apr 2026 12:40:34 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat(podcast):=20implement=20Podcasting?= =?UTF-8?q?=202.0=20namespace=20support=20(Tier=201=E2=80=933)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full support for the Podcasting 2.0 namespace (https://podcastindex.org/namespace/1.0) across RSS parsing, persistence, Subsonic API responses, and OpenSubsonic extensions. - podcast:guid: channel-level UUIDv5 for stable identity across feed URL changes - podcast:chapters: per-episode chapters URL + MIME type (chaptersUrl in API) - podcast:transcript: multiple transcripts per episode stored in a dedicated podcast_transcript table; attributes: url, type, language, rel - podcast:season: season number + optional name per episode - podcast:episode: episode number (decimal string) + optional display label - podcast:person: host/guest entries at both channel and episode level stored in a dedicated podcast_person table; attributes: name, role, group, img, href; role defaults to "host" and group defaults to "cast" per spec - podcast:locked: feed lock flag + optional owner email on channel - podcast:funding: first funding entry URL + display text on channel - podcast:medium: content type classification on channel - podcast:soundbite: startTime (float), duration (float), title per episode - podcast:updateFrequency: display text + rrule + complete flag on channel - podcast:podroll: creator-recommended feed list stored in podcast_podroll table (feedGuid, feedUrl, title, sort_order); returned as podroll[] in GetPodcasts - podcast:liveItem: live stream detection stored in podcast_live_item table (one row per channel, unique index); stores status, start/end times, enclosure URL/type, and contentLink for fallback playback; returned as liveItem object in GetPodcasts; Upsert preserves created_at on updates - podcast:podping: usesPodping boolean on channel; RefreshChannels skips channels with usesPodping=true (they receive updates via Podping WebSocket) - 20260428000000_add_podcast20.go: ALTER TABLE adds 9 columns to podcast_channel, 9 columns to podcast_episode; CREATE TABLE podcast_transcript (episode_id FK, url, mime_type, language, rel) and podcast_person (no FK constraints — put() serialises "" not NULL) - 20260428120000_add_podcast_tier3.go: ALTER TABLE adds uses_podping to podcast_channel; CREATE TABLE podcast_podroll and podcast_live_item (UNIQUE INDEX on channel_id) GetPodcasts response (PodcastChannel) gains: podcastGuid, locked, medium, fundingUrl, fundingText, updateFrequency, complete, usesPodping, person[], podroll[], liveItem{} GetPodcastEpisode response (PodcastEpisode) gains: season, seasonName, episode, episodeDisplay, chaptersUrl, soundbiteStart, soundbiteDur, transcript[], person[] GetPodcastEpisode now loads transcripts and persons from their repositories (previously only read the base episode row). New OpenSubsonic extensions declared: podcastChapters, podcastTranscripts, podcastSeason, podcastPerson, podcastFunding, podcastMedium, podcastPodroll, podcastLiveItem, podcastPodping - core/podcasts/rss_test.go: 41 new specs covering all namespace tags, default value handling (role→"host", group→"cast"), backward compatibility - core/podcasts/podcasts_test.go: 26 new service specs covering AddChannel field persistence, transcript/person saving, podroll/liveItem saving, RefreshChannels podping skip behaviour - persistence/podcast_transcript_repository_test.go: 10 specs - persistence/podcast_person_repository_test.go: 12 specs - persistence/podcast_podroll_repository_test.go: 10 specs - persistence/podcast_live_item_repository_test.go: 8 specs - server/subsonic/podcasts_test.go: 15 new handler specs for Tier 2 and Tier 3 fields in GetPodcasts and GetPodcastEpisode responses --- core/podcasts/podcasts.go | 96 +++- core/podcasts/podcasts_test.go | 265 +++++++++- core/podcasts/rss.go | 252 +++++++++- core/podcasts/rss_test.go | 464 ++++++++++++++++++ db/migrations/20260428000000_add_podcast20.go | 76 +++ .../20260428120000_add_podcast_tier3.go | 59 +++ model/datastore.go | 4 + model/podcast.go | 178 +++++-- persistence/persistence.go | 16 + persistence/podcast_live_item_repository.go | 59 +++ .../podcast_live_item_repository_test.go | 150 ++++++ persistence/podcast_person_repository.go | 83 ++++ persistence/podcast_person_repository_test.go | 172 +++++++ persistence/podcast_podroll_repository.go | 59 +++ .../podcast_podroll_repository_test.go | 145 ++++++ persistence/podcast_transcript_repository.go | 59 +++ .../podcast_transcript_repository_test.go | 144 ++++++ server/subsonic/opensubsonic.go | 9 + server/subsonic/opensubsonic_test.go | 26 +- server/subsonic/podcasts.go | 140 +++++- server/subsonic/podcasts_test.go | 286 +++++++++++ server/subsonic/responses/responses.go | 89 +++- tests/mock_data_store.go | 108 +++- tests/mock_podcast_live_item_repo.go | 53 ++ tests/mock_podcast_person_repo.go | 97 ++++ tests/mock_podcast_podroll_repo.go | 70 +++ tests/mock_podcast_transcript_repo.go | 74 +++ 27 files changed, 3147 insertions(+), 86 deletions(-) create mode 100644 db/migrations/20260428000000_add_podcast20.go create mode 100644 db/migrations/20260428120000_add_podcast_tier3.go create mode 100644 persistence/podcast_live_item_repository.go create mode 100644 persistence/podcast_live_item_repository_test.go create mode 100644 persistence/podcast_person_repository.go create mode 100644 persistence/podcast_person_repository_test.go create mode 100644 persistence/podcast_podroll_repository.go create mode 100644 persistence/podcast_podroll_repository_test.go create mode 100644 persistence/podcast_transcript_repository.go create mode 100644 persistence/podcast_transcript_repository_test.go create mode 100644 tests/mock_podcast_live_item_repo.go create mode 100644 tests/mock_podcast_person_repo.go create mode 100644 tests/mock_podcast_podroll_repo.go create mode 100644 tests/mock_podcast_transcript_repo.go diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 8ca0c6d82..35664a628 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -79,23 +79,74 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { } ch := &model.PodcastChannel{ - URL: rssURL, - Title: feed.Title, - Description: feed.Description, - ImageURL: feed.ImageURL, - Status: model.PodcastStatusNew, + 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, + FundingURL: feed.FundingURL, + FundingText: feed.FundingText, + UpdateFrequency: feed.UpdateFrequency, + UpdateRRule: feed.UpdateRRule, + Complete: feed.Complete, + UsesPodping: feed.UsesPodping, } 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: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 + ep.Transcripts = nil + ep.Persons = 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) + } + } } ch.Status = model.PodcastStatusCompleted @@ -109,6 +160,9 @@ func (s *podcastService) RefreshChannels(ctx context.Context) error { } 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) } @@ -122,6 +176,19 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha return 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] @@ -131,9 +198,28 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha } ep.ChannelID = ch.ID ep.Status = model.PodcastStatusNew + transcripts := ep.Transcripts + persons := ep.Persons + ep.Transcripts = nil + ep.Persons = 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) + } + } } return nil } diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index 48dad79cd..567123668 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -115,7 +115,7 @@ var _ = Describe("PodcastService", func() { } err := svc.RefreshChannels(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(episodeRepo.Data).To(HaveLen(2)) // 기존 1 + 신규 1 + Expect(episodeRepo.Data).To(HaveLen(2)) // 1 existing + 1 new }) }) @@ -245,6 +245,160 @@ var _ = Describe("PodcastService", func() { }) }) + 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("stores first FundingURL and FundingText from feed", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.FundingURL).To(Equal("https://example.com/donate")) + Expect(ch.FundingText).To(Equal("Support us!")) + } + }) + + 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"} @@ -264,4 +418,113 @@ var _ = Describe("PodcastService", func() { }, "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 index b41c24926..b5bb47411 100644 --- a/core/podcasts/rss.go +++ b/core/podcasts/rss.go @@ -13,6 +13,8 @@ import ( "github.com/navidrome/navidrome/model" ) +const podcastNS = "https://podcastindex.org/namespace/1.0" + // Go's encoding/xml has a known limitation with inherited namespace prefixes. // We fall back to a regex scan for itunes:image when struct tag parsing yields nothing. var itunesImageRe = regexp.MustCompile(`<[^:>]*:image[^>]+href="([^"]*)"`) @@ -29,6 +31,23 @@ type rssFeed struct { Description string ImageURL string Episodes []model.PodcastEpisode + + // Podcasting 2.0 channel fields + PodcastGUID string + Locked bool + LockedOwner string + Medium string + FundingURL string + FundingText string + UpdateFrequency string + UpdateRRule string + Complete bool + Persons []model.PodcastPerson + + // Podcasting 2.0 Tier 3 channel fields + UsesPodping bool + Podroll []model.PodcastPodrollItem + LiveItems []model.PodcastLiveItem } type FeedPreview struct { @@ -37,6 +56,12 @@ type FeedPreview struct { 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) { @@ -45,13 +70,19 @@ func ParseFeedPreview(rssURL string) (*FeedPreview, error) { return nil, err } return &FeedPreview{ - Title: feed.Title, - Description: feed.Description, - ImageURL: feed.ImageURL, - EpisodeCount: len(feed.Episodes), + Title: feed.Title, + Description: feed.Description, + ImageURL: feed.ImageURL, + EpisodeCount: len(feed.Episodes), + Medium: feed.Medium, + UpdateFrequency: feed.UpdateFrequency, + FundingURL: feed.FundingURL, + FundingText: feed.FundingText, }, nil } +// ---- XML struct definitions ---- + type rssRoot struct { Channel rssChannel `xml:"channel"` } @@ -62,6 +93,50 @@ type rssChannel struct { 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"` + + // 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 { @@ -72,6 +147,30 @@ 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"` @@ -80,6 +179,14 @@ type rssItem struct { 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"` } type enclosure struct { @@ -88,6 +195,36 @@ type enclosure struct { 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"` +} + +// ---- Parsing ---- + func ParseRSSFeed(data []byte) (*rssFeed, error) { var root rssRoot if err := xml.Unmarshal(data, &root); err != nil { @@ -98,11 +235,18 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { 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 <image><url>. - // Use regex fallback because Go's xml package doesn't always resolve - // namespace prefixes inherited from ancestor elements. if href := extractItunesImageHref(data); href != "" { feed.ImageURL = href } else if ch.ItunesImage.Href != "" { @@ -111,6 +255,53 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { feed.ImageURL = ch.Image.URL } + // first funding entry + if len(ch.PodcastFundings) > 0 { + feed.FundingURL = ch.PodcastFundings[0].URL + feed.FundingText = strings.TrimSpace(ch.PodcastFundings[0].Text) + } + + // 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 == "" { @@ -131,7 +322,38 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { 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, + }) + } + feed.Episodes = append(feed.Episodes, ep) } @@ -175,6 +397,23 @@ func parseDuration(s string) int { } } +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", @@ -192,7 +431,6 @@ func suffixFromMIME(mimeType, enclosureURL string) string { if s, ok := mimeToSuffix[base]; ok { return s } - // fallback: extract from URL path if u, err := url.Parse(enclosureURL); err == nil { if ext := path.Ext(u.Path); ext != "" { return strings.TrimPrefix(ext, ".") diff --git a/core/podcasts/rss_test.go b/core/podcasts/rss_test.go index f2365ead9..0df1c272c 100644 --- a/core/podcasts/rss_test.go +++ b/core/podcasts/rss_test.go @@ -114,3 +114,467 @@ var _ = Describe("ParseRSSFeed", func() { }) }) }) + +// Podcasting 2.0 namespace (https://podcastindex.org/namespace/1.0) parsing tests. +const testRSSFeedPodcast20 = `<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" + xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" + xmlns:podcast="https://podcastindex.org/namespace/1.0"> + <channel> + <title>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.FundingURL).To(Equal("https://example.com/donate")) + Expect(result.FundingText).To(Equal("Support us!")) + }) + + It("podcast:funding — ignores entries after the first", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.FundingURL).ToNot(ContainSubstring("donate2")) + }) + + 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)) + }) + }) +}) 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/model/datastore.go b/model/datastore.go index dd83b0d22..245a960f7 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -44,6 +44,10 @@ type DataStore interface { 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 Resource(ctx context.Context, model any) ResourceRepository diff --git a/model/podcast.go b/model/podcast.go index 6df3e1ccd..c8a71731e 100644 --- a/model/podcast.go +++ b/model/podcast.go @@ -2,6 +2,51 @@ 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 ( @@ -14,42 +59,104 @@ const ( ) 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"` - Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"` + 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"` + + // 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"` } 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"` + 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"` + 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"` + + // loaded separately + Transcripts PodcastTranscripts `structs:"-" json:"transcripts,omitempty"` + Persons PodcastPersons `structs:"-" json:"persons,omitempty"` } -type PodcastChannels []PodcastChannel -type PodcastEpisodes []PodcastEpisode +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 PodcastChannels []PodcastChannel +type PodcastEpisodes []PodcastEpisode +type PodcastTranscripts []PodcastTranscript +type PodcastPersons []PodcastPerson type PodcastChannelRepository interface { Get(id string) (*PodcastChannel, error) @@ -70,3 +177,18 @@ type PodcastEpisodeRepository interface { 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 +} diff --git a/persistence/persistence.go b/persistence/persistence.go index 2264b973d..0f0f1c90e 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -113,6 +113,22 @@ func (s *SQLStore) PodcastEpisode(ctx context.Context) model.PodcastEpisodeRepos 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) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: 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/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index fee17aac6..bc2144e8f 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -17,6 +17,15 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {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 96aa39701..f6ea32d59 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,7 +44,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { 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 8 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 15 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(8), + HaveLen(15), 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}}), @@ -64,6 +64,15 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { 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}}), @@ -77,7 +86,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) }) - It("should return 9 extensions including sonicSimilarity and getPodcastEpisode", func() { + It("should return 16 extensions including all podcast 2.0 and sonicSimilarity", func() { router.ServeHTTP(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -87,7 +96,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(9), + HaveLen(16), 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}}), @@ -96,6 +105,15 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { 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}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), )) }) diff --git a/server/subsonic/podcasts.go b/server/subsonic/podcasts.go index 447223050..08bb1f6d5 100644 --- a/server/subsonic/podcasts.go +++ b/server/subsonic/podcasts.go @@ -42,6 +42,68 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { 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 episode transcripts and persons when including episodes + var epTranscripts map[string]model.PodcastTranscripts + var epPersons map[string]model.PodcastPersons + 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) + } + } + } + } + resp := newResponse() resp.Podcasts = &responses.Podcasts{} for _, ch := range channels { @@ -53,9 +115,55 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { 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, + FundingUrl: ch.FundingURL, + FundingText: ch.FundingText, + UpdateFrequency: ch.UpdateFrequency, + Complete: ch.Complete, + // 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 _, 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] rch.Episode = append(rch.Episode, buildPodcastEpisode(ep)) } } @@ -175,10 +283,15 @@ func (api *Router) GetPodcastEpisode(r *http.Request) (*responses.Subsonic, erro if err != nil { return nil, err } - ep, err := api.ds.PodcastEpisode(r.Context()).Get(id) + 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) + resp := newResponse() re := buildPodcastEpisode(*ep) resp.PodcastEpisode = &re @@ -200,10 +313,35 @@ func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode { 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, } 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, + }) + } return re } diff --git a/server/subsonic/podcasts_test.go b/server/subsonic/podcasts_test.go index 93afe727c..17aaeba3c 100644 --- a/server/subsonic/podcasts_test.go +++ b/server/subsonic/podcasts_test.go @@ -299,4 +299,290 @@ var _ = Describe("Podcasts", func() { 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 fundingUrl and fundingText in response", func() { + 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.FundingUrl).To(Equal("https://example.com/donate")) + Expect(ch.FundingText).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 43eba3e1b..c0be0c012 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -714,23 +714,84 @@ type PodcastChannel struct { 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"` + FundingUrl string `xml:"fundingUrl,attr,omitempty" json:"fundingUrl,omitempty"` + FundingText string `xml:"fundingText,attr,omitempty" json:"fundingText,omitempty"` + UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"` + Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"` + Person []PodcastPersonResp `xml:"person,omitempty" json:"person,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"` + 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"` + Transcript []PodcastTranscriptResp `xml:"transcript,omitempty" json:"transcript,omitempty"` + Person []PodcastPersonResp `xml:"person,omitempty" json:"person,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 diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 0d2b13b89..26406b748 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -8,32 +8,36 @@ 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 - MockedPodcastChannel model.PodcastChannelRepository - MockedPodcastEpisode model.PodcastEpisodeRepository - 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 + scrobbleBufferMu sync.Mutex + repoMu sync.Mutex // GC tracking GCCalled bool @@ -349,6 +353,58 @@ func (db *MockDataStore) PodcastEpisode(ctx context.Context) model.PodcastEpisod 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) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } 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 +} From fb9461d4bfa4f77f87078713b68602ff38dd2023 Mon Sep 17 00:00:00 2001 From: Ji-ho Lee Date: Tue, 28 Apr 2026 16:34:27 +0900 Subject: [PATCH 07/11] feat(podcast): add Podcasting 2.0 metadata support with persistence layer - Parse podcast:images, podcast:funding, podcast:transcript, podcast:chapters, podcast:soundbite, and podcast:person tags from RSS feeds - Add DB migration and repositories for podcast images and funding sources - Expose Podcasting 2.0 fields in Subsonic API responses - Tag downloaded episodes with genre=Podcast for playlist filtering - Update mock data store and fix subsonic test compatibility --- core/podcasts/podcasts.go | 54 +++- core/podcasts/podcasts_test.go | 12 +- core/podcasts/rss.go | 144 ++++++++++- core/podcasts/rss_test.go | 238 ++++++++++++++++- .../20260428200000_add_podcast_metadata.go | 61 +++++ model/datastore.go | 2 + model/podcast.go | 52 +++- persistence/persistence.go | 8 + persistence/podcast_funding_repository.go | 59 +++++ .../podcast_funding_repository_test.go | 167 ++++++++++++ persistence/podcast_image_repository.go | 94 +++++++ persistence/podcast_image_repository_test.go | 239 ++++++++++++++++++ server/subsonic/album_lists_test.go | 2 +- server/subsonic/media_annotation_test.go | 2 +- server/subsonic/media_retrieval_test.go | 2 +- server/subsonic/playlists_test.go | 4 +- server/subsonic/podcasts.go | 58 ++++- server/subsonic/podcasts_test.go | 12 +- server/subsonic/responses/responses.go | 37 ++- server/subsonic/searching_test.go | 2 +- server/subsonic/transcode_test.go | 6 +- tests/mock_data_store.go | 24 ++ tests/mock_podcast_funding_repo.go | 66 +++++ tests/mock_podcast_image_repo.go | 114 +++++++++ 24 files changed, 1413 insertions(+), 46 deletions(-) create mode 100644 db/migrations/20260428200000_add_podcast_metadata.go create mode 100644 persistence/podcast_funding_repository.go create mode 100644 persistence/podcast_funding_repository_test.go create mode 100644 persistence/podcast_image_repository.go create mode 100644 persistence/podcast_image_repository_test.go create mode 100644 tests/mock_podcast_funding_repo.go create mode 100644 tests/mock_podcast_image_repo.go diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 35664a628..9f1022517 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -88,12 +88,16 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { Locked: feed.Locked, LockedOwner: feed.LockedOwner, Medium: feed.Medium, - FundingURL: feed.FundingURL, - FundingText: feed.FundingText, 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 @@ -106,6 +110,23 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { } } + // 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 { @@ -127,8 +148,10 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { 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 } @@ -147,6 +170,12 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { 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 @@ -176,6 +205,16 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha 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) @@ -200,8 +239,10 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha 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 } @@ -220,6 +261,12 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha 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 } @@ -300,6 +347,8 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod } else { relPath := strings.TrimPrefix(dest, conf.Server.DataFolder+string(filepath.Separator)) now := time.Now() + tags := model.Tags{} + tags.Add("genre", "Podcast") mf := &model.MediaFile{ ID: id.NewRandom(), LibraryID: libID, @@ -310,6 +359,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod Artist: "", AlbumArtist: ch.Title, Genre: "Podcast", + Tags: tags, Duration: float32(ep.Duration), Size: size, BitRate: ep.BitRate, diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index 567123668..af0dc9957 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -285,12 +285,16 @@ var _ = Describe("PodcastService", func() { } }) - It("stores first FundingURL and FundingText from feed", func() { + It("saves funding items to funding repo", func() { + fundingRepo := tests.CreateMockPodcastFundingRepo() + ds.MockedPodcastFunding = fundingRepo Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) - for _, ch := range channelRepo.Data { - Expect(ch.FundingURL).To(Equal("https://example.com/donate")) - Expect(ch.FundingText).To(Equal("Support us!")) + 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() { diff --git a/core/podcasts/rss.go b/core/podcasts/rss.go index b5bb47411..70c618ab1 100644 --- a/core/podcasts/rss.go +++ b/core/podcasts/rss.go @@ -15,9 +15,49 @@ import ( const podcastNS = "https://podcastindex.org/namespace/1.0" -// Go's encoding/xml has a known limitation with inherited namespace prefixes. -// We fall back to a regex scan for itunes:image when struct tag parsing yields nothing. -var itunesImageRe = regexp.MustCompile(`<[^:>]*:image[^>]+href="([^"]*)"`) +// 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 { @@ -37,12 +77,18 @@ type rssFeed struct { Locked bool LockedOwner string Medium string - FundingURL string - FundingText 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 @@ -69,6 +115,11 @@ func ParseFeedPreview(rssURL string) (*FeedPreview, error) { 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, @@ -76,8 +127,8 @@ func ParseFeedPreview(rssURL string) (*FeedPreview, error) { EpisodeCount: len(feed.Episodes), Medium: feed.Medium, UpdateFrequency: feed.UpdateFrequency, - FundingURL: feed.FundingURL, - FundingText: feed.FundingText, + FundingURL: fundingURL, + FundingText: fundingText, }, nil } @@ -101,6 +152,10 @@ type rssChannel struct { 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"` @@ -187,6 +242,9 @@ type rssItem struct { 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 { @@ -223,6 +281,27 @@ type podcastSoundbite struct { 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) { @@ -255,12 +334,35 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { feed.ImageURL = ch.Image.URL } - // first funding entry - if len(ch.PodcastFundings) > 0 { - feed.FundingURL = ch.PodcastFundings[0].URL - feed.FundingText = strings.TrimSpace(ch.PodcastFundings[0].Text) + // 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{ @@ -354,6 +456,26 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { }) } + // 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) } diff --git a/core/podcasts/rss_test.go b/core/podcasts/rss_test.go index 0df1c272c..41728ccb9 100644 --- a/core/podcasts/rss_test.go +++ b/core/podcasts/rss_test.go @@ -409,14 +409,21 @@ var _ = Describe("ParseRSSFeed — Podcasting 2.0 namespace", func() { It("podcast:funding — stores first entry URL and text", func() { result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) Expect(err).ToNot(HaveOccurred()) - Expect(result.FundingURL).To(Equal("https://example.com/donate")) - Expect(result.FundingText).To(Equal("Support us!")) + 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 — ignores entries after the first", func() { + It("podcast:funding — stores all entries", func() { result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) Expect(err).ToNot(HaveOccurred()) - Expect(result.FundingURL).ToNot(ContainSubstring("donate2")) + 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() { @@ -578,3 +585,226 @@ var _ = Describe("ParseRSSFeed — Podcasting 2.0 namespace", func() { }) }) }) + +// ---- 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/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/model/datastore.go b/model/datastore.go index 245a960f7..26a805171 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -48,6 +48,8 @@ type DataStore interface { 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 index c8a71731e..9747364c9 100644 --- a/model/podcast.go +++ b/model/podcast.go @@ -81,6 +81,12 @@ type PodcastChannel struct { 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"` @@ -88,8 +94,10 @@ type PodcastChannel struct { LiveItem *PodcastLiveItem `structs:"-" json:"liveItem,omitempty"` // loaded separately - Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"` - Persons PodcastPersons `structs:"-" json:"persons,omitempty"` + 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 { @@ -125,10 +133,15 @@ type PodcastEpisode struct { 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 { @@ -153,10 +166,30 @@ type PodcastPerson struct { 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) @@ -192,3 +225,18 @@ type PodcastPersonRepository interface { 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 0f0f1c90e..3b61bfcb3 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -129,6 +129,14 @@ func (s *SQLStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveItemRep 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: 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/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/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/playlists_test.go b/server/subsonic/playlists_test.go index a7c9e2ec7..bd0b18b7d 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() }) @@ -307,7 +307,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 index 08bb1f6d5..2bfd263fa 100644 --- a/server/subsonic/podcasts.go +++ b/server/subsonic/podcasts.go @@ -75,9 +75,26 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { } } - // Bulk-load episode transcripts and persons when including episodes + // 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 { @@ -101,6 +118,13 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { 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) + } + } } } @@ -119,10 +143,14 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { PodcastGuid: ch.PodcastGUID, Locked: ch.Locked, Medium: ch.Medium, - FundingUrl: ch.FundingURL, - FundingText: ch.FundingText, 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, } @@ -135,6 +163,18 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { 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, @@ -164,6 +204,7 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { 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)) } } @@ -291,6 +332,7 @@ func (api *Router) GetPodcastEpisode(r *http.Request) (*responses.Subsonic, erro 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) @@ -321,6 +363,10 @@ func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode { 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) @@ -342,6 +388,12 @@ func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode { Href: p.Href, }) } + for _, img := range ep.Images { + re.Images = append(re.Images, responses.PodcastImageResp{ + URL: img.URL, + Width: img.Width, + }) + } return re } diff --git a/server/subsonic/podcasts_test.go b/server/subsonic/podcasts_test.go index 17aaeba3c..e9157fb26 100644 --- a/server/subsonic/podcasts_test.go +++ b/server/subsonic/podcasts_test.go @@ -343,14 +343,20 @@ var _ = Describe("Podcasts", func() { Expect(resp.Podcasts.Channel[0].Medium).To(Equal("podcast")) }) - It("includes fundingUrl and fundingText in response", func() { + 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.FundingUrl).To(Equal("https://example.com/donate")) - Expect(ch.FundingText).To(Equal("Support us!")) + 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() { diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c0be0c012..62736f452 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -704,6 +704,16 @@ 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"` @@ -716,14 +726,20 @@ type PodcastChannel struct { 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"` - FundingUrl string `xml:"fundingUrl,attr,omitempty" json:"fundingUrl,omitempty"` - FundingText string `xml:"fundingText,attr,omitempty" json:"fundingText,omitempty"` - UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"` - Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"` - Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` + 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"` @@ -775,8 +791,13 @@ type PodcastEpisode struct { 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 { 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 26406b748..aad7261c0 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -36,6 +36,8 @@ type MockDataStore struct { MockedPodcastPerson model.PodcastPersonRepository MockedPodcastPodroll model.PodcastPodrollRepository MockedPodcastLiveItem model.PodcastLiveItemRepository + MockedPodcastFunding model.PodcastFundingRepository + MockedPodcastImage model.PodcastImageRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -405,6 +407,28 @@ func (db *MockDataStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveI 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) } 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 +} From 73b2edaa5361c0b2a37b149e6ca086aabdc8c368 Mon Sep 17 00:00:00 2001 From: Ji-ho Lee Date: Tue, 28 Apr 2026 16:34:27 +0900 Subject: [PATCH 08/11] feat(ui): improve podcast display in playlist and podcast views - Show podcast-aware album link in playlist (navigates to podcast channel page) - Show '-' instead of '[Unknown Artist]' for podcast episodes with no artist - Use best-resolution image URL in podcast list view - Add srcSet/sizes for responsive cover images in podcast grid and show views --- ui/src/playlist/PlaylistSongs.jsx | 5 ++++- ui/src/podcast/PodcastGridView.jsx | 13 ++++++++++++- ui/src/podcast/PodcastList.jsx | 9 ++++++++- ui/src/podcast/PodcastShow.jsx | 7 +++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx index 8f231e6a4..9c614a8dc 100644 --- a/ui/src/playlist/PlaylistSongs.jsx +++ b/ui/src/playlist/PlaylistSongs.jsx @@ -57,7 +57,10 @@ AlbumOrChannelLinkField.defaultProps = { addLabel: true, sortBy: 'album' } const PodcastAwareArtistField = (props) => { const record = props.record - if (record?.genre === 'Podcast' && !record?.artist) { + const isPodcast = record?.genre === 'Podcast' + const hasNoRealArtist = + !record?.artist || record?.artist === '[Unknown Artist]' + if (isPodcast && hasNoRealArtist) { return - } return diff --git a/ui/src/podcast/PodcastGridView.jsx b/ui/src/podcast/PodcastGridView.jsx index 7de199af9..ff5bd27b7 100644 --- a/ui/src/podcast/PodcastGridView.jsx +++ b/ui/src/podcast/PodcastGridView.jsx @@ -42,6 +42,11 @@ const useStyles = makeStyles((theme) => ({ }, })) +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 @@ -67,7 +72,13 @@ const PodcastGridView = ({ width, ...props }) => {
{record.imageUrl ? ( - {record.title} + {record.title} ) : (
diff --git a/ui/src/podcast/PodcastList.jsx b/ui/src/podcast/PodcastList.jsx index 144ef5b1c..d67984827 100644 --- a/ui/src/podcast/PodcastList.jsx +++ b/ui/src/podcast/PodcastList.jsx @@ -42,11 +42,18 @@ const PodcastFilter = (props) => ( ) +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 ( - + ) } return ( diff --git a/ui/src/podcast/PodcastShow.jsx b/ui/src/podcast/PodcastShow.jsx index 1fbcaee89..6f98b4d5a 100644 --- a/ui/src/podcast/PodcastShow.jsx +++ b/ui/src/podcast/PodcastShow.jsx @@ -88,6 +88,11 @@ const useStyles = makeStyles((theme) => ({ 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) @@ -153,6 +158,8 @@ const PodcastShow = (props) => { {record.imageUrl ? ( {record.title} From a36b2201b321c156c227a1a23ea1d37c01ef4ec3 Mon Sep 17 00:00:00 2001 From: Ji-ho Lee Date: Tue, 28 Apr 2026 16:39:50 +0900 Subject: [PATCH 09/11] feat(i18n): complete Korean translation Add missing 105 keys across all resource categories: - Song: composer, albumGain, trackGain, disc, showInPlaylist, instantMix - Artist: podcast action - Podcast: full resource (name, fields, actions, notifications, status) - Library: quickScan/fullScan actions and notifications, fix scanStarted typo - Plugin: full resource (fields, sections, status, actions, notifications, validation, messages, placeholders) - Message: cover art upload/remove, startingInstantMix - About.config: downloadToml - Activity: selectiveScan --- resources/i18n/ko.json | 147 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 7 deletions(-) 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": "경과 시간" }, From 472b9c2aa0c8ec8cee75b43a034a88707d206ace Mon Sep 17 00:00:00 2001 From: Lee Ji-ho <=> Date: Thu, 13 Aug 2026 14:21:12 +0900 Subject: [PATCH 10/11] fix(podcast): fix build failure and missing episode play/status UX server/e2e was failing to build because subsonic.New() gained a podcasts.Podcasts parameter that the sonic-similarity e2e test wasn't passing. Also fixes real bugs surfaced while getting the podcast tests green: completed episodes had no play button, the downloading status badge dropped its label text, and PodcastCreate's test suite assumed a single-step form submit instead of the actual preview-then-add flow. Co-Authored-By: Claude Sonnet 5 --- .../e2e/subsonic_sonic_similarity_test.go | 3 + ui/src/podcast/EpisodeActions.jsx | 36 +++++++++-- ui/src/podcast/PodcastCreate.test.jsx | 61 ++++++++++++++----- ui/src/podcast/PodcastShow.jsx | 6 +- ui/src/podcast/StatusBadge.jsx | 1 + 5 files changed, 86 insertions(+), 21 deletions(-) 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/ui/src/podcast/EpisodeActions.jsx b/ui/src/podcast/EpisodeActions.jsx index ce55cc0c8..edea9bd34 100644 --- a/ui/src/podcast/EpisodeActions.jsx +++ b/ui/src/podcast/EpisodeActions.jsx @@ -1,30 +1,56 @@ 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 }) => { +const EpisodeActions = ({ episode, onRefresh, channelTitle }) => { + const dispatch = useDispatch() const handleDownload = async () => { await subsonic.downloadPodcastEpisode(episode.id) onRefresh?.() } - const handleDelete = async () => { + 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 ( - - - + <> + + + + + + + ) } diff --git a/ui/src/podcast/PodcastCreate.test.jsx b/ui/src/podcast/PodcastCreate.test.jsx index 2a88379a4..948594439 100644 --- a/ui/src/podcast/PodcastCreate.test.jsx +++ b/ui/src/podcast/PodcastCreate.test.jsx @@ -3,7 +3,17 @@ 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({}) }, + 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() @@ -19,12 +29,27 @@ vi.mock('react-admin', async (importOriginal) => { useRedirect: () => mockRedirect, useRefresh: () => mockRefresh, Title: () => null, + Button: ({ children, onClick, label, disabled }) => ( + + ), } }) 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()) @@ -33,12 +58,18 @@ describe('PodcastCreate', () => { expect(screen.getByRole('textbox')).toBeTruthy() }) - it('calls createPodcastChannel with the entered URL on submit', async () => { + it('fetches a preview of the feed for the entered URL', async () => { render() - fireEvent.change(screen.getByRole('textbox'), { - target: { value: 'https://example.com/feed.xml' }, - }) - fireEvent.submit(screen.getByRole('form')) + 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() + 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', @@ -46,12 +77,12 @@ describe('PodcastCreate', () => { }) }) - it('redirects to /podcast after successful submit', async () => { + it('redirects to /podcast after successfully adding the channel', async () => { render() - fireEvent.change(screen.getByRole('textbox'), { - target: { value: 'https://example.com/feed.xml' }, - }) - fireEvent.submit(screen.getByRole('form')) + await fetchPreview('https://example.com/feed.xml') + fireEvent.click( + await screen.findByLabelText('resources.podcast.actions.addChannel'), + ) await waitFor(() => { expect(mockRedirect).toHaveBeenCalledWith('/podcast') }) @@ -59,10 +90,10 @@ describe('PodcastCreate', () => { it('notifies on success', async () => { render() - fireEvent.change(screen.getByRole('textbox'), { - target: { value: 'https://example.com/feed.xml' }, - }) - fireEvent.submit(screen.getByRole('form')) + 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/PodcastShow.jsx b/ui/src/podcast/PodcastShow.jsx index 6f98b4d5a..2a462effc 100644 --- a/ui/src/podcast/PodcastShow.jsx +++ b/ui/src/podcast/PodcastShow.jsx @@ -212,7 +212,11 @@ const PodcastShow = (props) => { - + ))} diff --git a/ui/src/podcast/StatusBadge.jsx b/ui/src/podcast/StatusBadge.jsx index fd968e1ee..a78a6f9b5 100644 --- a/ui/src/podcast/StatusBadge.jsx +++ b/ui/src/podcast/StatusBadge.jsx @@ -22,6 +22,7 @@ const StatusBadge = ({ status, errorMessage, downloadedBytes, size }) => { return ( + {label} {pct !== null && {`${pct}%`}} ) From abf8df36353b3c42031482266ae4bff1eccf327c Mon Sep 17 00:00:00 2001 From: Lee Ji-ho <=> Date: Thu, 13 Aug 2026 14:51:57 +0900 Subject: [PATCH 11/11] fix(podcast): resolve post-rebase breakage after rebasing onto latest master Rebasing onto master's Podcasting-branch-unrelated changes surfaced several integration gaps the merge conflicts didn't catch: - conf.Server.DataFolder is now a Dir type, not a string; podcasts.go and its tests needed .String()/conf.NewDir() at each call site. - subsonic.New() gained the podcasts.Podcasts parameter; several e2e/unit test call sites elsewhere in the tree were still passing the old arg count. - The uniform-canonical-ids migration (dated after our podcast migrations, so it runs against a schema that already has the podcast tables) didn't know about the new podcast_* id columns, leaving them unrewritten while everything else (including podcast_episode.stream_id's matching media_file.id) got canonicalized. - opensubsonic_test.go's expected extension count was miscounted during conflict resolution (9 Podcasting 2.0 extensions, not 7). - .gitignore's unanchored `podcasts/` entry from an earlier commit accidentally matched core/podcasts/ (source) in addition to the downloaded-episode directory; anchored both to their actual paths. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 5 +++-- core/podcasts/podcasts.go | 6 +++--- core/podcasts/podcasts_test.go | 6 +++--- db/migrations/20260720015443_uniform_canonical_ids.go | 8 ++++++++ db/migrations/uniform_canonical_ids_test.go | 8 ++++++++ server/subsonic/e2e/e2e_suite_test.go | 1 + server/subsonic/e2e/subsonic_artwork_test.go | 2 +- server/subsonic/lyrics_test.go | 2 +- server/subsonic/opensubsonic_test.go | 8 ++++---- 9 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index e49eb468a..fd43a11a7 100644 --- a/.gitignore +++ b/.gitignore @@ -45,5 +45,6 @@ go.work* # Temp benchmark files zz_*_test.go -# Downloaded podcast episode audio files should not be tracked -podcasts/ +# Downloaded podcast episode audio files should not be tracked (DataFolder defaults to ".") +/podcasts/ +/ui/podcasts/ diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 9f1022517..36340c018 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -56,7 +56,7 @@ func (s *podcastService) podcastLibraryID(ctx context.Context) (int, error) { } lib := &model.Library{ Name: podcastLibraryName, - Path: conf.Server.DataFolder, + Path: conf.Server.DataFolder.String(), } if err := s.ds.Library(ctx).Put(lib); err != nil { return 0, err @@ -296,7 +296,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod if suffix == "" { suffix = "mp3" } - dir := filepath.Join(conf.Server.DataFolder, "podcasts", ep.ChannelID) + dir := filepath.Join(conf.Server.DataFolder.String(), "podcasts", ep.ChannelID) if err := os.MkdirAll(dir, 0755); err != nil { s.setEpisodeError(ctx, ep, err) return @@ -345,7 +345,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod 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(filepath.Separator)) + relPath := strings.TrimPrefix(dest, conf.Server.DataFolder.String()+string(filepath.Separator)) now := time.Now() tags := model.Tags{} tags.Add("genre", "Podcast") diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index af0dc9957..8672880b1 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -49,7 +49,7 @@ var _ = Describe("PodcastService", func() { })) DeferCleanup(mockServer.Close) - conf.Server.DataFolder = GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) svc = podcasts.NewPodcastService(ctx, ds, nil, nil) }) @@ -145,7 +145,7 @@ var _ = Describe("PodcastService", func() { It("creates the audio file at the expected path", func() { _ = svc.DownloadEpisode(ctx, "ep-1") - expectedPath := filepath.Join(conf.Server.DataFolder, "podcasts", "ch-1", "ep-1.mp3") + expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3") Eventually(func() bool { _, err := os.Stat(expectedPath) return err == nil @@ -161,7 +161,7 @@ var _ = Describe("PodcastService", func() { It("records the file path after download", func() { _ = svc.DownloadEpisode(ctx, "ep-1") - expectedPath := filepath.Join(conf.Server.DataFolder, "podcasts", "ch-1", "ep-1.mp3") + 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)) 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/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index cd39b4429..ee83d456f 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -20,6 +20,7 @@ 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" diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 619619810..91520e7ef 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/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/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index f6ea32d59..0c42c5054 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,7 +44,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { 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 15 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(15), + 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}}), @@ -86,7 +86,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) }) - It("should return 16 extensions including all podcast 2.0 and 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)) @@ -96,7 +96,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(16), + 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}}),