From 775747264be0dec75723d0c93d186ebee5c2419e Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Mon, 27 Apr 2026 23:40:15 +0900 Subject: [PATCH] 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 fd04c44c5..f8b1265d5 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/podcasts" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" @@ -108,8 +109,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) + podcastService := podcasts.NewPodcastService(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 cc38dd9de..6ef657f9d 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -37,12 +37,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 { @@ -247,7 +248,8 @@ type probeOutput struct { } type probeFormat struct { - BitRate string `json:"bit_rate"` + BitRate string `json:"bit_rate"` + Duration string `json:"duration"` } type probeStream struct { @@ -303,6 +305,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 57a712a20..14033c850 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -75,6 +75,7 @@ func (api *Router) routes() http.Handler { api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) api.addRadioRoute(r) + api.addPodcastRoute(r) api.R(r, "/tag", model.Tag{}, false) api.R(r, "/scrobble", model.Scrobble{}, false) if conf.Server.EnableSharing { diff --git a/server/nativeapi/podcasts.go b/server/nativeapi/podcasts.go new file mode 100644 index 000000000..a1599fdaf --- /dev/null +++ b/server/nativeapi/podcasts.go @@ -0,0 +1,45 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/podcasts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addPodcastRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.PodcastChannel{}) + } + r.Route("/podcast", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Get("/preview", api.podcastPreview) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Delete("/", rest.Delete(constructor)) + }) + }) +} + +func (api *Router) podcastPreview(w http.ResponseWriter, r *http.Request) { + feedURL := r.URL.Query().Get("url") + if feedURL == "" { + http.Error(w, "url is required", http.StatusBadRequest) + return + } + feed, err := podcasts.ParseFeedPreview(feedURL) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + exists, _ := api.ds.PodcastChannel(r.Context()).ExistsByURL(feedURL) + feed.AlreadyExists = exists + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(feed) +} diff --git a/server/subsonic/api.go b/server/subsonic/api.go index deedc46c7..5ef37fce3 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -17,6 +17,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" @@ -53,6 +54,7 @@ type Router struct { metrics metrics.Metrics lyrics lyricssvc.Lyrics transcodeDecision stream.TranscodeDecider + podcasts podcastsvc.Podcasts sonic *sonicsvc.Sonic } @@ -60,6 +62,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{ @@ -78,6 +81,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() @@ -225,9 +229,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 fd200fa60..35c7f5087 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -138,7 +138,8 @@ "actions": { "topSongs": "Top Songs", "shuffle": "Shuffle", - "radio": "Radio" + "radio": "Radio", + "podcast": "Podcasts" } }, "user": { @@ -251,6 +252,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') + }) +})