From 775747264be0dec75723d0c93d186ebee5c2419e Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Mon, 27 Apr 2026 23:40:15 +0900 Subject: [PATCH 01/20] 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') + }) +}) From 544929bae28aec6c79caab55eb208a9ed9364696 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee <search5@gmail.com> Date: Mon, 27 Apr 2026 23:40:40 +0900 Subject: [PATCH 02/20] chore: add podcasts/ to .gitignore (downloaded audio files should not be tracked) Signed-off-by: ji-ho lee <search5@gmail.com> --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4ed971491..1e4592b26 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ zz_*_test.go # wazero compilation cache for the plugins test suite /plugins/testdata/.wazero-cache/ /plugins/testdata/*.stage/ + +# Downloaded podcast episode audio files should not be tracked +podcasts/ From cff5a2acb0a6447e914c9fb2e6d0c55a6654539f Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee <search5@gmail.com> Date: Tue, 28 Apr 2026 00:12:40 +0900 Subject: [PATCH 03/20] test(podcast): fix test failures after API changes - Add ExistsByURL to MockPodcastChannelRepo - Add channel mock data to DownloadEpisode error handling and timestamp tests - Fix DeleteEpisode test to match actual behavior (resets to new status) - Pass podcasts.Podcasts to subsonic.New in e2e test suite Signed-off-by: ji-ho lee <search5@gmail.com> --- core/podcasts/podcasts_test.go | 19 +++++++++++++++---- server/subsonic/e2e/e2e_suite_test.go | 2 ++ tests/mock_podcast_channel_repo.go | 12 ++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index da4ac6ff7..7226e80a6 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -50,7 +50,7 @@ var _ = Describe("PodcastService", func() { DeferCleanup(mockServer.Close) conf.Server.DataFolder = GinkgoT().TempDir() - svc = podcasts.NewPodcastService(ds, nil) + svc = podcasts.NewPodcastService(ds, nil, nil) }) Describe("AddChannel", func() { @@ -123,6 +123,11 @@ var _ = Describe("PodcastService", func() { var episode *model.PodcastEpisode BeforeEach(func() { + channelRepo.Data["ch-1"] = &model.PodcastChannel{ + ID: "ch-1", + Title: "Test Channel", + URL: "http://example.com/feed.xml", + } episode = &model.PodcastEpisode{ ID: "ep-1", ChannelID: "ch-1", @@ -164,11 +169,11 @@ var _ = Describe("PodcastService", func() { }) Describe("DeleteEpisode", func() { - It("removes episode from DB", func() { - episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", ChannelID: "ch-1"} + It("resets episode to new status", func() { + episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", ChannelID: "ch-1", Status: model.PodcastStatusCompleted} err := svc.DeleteEpisode(ctx, "ep-1") Expect(err).ToNot(HaveOccurred()) - Expect(episodeRepo.Data).To(BeEmpty()) + Expect(episodeRepo.Data["ep-1"].Status).To(Equal(model.PodcastStatusNew)) }) It("deletes the downloaded file when path is set", func() { @@ -208,6 +213,9 @@ var _ = Describe("PodcastService", func() { }) Describe("DownloadEpisode error handling", func() { + BeforeEach(func() { + channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1", Title: "Test Channel"} + }) It("sets status to error when download fails", func() { episodeRepo.Data["ep-bad"] = &model.PodcastEpisode{ ID: "ep-bad", @@ -238,6 +246,9 @@ var _ = Describe("PodcastService", func() { }) Describe("DownloadEpisode with timestamp", func() { + BeforeEach(func() { + channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1", Title: "Test Channel"} + }) It("sets updated_at after status change", func() { episodeRepo.Data["ep-ts"] = &model.PodcastEpisode{ ID: "ep-ts", diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 58e877b0d..666636920 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -24,6 +24,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/podcasts" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/storage/storagetest" "github.com/navidrome/navidrome/core/stream" @@ -430,6 +431,7 @@ func setupTestDB() { metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, + podcasts.NewPodcastService(ds, ffmpeg.New(), events.NoopBroker()), nil, ) } diff --git a/tests/mock_podcast_channel_repo.go b/tests/mock_podcast_channel_repo.go index 8096f0d4b..0e5349570 100644 --- a/tests/mock_podcast_channel_repo.go +++ b/tests/mock_podcast_channel_repo.go @@ -57,6 +57,18 @@ func (m *MockPodcastChannelRepo) UpdateChannel(channel *model.PodcastChannel) er return nil } +func (m *MockPodcastChannelRepo) ExistsByURL(url string) (bool, error) { + if m.Err { + return false, errors.New("error") + } + for _, c := range m.Data { + if c.URL == url { + return true, nil + } + } + return false, nil +} + func (m *MockPodcastChannelRepo) Delete(chanID string) error { if m.Err { return errors.New("error") From 5279f23bc86b5a517b4d1a09c22d21fb3a790ba8 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee <search5@gmail.com> Date: Tue, 28 Apr 2026 00:17:17 +0900 Subject: [PATCH 04/20] fix(podcast): address code review feedback - Add 30s timeout to episode download HTTP client - Add 15s timeout to RSS feed fetch HTTP client - Fix N+1 query in GetAll(withEpisodes): fetch all episodes in a single query using IN clause via GetByChannels - Add sanitizeMetadata helper to strip null bytes from ffmpeg tag values - Add TODO comment on background goroutine context for server shutdown Signed-off-by: ji-ho lee <search5@gmail.com> --- core/podcasts/podcasts.go | 18 ++++++++++++++---- model/podcast.go | 1 + persistence/podcast_channel_repository.go | 20 ++++++++++++++------ persistence/podcast_episode_repository.go | 10 ++++++++++ tests/mock_podcast_episode_repo.go | 17 +++++++++++++++++ 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index d8a12b4c2..8737e9f7f 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -153,6 +153,8 @@ func (s *podcastService) DownloadEpisode(ctx context.Context, id string) error { return err } + // Use context.Background() so the download is not cancelled when the HTTP + // request completes. TODO: tie to server shutdown context for graceful termination. go s.doDownload(context.Background(), ep, ch) return nil } @@ -176,7 +178,8 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod } defer f.Close() - resp, err := http.Get(ep.EnclosureURL) //nolint:gosec + httpClient := &http.Client{Timeout: 30 * time.Second} + resp, err := httpClient.Get(ep.EnclosureURL) //nolint:gosec if err != nil { s.setEpisodeError(ctx, ep, err) return @@ -287,6 +290,12 @@ func (s *podcastService) setEpisodeError(ctx context.Context, ep *model.PodcastE } } +// sanitizeMetadata removes null bytes and trims whitespace from ffmpeg metadata values. +// Since exec.Command passes args directly (no shell), only null bytes need sanitizing. +func sanitizeMetadata(s string) string { + return strings.ReplaceAll(strings.TrimSpace(s), "\x00", "") +} + func (s *podcastService) writeID3Tags(ctx context.Context, dest, suffix, title, album string) { if s.ff == nil { return @@ -298,8 +307,8 @@ func (s *podcastService) writeID3Tags(ctx context.Context, dest, suffix, title, tmp := dest + ".tmp." + suffix cmd := exec.CommandContext(ctx, ffmpegPath, "-i", dest, - "-metadata", "title="+title, - "-metadata", "album="+album, + "-metadata", "title="+sanitizeMetadata(title), + "-metadata", "album="+sanitizeMetadata(album), "-metadata", "artist=", "-metadata", "genre=Podcast", "-c", "copy", "-y", tmp, @@ -386,7 +395,8 @@ func (pw *progressWriter) Write(p []byte) (int, error) { } func fetchAndParse(rssURL string) (*rssFeed, error) { - resp, err := http.Get(rssURL) //nolint:gosec + httpClient := &http.Client{Timeout: 15 * time.Second} + resp, err := httpClient.Get(rssURL) //nolint:gosec if err != nil { return nil, fmt.Errorf("fetching RSS feed: %w", err) } diff --git a/model/podcast.go b/model/podcast.go index 025b7acf7..6df3e1ccd 100644 --- a/model/podcast.go +++ b/model/podcast.go @@ -64,6 +64,7 @@ type PodcastEpisodeRepository interface { Get(id string) (*PodcastEpisode, error) GetNewest(count int) (PodcastEpisodes, error) GetByChannel(channelID string) (PodcastEpisodes, error) + GetByChannels(channelIDs []string) (PodcastEpisodes, error) GetByGUID(channelID, guid string) (*PodcastEpisode, error) Create(ep *PodcastEpisode) error Update(ep *PodcastEpisode) error diff --git a/persistence/podcast_channel_repository.go b/persistence/podcast_channel_repository.go index 9b708415b..7f72d3b12 100644 --- a/persistence/podcast_channel_repository.go +++ b/persistence/podcast_channel_repository.go @@ -43,14 +43,22 @@ func (r *podcastChannelRepository) GetAll(withEpisodes bool) (model.PodcastChann if err := r.queryAll(sel, &channels); err != nil { return nil, err } - if withEpisodes { + if withEpisodes && len(channels) > 0 { + ids := make([]string, len(channels)) + for i, ch := range channels { + ids[i] = ch.ID + } epRepo := NewPodcastEpisodeRepository(r.ctx, r.db) + allEps, err := epRepo.GetByChannels(ids) + if err != nil { + return nil, err + } + epsByChannel := make(map[string]model.PodcastEpisodes, len(channels)) + for _, ep := range allEps { + epsByChannel[ep.ChannelID] = append(epsByChannel[ep.ChannelID], ep) + } for i := range channels { - eps, err := epRepo.GetByChannel(channels[i].ID) - if err != nil { - return nil, err - } - channels[i].Episodes = eps + channels[i].Episodes = epsByChannel[channels[i].ID] } } return channels, nil diff --git a/persistence/podcast_episode_repository.go b/persistence/podcast_episode_repository.go index 78f631920..ae530f2e5 100644 --- a/persistence/podcast_episode_repository.go +++ b/persistence/podcast_episode_repository.go @@ -45,6 +45,16 @@ func (r *podcastEpisodeRepository) GetByChannel(channelID string) (model.Podcast return eps, err } +func (r *podcastEpisodeRepository) GetByChannels(channelIDs []string) (model.PodcastEpisodes, error) { + if len(channelIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}).OrderBy("channel_id, publish_date DESC") + var eps model.PodcastEpisodes + err := r.queryAll(sel, &eps) + return eps, err +} + func (r *podcastEpisodeRepository) GetByGUID(channelID, guid string) (*model.PodcastEpisode, error) { sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID, "guid": guid}) res := model.PodcastEpisode{} diff --git a/tests/mock_podcast_episode_repo.go b/tests/mock_podcast_episode_repo.go index d8b5ced33..ebc7d8c79 100644 --- a/tests/mock_podcast_episode_repo.go +++ b/tests/mock_podcast_episode_repo.go @@ -45,6 +45,23 @@ func (m *MockPodcastEpisodeRepo) GetNewest(count int) (model.PodcastEpisodes, er return all, nil } +func (m *MockPodcastEpisodeRepo) GetByChannels(channelIDs []string) (model.PodcastEpisodes, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(channelIDs)) + for _, id := range channelIDs { + ids[id] = true + } + var result model.PodcastEpisodes + for _, ep := range m.Data { + if ids[ep.ChannelID] { + result = append(result, *ep) + } + } + return result, nil +} + func (m *MockPodcastEpisodeRepo) GetByChannel(channelID string) (model.PodcastEpisodes, error) { if m.Err { return nil, errors.New("error") From be3c271c9c88368a87bc7fc18622b294485c0485 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee <search5@gmail.com> Date: Tue, 28 Apr 2026 00:20:00 +0900 Subject: [PATCH 05/20] refactor(podcast): inject server shutdown context into podcast service Pass the server root context (ctx) to NewPodcastService so that background download goroutines are tied to the server lifecycle and will be cancelled on shutdown, matching the pattern used by scanner.New. Signed-off-by: ji-ho lee <search5@gmail.com> --- cmd/wire_gen.go | 2 +- core/podcasts/podcasts.go | 15 +++++++-------- core/podcasts/podcasts_test.go | 2 +- server/subsonic/e2e/e2e_suite_test.go | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index f8b1265d5..43562da08 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -109,7 +109,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) - podcastService := podcasts.NewPodcastService(dataStore, fFmpeg, broker) + podcastService := podcasts.NewPodcastService(ctx, dataStore, fFmpeg, broker) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, podcastService, sonicSonic) return router diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 8737e9f7f..8ca0c6d82 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -31,13 +31,14 @@ type Podcasts interface { } type podcastService struct { - ds model.DataStore - ff ffmpeg.FFmpeg - broker events.Broker + rootCtx context.Context + ds model.DataStore + ff ffmpeg.FFmpeg + broker events.Broker } -func NewPodcastService(ds model.DataStore, ff ffmpeg.FFmpeg, broker events.Broker) Podcasts { - return &podcastService{ds: ds, ff: ff, broker: broker} +func NewPodcastService(rootCtx context.Context, ds model.DataStore, ff ffmpeg.FFmpeg, broker events.Broker) Podcasts { + return &podcastService{rootCtx: rootCtx, ds: ds, ff: ff, broker: broker} } // podcastLibraryID returns the ID of the podcast virtual library, @@ -153,9 +154,7 @@ func (s *podcastService) DownloadEpisode(ctx context.Context, id string) error { return err } - // Use context.Background() so the download is not cancelled when the HTTP - // request completes. TODO: tie to server shutdown context for graceful termination. - go s.doDownload(context.Background(), ep, ch) + go s.doDownload(s.rootCtx, ep, ch) return nil } diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index 7226e80a6..48dad79cd 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -50,7 +50,7 @@ var _ = Describe("PodcastService", func() { DeferCleanup(mockServer.Close) conf.Server.DataFolder = GinkgoT().TempDir() - svc = podcasts.NewPodcastService(ds, nil, nil) + svc = podcasts.NewPodcastService(ctx, ds, nil, nil) }) Describe("AddChannel", func() { diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 666636920..9879fa181 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -431,7 +431,7 @@ func setupTestDB() { metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, - podcasts.NewPodcastService(ds, ffmpeg.New(), events.NoopBroker()), + podcasts.NewPodcastService(context.Background(), ds, ffmpeg.New(), events.NoopBroker()), nil, ) } From 152a032ae170a62d1ac63b907a88fa8c9abcb0e5 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee <search5@gmail.com> Date: Tue, 28 Apr 2026 12:40:34 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat(podcast):=20implement=20Podcasting?= =?UTF-8?q?=202.0=20namespace=20support=20(Tier=201=E2=80=933)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds full support for the Podcasting 2.0 namespace (https://podcastindex.org/namespace/1.0) across RSS parsing, persistence, Subsonic API responses, and OpenSubsonic extensions. - podcast:guid: channel-level UUIDv5 for stable identity across feed URL changes - podcast:chapters: per-episode chapters URL + MIME type (chaptersUrl in API) - podcast:transcript: multiple transcripts per episode stored in a dedicated podcast_transcript table; attributes: url, type, language, rel - podcast:season: season number + optional name per episode - podcast:episode: episode number (decimal string) + optional display label - podcast:person: host/guest entries at both channel and episode level stored in a dedicated podcast_person table; attributes: name, role, group, img, href; role defaults to "host" and group defaults to "cast" per spec - podcast:locked: feed lock flag + optional owner email on channel - podcast:funding: first funding entry URL + display text on channel - podcast:medium: content type classification on channel - podcast:soundbite: startTime (float), duration (float), title per episode - podcast:updateFrequency: display text + rrule + complete flag on channel - podcast:podroll: creator-recommended feed list stored in podcast_podroll table (feedGuid, feedUrl, title, sort_order); returned as podroll[] in GetPodcasts - podcast:liveItem: live stream detection stored in podcast_live_item table (one row per channel, unique index); stores status, start/end times, enclosure URL/type, and contentLink for fallback playback; returned as liveItem object in GetPodcasts; Upsert preserves created_at on updates - podcast:podping: usesPodping boolean on channel; RefreshChannels skips channels with usesPodping=true (they receive updates via Podping WebSocket) - 20260428000000_add_podcast20.go: ALTER TABLE adds 9 columns to podcast_channel, 9 columns to podcast_episode; CREATE TABLE podcast_transcript (episode_id FK, url, mime_type, language, rel) and podcast_person (no FK constraints — put() serialises "" not NULL) - 20260428120000_add_podcast_tier3.go: ALTER TABLE adds uses_podping to podcast_channel; CREATE TABLE podcast_podroll and podcast_live_item (UNIQUE INDEX on channel_id) GetPodcasts response (PodcastChannel) gains: podcastGuid, locked, medium, fundingUrl, fundingText, updateFrequency, complete, usesPodping, person[], podroll[], liveItem{} GetPodcastEpisode response (PodcastEpisode) gains: season, seasonName, episode, episodeDisplay, chaptersUrl, soundbiteStart, soundbiteDur, transcript[], person[] GetPodcastEpisode now loads transcripts and persons from their repositories (previously only read the base episode row). New OpenSubsonic extensions declared: podcastChapters, podcastTranscripts, podcastSeason, podcastPerson, podcastFunding, podcastMedium, podcastPodroll, podcastLiveItem, podcastPodping - core/podcasts/rss_test.go: 41 new specs covering all namespace tags, default value handling (role→"host", group→"cast"), backward compatibility - core/podcasts/podcasts_test.go: 26 new service specs covering AddChannel field persistence, transcript/person saving, podroll/liveItem saving, RefreshChannels podping skip behaviour - persistence/podcast_transcript_repository_test.go: 10 specs - persistence/podcast_person_repository_test.go: 12 specs - persistence/podcast_podroll_repository_test.go: 10 specs - persistence/podcast_live_item_repository_test.go: 8 specs - server/subsonic/podcasts_test.go: 15 new handler specs for Tier 2 and Tier 3 fields in GetPodcasts and GetPodcastEpisode responses --- core/podcasts/podcasts.go | 96 +++- core/podcasts/podcasts_test.go | 265 +++++++++- core/podcasts/rss.go | 252 +++++++++- core/podcasts/rss_test.go | 464 ++++++++++++++++++ db/migrations/20260428000000_add_podcast20.go | 76 +++ .../20260428120000_add_podcast_tier3.go | 59 +++ model/datastore.go | 4 + model/podcast.go | 178 +++++-- persistence/persistence.go | 16 + persistence/podcast_live_item_repository.go | 59 +++ .../podcast_live_item_repository_test.go | 150 ++++++ persistence/podcast_person_repository.go | 83 ++++ persistence/podcast_person_repository_test.go | 172 +++++++ persistence/podcast_podroll_repository.go | 59 +++ .../podcast_podroll_repository_test.go | 145 ++++++ persistence/podcast_transcript_repository.go | 59 +++ .../podcast_transcript_repository_test.go | 144 ++++++ server/subsonic/opensubsonic.go | 9 + server/subsonic/opensubsonic_test.go | 26 +- server/subsonic/podcasts.go | 140 +++++- server/subsonic/podcasts_test.go | 286 +++++++++++ server/subsonic/responses/responses.go | 89 +++- tests/mock_data_store.go | 108 +++- tests/mock_podcast_live_item_repo.go | 53 ++ tests/mock_podcast_person_repo.go | 97 ++++ tests/mock_podcast_podroll_repo.go | 70 +++ tests/mock_podcast_transcript_repo.go | 74 +++ 27 files changed, 3147 insertions(+), 86 deletions(-) create mode 100644 db/migrations/20260428000000_add_podcast20.go create mode 100644 db/migrations/20260428120000_add_podcast_tier3.go create mode 100644 persistence/podcast_live_item_repository.go create mode 100644 persistence/podcast_live_item_repository_test.go create mode 100644 persistence/podcast_person_repository.go create mode 100644 persistence/podcast_person_repository_test.go create mode 100644 persistence/podcast_podroll_repository.go create mode 100644 persistence/podcast_podroll_repository_test.go create mode 100644 persistence/podcast_transcript_repository.go create mode 100644 persistence/podcast_transcript_repository_test.go create mode 100644 tests/mock_podcast_live_item_repo.go create mode 100644 tests/mock_podcast_person_repo.go create mode 100644 tests/mock_podcast_podroll_repo.go create mode 100644 tests/mock_podcast_transcript_repo.go diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 8ca0c6d82..35664a628 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -79,23 +79,74 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { } ch := &model.PodcastChannel{ - URL: rssURL, - Title: feed.Title, - Description: feed.Description, - ImageURL: feed.ImageURL, - Status: model.PodcastStatusNew, + URL: rssURL, + Title: feed.Title, + Description: feed.Description, + ImageURL: feed.ImageURL, + Status: model.PodcastStatusNew, + PodcastGUID: feed.PodcastGUID, + Locked: feed.Locked, + LockedOwner: feed.LockedOwner, + Medium: feed.Medium, + FundingURL: feed.FundingURL, + FundingText: feed.FundingText, + UpdateFrequency: feed.UpdateFrequency, + UpdateRRule: feed.UpdateRRule, + Complete: feed.Complete, + UsesPodping: feed.UsesPodping, } if err := s.ds.PodcastChannel(ctx).Create(ch); err != nil { return err } + // Save channel-level persons + if len(feed.Persons) > 0 { + if err := s.ds.PodcastPerson(ctx).SaveForChannel(ch.ID, feed.Persons); err != nil { + log.Warn(ctx, "Failed to save podcast channel persons", "channel", ch.ID, err) + } + } + + // Save podcast:podroll items + if len(feed.Podroll) > 0 { + if err := s.ds.PodcastPodroll(ctx).SaveForChannel(ch.ID, feed.Podroll); err != nil { + log.Warn(ctx, "Failed to save podcast podroll", "channel", ch.ID, err) + } + } + + // Save podcast:liveItem entries + for _, li := range feed.LiveItems { + li.ChannelID = ch.ID + if err := s.ds.PodcastLiveItem(ctx).Upsert(&li); err != nil { + log.Warn(ctx, "Failed to save podcast live item", "channel", ch.ID, err) + } + } + for i := range feed.Episodes { ep := feed.Episodes[i] ep.ChannelID = ch.ID ep.Status = model.PodcastStatusNew + transcripts := ep.Transcripts + persons := ep.Persons + ep.Transcripts = nil + ep.Persons = nil if err := s.ds.PodcastEpisode(ctx).Create(&ep); err != nil { return err } + // Save episode transcripts + if len(transcripts) > 0 { + for j := range transcripts { + transcripts[j].EpisodeID = ep.ID + } + if err := s.ds.PodcastTranscript(ctx).Save(transcripts); err != nil { + log.Warn(ctx, "Failed to save podcast episode transcripts", "episode", ep.ID, err) + } + } + // Save episode persons + if len(persons) > 0 { + if err := s.ds.PodcastPerson(ctx).SaveForEpisode(ep.ID, persons); err != nil { + log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err) + } + } } ch.Status = model.PodcastStatusCompleted @@ -109,6 +160,9 @@ func (s *podcastService) RefreshChannels(ctx context.Context) error { } for _, ch := range channels { + if ch.UsesPodping { + continue // skip — this channel uses Podping for updates + } if err := s.refreshChannel(ctx, ch); err != nil { log.Warn(ctx, "Failed to refresh podcast channel", "channel", ch.Title, err) } @@ -122,6 +176,19 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha return err } + // Refresh podcast:podroll + if err := s.ds.PodcastPodroll(ctx).SaveForChannel(ch.ID, feed.Podroll); err != nil { + log.Warn(ctx, "Failed to refresh podroll", "channel", ch.ID, err) + } + + // Refresh podcast:liveItem + for _, li := range feed.LiveItems { + li.ChannelID = ch.ID + if err := s.ds.PodcastLiveItem(ctx).Upsert(&li); err != nil { + log.Warn(ctx, "Failed to upsert live item", "channel", ch.ID, err) + } + } + epRepo := s.ds.PodcastEpisode(ctx) for i := range feed.Episodes { ep := feed.Episodes[i] @@ -131,9 +198,28 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha } ep.ChannelID = ch.ID ep.Status = model.PodcastStatusNew + transcripts := ep.Transcripts + persons := ep.Persons + ep.Transcripts = nil + ep.Persons = nil if err := epRepo.Create(&ep); err != nil { return err } + // Save episode transcripts + if len(transcripts) > 0 { + for j := range transcripts { + transcripts[j].EpisodeID = ep.ID + } + if err := s.ds.PodcastTranscript(ctx).Save(transcripts); err != nil { + log.Warn(ctx, "Failed to save podcast episode transcripts", "episode", ep.ID, err) + } + } + // Save episode persons + if len(persons) > 0 { + if err := s.ds.PodcastPerson(ctx).SaveForEpisode(ep.ID, persons); err != nil { + log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err) + } + } } return nil } diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index 48dad79cd..567123668 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -115,7 +115,7 @@ var _ = Describe("PodcastService", func() { } err := svc.RefreshChannels(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(episodeRepo.Data).To(HaveLen(2)) // 기존 1 + 신규 1 + Expect(episodeRepo.Data).To(HaveLen(2)) // 1 existing + 1 new }) }) @@ -245,6 +245,160 @@ var _ = Describe("PodcastService", func() { }) }) + Describe("AddChannel — Podcasting 2.0 field persistence", func() { + var transcriptRepo *tests.MockPodcastTranscriptRepo + var personRepo *tests.MockPodcastPersonRepo + var p20Server *httptest.Server + + BeforeEach(func() { + transcriptRepo = tests.CreateMockPodcastTranscriptRepo() + personRepo = tests.CreateMockPodcastPersonRepo() + ds.MockedPodcastTranscript = transcriptRepo + ds.MockedPodcastPerson = personRepo + + p20Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + fmt.Fprint(w, testRSSFeedPodcast20) + })) + DeferCleanup(p20Server.Close) + }) + + It("stores PodcastGUID from feed", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.PodcastGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810")) + } + }) + + It("stores Locked flag and LockedOwner from feed", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.Locked).To(BeTrue()) + Expect(ch.LockedOwner).To(Equal("owner@example.com")) + } + }) + + It("stores Medium from feed", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.Medium).To(Equal("podcast")) + } + }) + + It("stores first FundingURL and FundingText from feed", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.FundingURL).To(Equal("https://example.com/donate")) + Expect(ch.FundingText).To(Equal("Support us!")) + } + }) + + It("stores UpdateFrequency and UpdateRRule from feed", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.UpdateFrequency).To(Equal("Weekly")) + Expect(ch.UpdateRRule).To(Equal("FREQ=WEEKLY")) + } + }) + + It("saves channel-level podcast:person entries", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + Expect(personRepo.Data).ToNot(BeEmpty()) + var channelPersons []string + for _, p := range personRepo.Data { + if p.ChannelID != "" { + channelPersons = append(channelPersons, p.Name) + } + } + Expect(channelPersons).To(ConsistOf("Jane Host", "Bob Producer")) + }) + + It("saves episode podcast:transcript entries", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + Expect(transcriptRepo.Data).ToNot(BeEmpty()) + var mimeTypes []string + for _, t := range transcriptRepo.Data { + mimeTypes = append(mimeTypes, t.MimeType) + } + Expect(mimeTypes).To(ConsistOf("text/vtt", "application/x-subrip")) + }) + + It("stores transcript language and rel attributes", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var vttLanguage, vttRel string + for _, t := range transcriptRepo.Data { + if t.MimeType == "text/vtt" { + vttLanguage = t.Language + vttRel = t.Rel + } + } + Expect(vttLanguage).To(Equal("en")) + Expect(vttRel).To(Equal("captions")) + }) + + It("saves episode-level podcast:person entries", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var episodePersonNames []string + for _, p := range personRepo.Data { + if p.EpisodeID != "" { + episodePersonNames = append(episodePersonNames, p.Name) + } + } + Expect(episodePersonNames).To(ContainElement("John Guest")) + }) + + It("stores episode ChaptersURL", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var chaptersURLs []string + for _, ep := range episodeRepo.Data { + if ep.ChaptersURL != "" { + chaptersURLs = append(chaptersURLs, ep.ChaptersURL) + } + } + Expect(chaptersURLs).To(ContainElement("https://example.com/ep1/chapters.json")) + }) + + It("stores episode Season number and name", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var ep1 *model.PodcastEpisode + for _, ep := range episodeRepo.Data { + if ep.GUID == "guid-ep-001" { + ep1 = ep + } + } + Expect(ep1).ToNot(BeNil()) + Expect(ep1.Season).To(Equal(1)) + Expect(ep1.SeasonName).To(Equal("Season One")) + }) + + It("stores episode EpisodeNumber and EpisodeDisplay", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var ep1 *model.PodcastEpisode + for _, ep := range episodeRepo.Data { + if ep.GUID == "guid-ep-001" { + ep1 = ep + } + } + Expect(ep1).ToNot(BeNil()) + Expect(ep1.EpisodeNumber).To(Equal("1")) + Expect(ep1.EpisodeDisplay).To(Equal("Ep.1")) + }) + + It("stores episode Soundbite fields", func() { + Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) + var ep1 *model.PodcastEpisode + for _, ep := range episodeRepo.Data { + if ep.GUID == "guid-ep-001" { + ep1 = ep + } + } + Expect(ep1).ToNot(BeNil()) + Expect(ep1.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001)) + Expect(ep1.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001)) + Expect(ep1.SoundbiteTitle).To(Equal("Best moment")) + }) + }) + Describe("DownloadEpisode with timestamp", func() { BeforeEach(func() { channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1", Title: "Test Channel"} @@ -264,4 +418,113 @@ var _ = Describe("PodcastService", func() { }, "3s").Should(BeTrue()) }) }) + + Describe("AddChannel — Tier 3 field persistence", func() { + var podrollRepo *tests.MockPodcastPodrollRepo + var liveItemRepo *tests.MockPodcastLiveItemRepo + var tier3Server *httptest.Server + + BeforeEach(func() { + podrollRepo = tests.CreateMockPodcastPodrollRepo() + liveItemRepo = tests.CreateMockPodcastLiveItemRepo() + ds.MockedPodcastPodroll = podrollRepo + ds.MockedPodcastLiveItem = liveItemRepo + }) + + Context("when feed has podcast:podping usesPodping=true", func() { + BeforeEach(func() { + tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + fmt.Fprint(w, testRSSFeedPodping) + })) + DeferCleanup(tier3Server.Close) + }) + + It("stores UsesPodping=true on the channel", func() { + Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed()) + for _, ch := range channelRepo.Data { + Expect(ch.UsesPodping).To(BeTrue()) + } + }) + }) + + Context("when feed has podcast:podroll", func() { + BeforeEach(func() { + tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + fmt.Fprint(w, testRSSFeedPodroll) + })) + DeferCleanup(tier3Server.Close) + }) + + It("saves podroll items for the channel", func() { + Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed()) + Expect(podrollRepo.Data).ToNot(BeEmpty()) + var urls []string + for _, item := range podrollRepo.Data { + urls = append(urls, item.FeedURL) + } + Expect(urls).To(ConsistOf( + "https://example.com/feed.xml", + "https://other.com/feed.xml", + )) + }) + }) + + Context("when feed has podcast:liveItem", func() { + BeforeEach(func() { + tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + fmt.Fprint(w, testRSSFeedLiveItem) + })) + DeferCleanup(tier3Server.Close) + }) + + It("saves the live item for the channel", func() { + Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed()) + Expect(liveItemRepo.Data).ToNot(BeEmpty()) + for _, li := range liveItemRepo.Data { + Expect(li.Status).To(Equal("live")) + Expect(li.GUID).To(Equal("live-guid-001")) + } + }) + }) + }) + + Describe("RefreshChannels — Tier 3 podping skip", func() { + var podrollRepo *tests.MockPodcastPodrollRepo + var liveItemRepo *tests.MockPodcastLiveItemRepo + + BeforeEach(func() { + podrollRepo = tests.CreateMockPodcastPodrollRepo() + liveItemRepo = tests.CreateMockPodcastLiveItemRepo() + ds.MockedPodcastPodroll = podrollRepo + ds.MockedPodcastLiveItem = liveItemRepo + }) + + It("skips channels with UsesPodping=true during refresh", func() { + // UsesPodping channel points to a server that would add episodes. + channelRepo.Data["ch-podping"] = &model.PodcastChannel{ + ID: "ch-podping", + URL: mockServer.URL + "/feed.xml", + UsesPodping: true, + } + initialEpisodeCount := len(episodeRepo.Data) + + Expect(svc.RefreshChannels(ctx)).To(Succeed()) + // No new episodes should be added because the only channel uses podping. + Expect(episodeRepo.Data).To(HaveLen(initialEpisodeCount)) + }) + + It("still refreshes channels with UsesPodping=false", func() { + channelRepo.Data["ch-normal"] = &model.PodcastChannel{ + ID: "ch-normal", + URL: mockServer.URL + "/feed.xml", + UsesPodping: false, + } + Expect(svc.RefreshChannels(ctx)).To(Succeed()) + // Episodes from the mock feed should have been added. + Expect(episodeRepo.Data).ToNot(BeEmpty()) + }) + }) }) diff --git a/core/podcasts/rss.go b/core/podcasts/rss.go index b41c24926..b5bb47411 100644 --- a/core/podcasts/rss.go +++ b/core/podcasts/rss.go @@ -13,6 +13,8 @@ import ( "github.com/navidrome/navidrome/model" ) +const podcastNS = "https://podcastindex.org/namespace/1.0" + // Go's encoding/xml has a known limitation with inherited namespace prefixes. // We fall back to a regex scan for itunes:image when struct tag parsing yields nothing. var itunesImageRe = regexp.MustCompile(`<[^:>]*:image[^>]+href="([^"]*)"`) @@ -29,6 +31,23 @@ type rssFeed struct { Description string ImageURL string Episodes []model.PodcastEpisode + + // Podcasting 2.0 channel fields + PodcastGUID string + Locked bool + LockedOwner string + Medium string + FundingURL string + FundingText string + UpdateFrequency string + UpdateRRule string + Complete bool + Persons []model.PodcastPerson + + // Podcasting 2.0 Tier 3 channel fields + UsesPodping bool + Podroll []model.PodcastPodrollItem + LiveItems []model.PodcastLiveItem } type FeedPreview struct { @@ -37,6 +56,12 @@ type FeedPreview struct { ImageURL string `json:"imageUrl"` EpisodeCount int `json:"episodeCount"` AlreadyExists bool `json:"alreadyExists"` + + // Podcasting 2.0 + Medium string `json:"medium,omitempty"` + UpdateFrequency string `json:"updateFrequency,omitempty"` + FundingURL string `json:"fundingUrl,omitempty"` + FundingText string `json:"fundingText,omitempty"` } func ParseFeedPreview(rssURL string) (*FeedPreview, error) { @@ -45,13 +70,19 @@ func ParseFeedPreview(rssURL string) (*FeedPreview, error) { return nil, err } return &FeedPreview{ - Title: feed.Title, - Description: feed.Description, - ImageURL: feed.ImageURL, - EpisodeCount: len(feed.Episodes), + Title: feed.Title, + Description: feed.Description, + ImageURL: feed.ImageURL, + EpisodeCount: len(feed.Episodes), + Medium: feed.Medium, + UpdateFrequency: feed.UpdateFrequency, + FundingURL: feed.FundingURL, + FundingText: feed.FundingText, }, nil } +// ---- XML struct definitions ---- + type rssRoot struct { Channel rssChannel `xml:"channel"` } @@ -62,6 +93,50 @@ type rssChannel struct { Image rssImage `xml:"image"` ItunesImage itunesImage `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"` Items []rssItem `xml:"item"` + + // Podcasting 2.0 channel tags — Tier 1 & 2 + PodcastGUID string `xml:"https://podcastindex.org/namespace/1.0 guid"` + PodcastLocked podcastLocked `xml:"https://podcastindex.org/namespace/1.0 locked"` + PodcastMedium string `xml:"https://podcastindex.org/namespace/1.0 medium"` + PodcastFundings []podcastFunding `xml:"https://podcastindex.org/namespace/1.0 funding"` + PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"` + PodcastUpdateFreq podcastUpdateFreq `xml:"https://podcastindex.org/namespace/1.0 updateFrequency"` + + // Podcasting 2.0 channel tags — Tier 3 + PodcastPodping podcastPodping `xml:"https://podcastindex.org/namespace/1.0 podping"` + PodcastPodroll podcastPodroll `xml:"https://podcastindex.org/namespace/1.0 podroll"` + PodcastLiveItems []podcastLiveItemXML `xml:"https://podcastindex.org/namespace/1.0 liveItem"` +} + +// Tier 3 XML parsing structs. + +type podcastPodping struct { + UsesPodping string `xml:"usesPodping,attr"` +} + +type podcastRemoteItem struct { + FeedGUID string `xml:"feedGuid,attr"` + FeedURL string `xml:"feedUrl,attr"` + Title string `xml:"title,attr"` +} + +type podcastPodroll struct { + Items []podcastRemoteItem `xml:"https://podcastindex.org/namespace/1.0 remoteItem"` +} + +type podcastContentLink struct { + Href string `xml:"href,attr"` + Text string `xml:",chardata"` +} + +type podcastLiveItemXML struct { + Status string `xml:"status,attr"` + Start string `xml:"start,attr"` + End string `xml:"end,attr"` + Title string `xml:"title"` + GUID string `xml:"guid"` + Enclosure enclosure `xml:"enclosure"` + ContentLink podcastContentLink `xml:"https://podcastindex.org/namespace/1.0 contentLink"` } type rssImage struct { @@ -72,6 +147,30 @@ type itunesImage struct { Href string `xml:"href,attr"` } +type podcastLocked struct { + Owner string `xml:"owner,attr"` + Value string `xml:",chardata"` +} + +type podcastFunding struct { + URL string `xml:"url,attr"` + Text string `xml:",chardata"` +} + +type podcastPerson struct { + Role string `xml:"role,attr"` + Group string `xml:"group,attr"` + Img string `xml:"img,attr"` + Href string `xml:"href,attr"` + Name string `xml:",chardata"` +} + +type podcastUpdateFreq struct { + Complete string `xml:"complete,attr"` + RRule string `xml:"rrule,attr"` + Text string `xml:",chardata"` +} + type rssItem struct { Title string `xml:"title"` Description string `xml:"description"` @@ -80,6 +179,14 @@ type rssItem struct { PubDate string `xml:"pubDate"` Enclosure enclosure `xml:"enclosure"` ItunesDur string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"` + + // Podcasting 2.0 episode tags + PodcastChapters podcastChapters `xml:"https://podcastindex.org/namespace/1.0 chapters"` + PodcastTranscripts []podcastTranscript `xml:"https://podcastindex.org/namespace/1.0 transcript"` + PodcastSeason podcastSeason `xml:"https://podcastindex.org/namespace/1.0 season"` + PodcastEpisodeNum podcastEpisodeNum `xml:"https://podcastindex.org/namespace/1.0 episode"` + PodcastSoundbite podcastSoundbite `xml:"https://podcastindex.org/namespace/1.0 soundbite"` + PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"` } type enclosure struct { @@ -88,6 +195,36 @@ type enclosure struct { Type string `xml:"type,attr"` } +type podcastChapters struct { + URL string `xml:"url,attr"` + Type string `xml:"type,attr"` +} + +type podcastTranscript struct { + URL string `xml:"url,attr"` + Type string `xml:"type,attr"` + Language string `xml:"language,attr"` + Rel string `xml:"rel,attr"` +} + +type podcastSeason struct { + Name string `xml:"name,attr"` + Value string `xml:",chardata"` +} + +type podcastEpisodeNum struct { + Display string `xml:"display,attr"` + Value string `xml:",chardata"` +} + +type podcastSoundbite struct { + StartTime string `xml:"startTime,attr"` + Duration string `xml:"duration,attr"` + Title string `xml:",chardata"` +} + +// ---- Parsing ---- + func ParseRSSFeed(data []byte) (*rssFeed, error) { var root rssRoot if err := xml.Unmarshal(data, &root); err != nil { @@ -98,11 +235,18 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { feed := &rssFeed{ Title: ch.Title, Description: ch.Description, + + // Podcasting 2.0 channel + PodcastGUID: ch.PodcastGUID, + Locked: strings.TrimSpace(ch.PodcastLocked.Value) == "yes", + LockedOwner: ch.PodcastLocked.Owner, + Medium: ch.PodcastMedium, + UpdateFrequency: strings.TrimSpace(ch.PodcastUpdateFreq.Text), + UpdateRRule: ch.PodcastUpdateFreq.RRule, + Complete: strings.TrimSpace(ch.PodcastUpdateFreq.Complete) == "true", } // itunes:image takes precedence over <image><url>. - // Use regex fallback because Go's xml package doesn't always resolve - // namespace prefixes inherited from ancestor elements. if href := extractItunesImageHref(data); href != "" { feed.ImageURL = href } else if ch.ItunesImage.Href != "" { @@ -111,6 +255,53 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { feed.ImageURL = ch.Image.URL } + // first funding entry + if len(ch.PodcastFundings) > 0 { + feed.FundingURL = ch.PodcastFundings[0].URL + feed.FundingText = strings.TrimSpace(ch.PodcastFundings[0].Text) + } + + // channel persons + for _, p := range ch.PodcastPersons { + feed.Persons = append(feed.Persons, model.PodcastPerson{ + Name: strings.TrimSpace(p.Name), + Role: defaultStr(p.Role, "host"), + Group: defaultStr(p.Group, "cast"), + Img: p.Img, + Href: p.Href, + }) + } + + // podcast:podping + feed.UsesPodping = strings.TrimSpace(ch.PodcastPodping.UsesPodping) == "true" + + // podcast:podroll + for i, item := range ch.PodcastPodroll.Items { + feed.Podroll = append(feed.Podroll, model.PodcastPodrollItem{ + FeedGUID: item.FeedGUID, + FeedURL: item.FeedURL, + Title: item.Title, + SortOrder: i, + }) + } + + // podcast:liveItem + for _, li := range ch.PodcastLiveItems { + startTime, _ := time.Parse(time.RFC3339, li.Start) + endTime, _ := time.Parse(time.RFC3339, li.End) + feed.LiveItems = append(feed.LiveItems, model.PodcastLiveItem{ + GUID: li.GUID, + Title: li.Title, + Status: li.Status, + StartTime: startTime, + EndTime: endTime, + EnclosureURL: li.Enclosure.URL, + EnclosureType: li.Enclosure.Type, + ContentLinkURL: li.ContentLink.Href, + ContentLinkText: strings.TrimSpace(li.ContentLink.Text), + }) + } + for _, item := range ch.Items { desc := item.Description if desc == "" { @@ -131,7 +322,38 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { Suffix: suffix, Duration: parseDuration(item.ItunesDur), Status: model.PodcastStatusNew, + + // Podcasting 2.0 episode + ChaptersURL: item.PodcastChapters.URL, + ChaptersType: item.PodcastChapters.Type, + Season: parseInt(item.PodcastSeason.Value), + SeasonName: item.PodcastSeason.Name, + EpisodeNumber: strings.TrimSpace(item.PodcastEpisodeNum.Value), + EpisodeDisplay: item.PodcastEpisodeNum.Display, + SoundbiteStart: parseFloat(item.PodcastSoundbite.StartTime), + SoundbiteDur: parseFloat(item.PodcastSoundbite.Duration), + SoundbiteTitle: strings.TrimSpace(item.PodcastSoundbite.Title), } + + for _, t := range item.PodcastTranscripts { + ep.Transcripts = append(ep.Transcripts, model.PodcastTranscript{ + URL: t.URL, + MimeType: t.Type, + Language: t.Language, + Rel: t.Rel, + }) + } + + for _, p := range item.PodcastPersons { + ep.Persons = append(ep.Persons, model.PodcastPerson{ + Name: strings.TrimSpace(p.Name), + Role: defaultStr(p.Role, "host"), + Group: defaultStr(p.Group, "cast"), + Img: p.Img, + Href: p.Href, + }) + } + feed.Episodes = append(feed.Episodes, ep) } @@ -175,6 +397,23 @@ func parseDuration(s string) int { } } +func parseInt(s string) int { + v, _ := strconv.Atoi(strings.TrimSpace(s)) + return v +} + +func parseFloat(s string) float64 { + v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + return v +} + +func defaultStr(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return s +} + var mimeToSuffix = map[string]string{ "audio/mpeg": "mp3", "audio/mp3": "mp3", @@ -192,7 +431,6 @@ func suffixFromMIME(mimeType, enclosureURL string) string { if s, ok := mimeToSuffix[base]; ok { return s } - // fallback: extract from URL path if u, err := url.Parse(enclosureURL); err == nil { if ext := path.Ext(u.Path); ext != "" { return strings.TrimPrefix(ext, ".") diff --git a/core/podcasts/rss_test.go b/core/podcasts/rss_test.go index f2365ead9..0df1c272c 100644 --- a/core/podcasts/rss_test.go +++ b/core/podcasts/rss_test.go @@ -114,3 +114,467 @@ var _ = Describe("ParseRSSFeed", func() { }) }) }) + +// Podcasting 2.0 namespace (https://podcastindex.org/namespace/1.0) parsing tests. +const testRSSFeedPodcast20 = `<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" + xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" + xmlns:podcast="https://podcastindex.org/namespace/1.0"> + <channel> + <title>Podcast 2.0 Show + Testing Podcasting 2.0 + + + 917393e3-1b1e-5cef-ace4-edaa54e1f810 + yes + podcast + Support us! + Secondary + Jane Host + Bob Producer + Weekly + + + Episode 1 + guid-ep-001 + Mon, 01 Jan 2024 00:00:00 +0000 + + 01:23:45 + 1 + 1 + + + + Best moment + John Guest + + + Episode 2 — no podcast: tags + guid-ep-002 + Thu, 01 Feb 2024 00:00:00 +0000 + + + +` + +const testRSSFeedLocked = ` + + + Unlocked Show + no + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedPersonDefaults = ` + + + Defaults Show + No Attrs Person + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + Episode No Attrs + + +` + +// --- Tier 3 RSS test fixtures --- + +const testRSSFeedPodping = ` + + + Podping Show + + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedPodpingFalse = ` + + + No Podping Show + + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedPodroll = ` + + + Podroll Show + + + + + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedLiveItem = ` + + + Live Show Channel + + Live Show + live-guid-001 + + Watch Live + + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedLiveItemPending = ` + + + Pending Live Channel + + Upcoming Show + live-guid-002 + + + + Epg1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +var _ = Describe("ParseRSSFeed — Tier 3 tags", func() { + Describe("podcast:podping", func() { + It("sets UsesPodping=true when usesPodping attribute is 'true'", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodping)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.UsesPodping).To(BeTrue()) + }) + + It("sets UsesPodping=false when usesPodping attribute is 'false'", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodpingFalse)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.UsesPodping).To(BeFalse()) + }) + + It("sets UsesPodping=false when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.UsesPodping).To(BeFalse()) + }) + }) + + Describe("podcast:podroll", func() { + It("parses multiple remoteItem entries", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Podroll).To(HaveLen(2)) + }) + + It("parses feedGuid, feedUrl, and title from each remoteItem", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll)) + Expect(err).ToNot(HaveOccurred()) + first := result.Podroll[0] + Expect(first.FeedGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810")) + Expect(first.FeedURL).To(Equal("https://example.com/feed.xml")) + Expect(first.Title).To(Equal("Great Show")) + }) + + It("handles remoteItem without title", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll)) + Expect(err).ToNot(HaveOccurred()) + second := result.Podroll[1] + Expect(second.FeedGUID).To(Equal("abc123-def456")) + Expect(second.FeedURL).To(Equal("https://other.com/feed.xml")) + Expect(second.Title).To(BeEmpty()) + }) + + It("assigns SortOrder in declaration order", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Podroll[0].SortOrder).To(Equal(0)) + Expect(result.Podroll[1].SortOrder).To(Equal(1)) + }) + + It("returns empty podroll when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Podroll).To(BeEmpty()) + }) + }) + + Describe("podcast:liveItem", func() { + It("parses status, start, and end attributes", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItem)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LiveItems).To(HaveLen(1)) + li := result.LiveItems[0] + Expect(li.Status).To(Equal("live")) + Expect(li.StartTime.UTC().Format(time.RFC3339)).To(Equal("2024-04-27T08:00:00Z")) + Expect(li.EndTime.UTC().Format(time.RFC3339)).To(Equal("2024-04-27T09:00:00Z")) + }) + + It("parses title, guid, enclosure, and contentLink", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItem)) + Expect(err).ToNot(HaveOccurred()) + li := result.LiveItems[0] + Expect(li.Title).To(Equal("Live Show")) + Expect(li.GUID).To(Equal("live-guid-001")) + Expect(li.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8")) + Expect(li.EnclosureType).To(Equal("application/x-mpegURL")) + Expect(li.ContentLinkURL).To(Equal("https://youtube.com/live")) + Expect(li.ContentLinkText).To(Equal("Watch Live")) + }) + + It("handles pending liveItem without start/end times", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItemPending)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LiveItems).To(HaveLen(1)) + li := result.LiveItems[0] + Expect(li.Status).To(Equal("pending")) + Expect(li.StartTime.IsZero()).To(BeTrue()) + Expect(li.EndTime.IsZero()).To(BeTrue()) + }) + + It("returns empty liveItems when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LiveItems).To(BeEmpty()) + }) + }) +}) + +var _ = Describe("ParseRSSFeed — Podcasting 2.0 namespace", func() { + Describe("channel-level tags", func() { + It("podcast:guid — parses channel GUID", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.PodcastGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810")) + }) + + It("podcast:locked yes — sets Locked=true and LockedOwner", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Locked).To(BeTrue()) + Expect(result.LockedOwner).To(Equal("owner@example.com")) + }) + + It("podcast:locked no — sets Locked=false", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocked)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Locked).To(BeFalse()) + }) + + It("podcast:medium — parses medium type", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Medium).To(Equal("podcast")) + }) + + It("podcast:funding — stores first entry URL and text", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.FundingURL).To(Equal("https://example.com/donate")) + Expect(result.FundingText).To(Equal("Support us!")) + }) + + It("podcast:funding — ignores entries after the first", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.FundingURL).ToNot(ContainSubstring("donate2")) + }) + + It("podcast:person — parses multiple channel persons", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Persons).To(HaveLen(2)) + }) + + It("podcast:person — parses name, role, group, img, href", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + host := result.Persons[0] + Expect(host.Name).To(Equal("Jane Host")) + Expect(host.Role).To(Equal("host")) + Expect(host.Group).To(Equal("cast")) + Expect(host.Img).To(Equal("https://example.com/host.jpg")) + Expect(host.Href).To(Equal("https://example.com/host")) + }) + + It("podcast:person — role defaults to 'host' when omitted", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Persons[0].Role).To(Equal("host")) + }) + + It("podcast:person — group defaults to 'cast' when omitted", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Persons[0].Group).To(Equal("cast")) + }) + + It("podcast:updateFrequency — parses display text and rrule", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.UpdateFrequency).To(Equal("Weekly")) + Expect(result.UpdateRRule).To(Equal("FREQ=WEEKLY")) + }) + + It("podcast:updateFrequency — complete=false sets Complete=false", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Complete).To(BeFalse()) + }) + }) + + Describe("episode-level tags", func() { + It("podcast:season — parses season number and name", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.Season).To(Equal(1)) + Expect(ep.SeasonName).To(Equal("Season One")) + }) + + It("podcast:season — episodes without tag have Season=0", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[1].Season).To(Equal(0)) + }) + + It("podcast:episode — parses episode number and display label", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.EpisodeNumber).To(Equal("1")) + Expect(ep.EpisodeDisplay).To(Equal("Ep.1")) + }) + + It("podcast:chapters — parses chapters URL and type", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.ChaptersURL).To(Equal("https://example.com/ep1/chapters.json")) + Expect(ep.ChaptersType).To(Equal("application/json+chapters")) + }) + + It("podcast:chapters — episodes without tag have empty ChaptersURL", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[1].ChaptersURL).To(BeEmpty()) + }) + + It("podcast:transcript — parses multiple transcripts per episode", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[0].Transcripts).To(HaveLen(2)) + }) + + It("podcast:transcript — parses URL, type, language, rel", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + t0 := result.Episodes[0].Transcripts[0] + Expect(t0.URL).To(Equal("https://example.com/ep1/transcript.vtt")) + Expect(t0.MimeType).To(Equal("text/vtt")) + Expect(t0.Language).To(Equal("en")) + Expect(t0.Rel).To(Equal("captions")) + }) + + It("podcast:transcript — rel is empty when omitted", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + t1 := result.Episodes[0].Transcripts[1] + Expect(t1.Rel).To(BeEmpty()) + }) + + It("podcast:soundbite — parses startTime and duration as float", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001)) + Expect(ep.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001)) + Expect(ep.SoundbiteTitle).To(Equal("Best moment")) + }) + + It("podcast:person — parses episode-level persons", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.Persons).To(HaveLen(1)) + Expect(ep.Persons[0].Name).To(Equal("John Guest")) + Expect(ep.Persons[0].Role).To(Equal("guest")) + }) + + It("podcast:person — episode person role defaults to 'host'", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[0].Persons[0].Role).To(Equal("host")) + }) + + It("podcast:person — episode person group defaults to 'cast'", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[0].Persons[0].Group).To(Equal("cast")) + }) + + It("episodes without podcast: tags have zero/empty values", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[1] + Expect(ep.Season).To(Equal(0)) + Expect(ep.ChaptersURL).To(BeEmpty()) + Expect(ep.Transcripts).To(BeEmpty()) + Expect(ep.Persons).To(BeEmpty()) + Expect(ep.SoundbiteStart).To(BeZero()) + }) + }) + + Describe("backward compatibility", func() { + It("standard RSS feed without podcast: namespace parses correctly", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Title).To(Equal("Test Podcast")) + Expect(result.PodcastGUID).To(BeEmpty()) + Expect(result.Locked).To(BeFalse()) + Expect(result.Medium).To(BeEmpty()) + Expect(result.Persons).To(BeEmpty()) + Expect(result.Episodes[0].Transcripts).To(BeEmpty()) + Expect(result.Episodes[0].Season).To(Equal(0)) + }) + }) +}) diff --git a/db/migrations/20260428000000_add_podcast20.go b/db/migrations/20260428000000_add_podcast20.go new file mode 100644 index 000000000..f8a72030f --- /dev/null +++ b/db/migrations/20260428000000_add_podcast20.go @@ -0,0 +1,76 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPodcast20, downAddPodcast20) +} + +func upAddPodcast20(ctx context.Context, tx *sql.Tx) error { + sqls := []string{ + // podcast_channel — Tier 1 + `ALTER TABLE podcast_channel ADD COLUMN podcast_guid TEXT NOT NULL DEFAULT ''`, + // podcast_channel — Tier 2 + `ALTER TABLE podcast_channel ADD COLUMN locked INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE podcast_channel ADD COLUMN locked_owner TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN medium TEXT NOT NULL DEFAULT 'podcast'`, + `ALTER TABLE podcast_channel ADD COLUMN funding_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN funding_text TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN update_frequency TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN update_rrule TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN complete INTEGER NOT NULL DEFAULT 0`, + // podcast_episode — Tier 1 + `ALTER TABLE podcast_episode ADD COLUMN season INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE podcast_episode ADD COLUMN season_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN episode_number TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN episode_display TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN chapters_url TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN chapters_type TEXT NOT NULL DEFAULT ''`, + // podcast_episode — Tier 2 + `ALTER TABLE podcast_episode ADD COLUMN soundbite_start REAL NOT NULL DEFAULT 0`, + `ALTER TABLE podcast_episode ADD COLUMN soundbite_dur REAL NOT NULL DEFAULT 0`, + `ALTER TABLE podcast_episode ADD COLUMN soundbite_title TEXT NOT NULL DEFAULT ''`, + // new tables + `CREATE TABLE podcast_transcript ( + id TEXT PRIMARY KEY, + episode_id TEXT NOT NULL REFERENCES podcast_episode(id) ON DELETE CASCADE, + url TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT '', + rel TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_transcript_episode_id ON podcast_transcript(episode_id)`, + // channel_id/episode_id have no FK constraints: the put() helper serialises empty + // strings as "" rather than NULL, which would violate a FK constraint. Cascade + // delete is handled at the application layer instead. + `CREATE TABLE podcast_person ( + id TEXT PRIMARY KEY, + channel_id TEXT, + episode_id TEXT, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'host', + group_name TEXT NOT NULL DEFAULT 'cast', + img TEXT NOT NULL DEFAULT '', + href TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_person_channel_id ON podcast_person(channel_id)`, + `CREATE INDEX podcast_person_episode_id ON podcast_person(episode_id)`, + } + for _, s := range sqls { + if _, err := tx.ExecContext(ctx, s); err != nil { + return err + } + } + return nil +} + +func downAddPodcast20(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20260428120000_add_podcast_tier3.go b/db/migrations/20260428120000_add_podcast_tier3.go new file mode 100644 index 000000000..2e71fdf68 --- /dev/null +++ b/db/migrations/20260428120000_add_podcast_tier3.go @@ -0,0 +1,59 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPodcastTier3, downAddPodcastTier3) +} + +func upAddPodcastTier3(ctx context.Context, tx *sql.Tx) error { + sqls := []string{ + // podcast:podping flag on channel + `ALTER TABLE podcast_channel ADD COLUMN uses_podping INTEGER NOT NULL DEFAULT 0`, + + // podcast:podroll — recommended feeds listed by a channel + `CREATE TABLE podcast_podroll ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + feed_guid TEXT NOT NULL DEFAULT '', + feed_url TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_podroll_channel_id ON podcast_podroll(channel_id)`, + + // podcast:liveItem — at most one active live item per channel + `CREATE TABLE podcast_live_item ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + guid TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + start_time DATETIME, + end_time DATETIME, + enclosure_url TEXT NOT NULL DEFAULT '', + enclosure_type TEXT NOT NULL DEFAULT '', + content_link_url TEXT NOT NULL DEFAULT '', + content_link_text TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`, + `CREATE UNIQUE INDEX podcast_live_item_channel_id ON podcast_live_item(channel_id)`, + } + for _, s := range sqls { + if _, err := tx.ExecContext(ctx, s); err != nil { + return err + } + } + return nil +} + +func downAddPodcastTier3(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/model/datastore.go b/model/datastore.go index dd83b0d22..245a960f7 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -44,6 +44,10 @@ type DataStore interface { ArtworkQueue(ctx context.Context) ArtworkQueueRepository PodcastChannel(ctx context.Context) PodcastChannelRepository PodcastEpisode(ctx context.Context) PodcastEpisodeRepository + PodcastTranscript(ctx context.Context) PodcastTranscriptRepository + PodcastPerson(ctx context.Context) PodcastPersonRepository + PodcastPodroll(ctx context.Context) PodcastPodrollRepository + PodcastLiveItem(ctx context.Context) PodcastLiveItemRepository Resource(ctx context.Context, model any) ResourceRepository diff --git a/model/podcast.go b/model/podcast.go index 6df3e1ccd..c8a71731e 100644 --- a/model/podcast.go +++ b/model/podcast.go @@ -2,6 +2,51 @@ package model import "time" +// PodcastPodrollItem represents one entry in a podcast:podroll recommendation list. +type PodcastPodrollItem struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId"` + FeedGUID string `structs:"feed_guid" json:"feedGuid,omitempty"` + FeedURL string `structs:"feed_url" json:"feedUrl,omitempty"` + Title string `structs:"title" json:"title,omitempty"` + SortOrder int `structs:"sort_order" json:"sortOrder"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +// PodcastLiveItem represents a podcast:liveItem stream associated with a channel. +type PodcastLiveItem struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId"` + GUID string `structs:"guid" json:"guid,omitempty"` + Title string `structs:"title" json:"title,omitempty"` + Status string `structs:"status" json:"status"` + StartTime time.Time `structs:"start_time" json:"startTime,omitempty"` + EndTime time.Time `structs:"end_time" json:"endTime,omitempty"` + EnclosureURL string `structs:"enclosure_url" json:"enclosureUrl,omitempty"` + EnclosureType string `structs:"enclosure_type" json:"enclosureType,omitempty"` + ContentLinkURL string `structs:"content_link_url" json:"contentLinkUrl,omitempty"` + ContentLinkText string `structs:"content_link_text" json:"contentLinkText,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` +} + +// PodcastPodrollItems is a slice of PodcastPodrollItem. +type PodcastPodrollItems []PodcastPodrollItem + +// PodcastPodrollRepository manages podcast:podroll entries for channels. +type PodcastPodrollRepository interface { + GetByChannel(channelID string) (PodcastPodrollItems, error) + GetByChannels(channelIDs []string) (PodcastPodrollItems, error) + SaveForChannel(channelID string, items []PodcastPodrollItem) error +} + +// PodcastLiveItemRepository manages podcast:liveItem entries (one per channel). +type PodcastLiveItemRepository interface { + GetByChannel(channelID string) (*PodcastLiveItem, error) + Upsert(item *PodcastLiveItem) error + DeleteByChannel(channelID string) error +} + type PodcastStatus string const ( @@ -14,42 +59,104 @@ const ( ) type PodcastChannel struct { - ID string `structs:"id" json:"id"` - URL string `structs:"url" json:"url"` - Title string `structs:"title" json:"title"` - Description string `structs:"description" json:"description"` - ImageURL string `structs:"image_url" json:"imageUrl"` - Status PodcastStatus `structs:"status" json:"status"` - ErrorMessage string `structs:"error_message" json:"errorMessage"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` - Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"` + ID string `structs:"id" json:"id"` + URL string `structs:"url" json:"url"` + Title string `structs:"title" json:"title"` + Description string `structs:"description" json:"description"` + ImageURL string `structs:"image_url" json:"imageUrl"` + Status PodcastStatus `structs:"status" json:"status"` + ErrorMessage string `structs:"error_message" json:"errorMessage"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + + // Podcasting 2.0 — Tier 1 + PodcastGUID string `structs:"podcast_guid" json:"podcastGuid,omitempty"` + + // Podcasting 2.0 — Tier 2 + Locked bool `structs:"locked" json:"locked,omitempty"` + LockedOwner string `structs:"locked_owner" json:"lockedOwner,omitempty"` + Medium string `structs:"medium" json:"medium,omitempty"` + FundingURL string `structs:"funding_url" json:"fundingUrl,omitempty"` + FundingText string `structs:"funding_text" json:"fundingText,omitempty"` + UpdateFrequency string `structs:"update_frequency" json:"updateFrequency,omitempty"` + UpdateRRule string `structs:"update_rrule" json:"updateRRule,omitempty"` + Complete bool `structs:"complete" json:"complete,omitempty"` + + // Podcasting 2.0 — Tier 3 + UsesPodping bool `structs:"uses_podping" json:"usesPodping,omitempty"` + Podroll PodcastPodrollItems `structs:"-" json:"podroll,omitempty"` + LiveItem *PodcastLiveItem `structs:"-" json:"liveItem,omitempty"` + + // loaded separately + Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"` + Persons PodcastPersons `structs:"-" json:"persons,omitempty"` } type PodcastEpisode struct { - ID string `structs:"id" json:"id"` - ChannelID string `structs:"channel_id" json:"channelId"` - StreamID string `structs:"stream_id" json:"streamId"` - GUID string `structs:"guid" json:"guid"` - Title string `structs:"title" json:"title"` - Description string `structs:"description" json:"description"` - PublishDate time.Time `structs:"publish_date" json:"publishDate"` - Duration int `structs:"duration" json:"duration"` - Size int64 `structs:"size" json:"size"` - BitRate int `structs:"bit_rate" json:"bitRate"` - Suffix string `structs:"suffix" json:"suffix"` - ContentType string `structs:"content_type" json:"contentType"` + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId"` + StreamID string `structs:"stream_id" json:"streamId"` + GUID string `structs:"guid" json:"guid"` + Title string `structs:"title" json:"title"` + Description string `structs:"description" json:"description"` + PublishDate time.Time `structs:"publish_date" json:"publishDate"` + Duration int `structs:"duration" json:"duration"` + Size int64 `structs:"size" json:"size"` + BitRate int `structs:"bit_rate" json:"bitRate"` + Suffix string `structs:"suffix" json:"suffix"` + ContentType string `structs:"content_type" json:"contentType"` Path string `structs:"path" json:"path"` EnclosureURL string `structs:"enclosure_url" json:"enclosureUrl"` DownloadedBytes int64 `structs:"downloaded_bytes" json:"downloadedBytes"` - Status PodcastStatus `structs:"status" json:"status"` - ErrorMessage string `structs:"error_message" json:"errorMessage"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + Status PodcastStatus `structs:"status" json:"status"` + ErrorMessage string `structs:"error_message" json:"errorMessage"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + + // Podcasting 2.0 — Tier 1 + Season int `structs:"season" json:"season,omitempty"` + SeasonName string `structs:"season_name" json:"seasonName,omitempty"` + EpisodeNumber string `structs:"episode_number" json:"episodeNumber,omitempty"` + EpisodeDisplay string `structs:"episode_display" json:"episodeDisplay,omitempty"` + ChaptersURL string `structs:"chapters_url" json:"chaptersUrl,omitempty"` + ChaptersType string `structs:"chapters_type" json:"chaptersType,omitempty"` + + // Podcasting 2.0 — Tier 2 + SoundbiteStart float64 `structs:"soundbite_start" json:"soundbiteStart,omitempty"` + SoundbiteDur float64 `structs:"soundbite_dur" json:"soundbiteDur,omitempty"` + SoundbiteTitle string `structs:"soundbite_title" json:"soundbiteTitle,omitempty"` + + // loaded separately + Transcripts PodcastTranscripts `structs:"-" json:"transcripts,omitempty"` + Persons PodcastPersons `structs:"-" json:"persons,omitempty"` } -type PodcastChannels []PodcastChannel -type PodcastEpisodes []PodcastEpisode +type PodcastTranscript struct { + ID string `structs:"id" json:"id"` + EpisodeID string `structs:"episode_id" json:"episodeId"` + URL string `structs:"url" json:"url"` + MimeType string `structs:"mime_type" json:"type"` + Language string `structs:"language" json:"language,omitempty"` + Rel string `structs:"rel" json:"rel,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +type PodcastPerson struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId,omitempty"` + EpisodeID string `structs:"episode_id" json:"episodeId,omitempty"` + Name string `structs:"name" json:"name"` + Role string `structs:"role" json:"role,omitempty"` + Group string `structs:"group_name" db:"group_name" json:"group,omitempty"` + Img string `structs:"img" json:"img,omitempty"` + Href string `structs:"href" json:"href,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +type PodcastChannels []PodcastChannel +type PodcastEpisodes []PodcastEpisode +type PodcastTranscripts []PodcastTranscript +type PodcastPersons []PodcastPerson type PodcastChannelRepository interface { Get(id string) (*PodcastChannel, error) @@ -70,3 +177,18 @@ type PodcastEpisodeRepository interface { Update(ep *PodcastEpisode) error Delete(id string) error } + +type PodcastTranscriptRepository interface { + GetByEpisode(episodeID string) (PodcastTranscripts, error) + GetByEpisodes(episodeIDs []string) (PodcastTranscripts, error) + Save(transcripts []PodcastTranscript) error + DeleteByEpisode(episodeID string) error +} + +type PodcastPersonRepository interface { + GetByChannel(channelID string) (PodcastPersons, error) + GetByEpisode(episodeID string) (PodcastPersons, error) + GetByEpisodes(episodeIDs []string) (PodcastPersons, error) + SaveForChannel(channelID string, persons []PodcastPerson) error + SaveForEpisode(episodeID string, persons []PodcastPerson) error +} diff --git a/persistence/persistence.go b/persistence/persistence.go index 2264b973d..0f0f1c90e 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -113,6 +113,22 @@ func (s *SQLStore) PodcastEpisode(ctx context.Context) model.PodcastEpisodeRepos return NewPodcastEpisodeRepository(ctx, s.getDBXBuilder()) } +func (s *SQLStore) PodcastTranscript(ctx context.Context) model.PodcastTranscriptRepository { + return NewPodcastTranscriptRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastPerson(ctx context.Context) model.PodcastPersonRepository { + return NewPodcastPersonRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastPodroll(ctx context.Context) model.PodcastPodrollRepository { + return NewPodcastPodrollRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveItemRepository { + return NewPodcastLiveItemRepository(ctx, s.getDBXBuilder()) +} + func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: diff --git a/persistence/podcast_live_item_repository.go b/persistence/podcast_live_item_repository.go new file mode 100644 index 000000000..5d543f23e --- /dev/null +++ b/persistence/podcast_live_item_repository.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastLiveItemRepository struct { + sqlRepository +} + +func NewPodcastLiveItemRepository(ctx context.Context, db dbx.Builder) model.PodcastLiveItemRepository { + r := &podcastLiveItemRepository{} + r.ctx = ctx + r.db = db + // Must set tableName before registerModel to avoid auto-derived name mismatch. + r.tableName = "podcast_live_item" + r.registerModel(&model.PodcastLiveItem{}, nil) + return r +} + +func (r *podcastLiveItemRepository) GetByChannel(channelID string) (*model.PodcastLiveItem, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}) + item := model.PodcastLiveItem{} + if err := r.queryOne(sel, &item); err != nil { + return nil, err + } + return &item, nil +} + +func (r *podcastLiveItemRepository) Upsert(item *model.PodcastLiveItem) error { + existing, err := r.GetByChannel(item.ChannelID) + if err == model.ErrNotFound { + item.ID = id.NewRandom() + item.CreatedAt = time.Now() + item.UpdatedAt = time.Now() + _, err = r.put(item.ID, item) + return err + } + if err != nil { + return err + } + item.ID = existing.ID + item.CreatedAt = existing.CreatedAt + item.UpdatedAt = time.Now() + _, err = r.put(item.ID, item) + return err +} + +func (r *podcastLiveItemRepository) DeleteByChannel(channelID string) error { + return r.delete(Eq{"channel_id": channelID}) +} + +var _ model.PodcastLiveItemRepository = (*podcastLiveItemRepository)(nil) diff --git a/persistence/podcast_live_item_repository_test.go b/persistence/podcast_live_item_repository_test.go new file mode 100644 index 000000000..c517269a7 --- /dev/null +++ b/persistence/podcast_live_item_repository_test.go @@ -0,0 +1,150 @@ +package persistence + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastLiveItemRepository", func() { + var repo model.PodcastLiveItemRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastLiveItemRepository(ctx, GetDBXBuilder()) + }) + + Describe("Upsert and GetByChannel", func() { + It("creates a new live item when none exists", func() { + item := &model.PodcastLiveItem{ + ChannelID: "pc-1", + GUID: "live-guid-001", + Title: "Live Show", + Status: "live", + StartTime: time.Date(2024, 4, 27, 8, 0, 0, 0, time.UTC), + EndTime: time.Date(2024, 4, 27, 9, 0, 0, 0, time.UTC), + EnclosureURL: "https://stream.example.com/live.m3u8", + EnclosureType: "application/x-mpegURL", + ContentLinkURL: "https://youtube.com/live", + ContentLinkText: "Watch Live", + } + Expect(repo.Upsert(item)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.ChannelID).To(Equal("pc-1")) + Expect(result.GUID).To(Equal("live-guid-001")) + Expect(result.Title).To(Equal("Live Show")) + Expect(result.Status).To(Equal("live")) + Expect(result.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8")) + Expect(result.ContentLinkURL).To(Equal("https://youtube.com/live")) + Expect(result.ContentLinkText).To(Equal("Watch Live")) + + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + }) + + It("assigns ID and timestamps automatically on create", func() { + item := &model.PodcastLiveItem{ + ChannelID: "pc-1", + Status: "live", + } + Expect(repo.Upsert(item)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result.ID).ToNot(BeEmpty()) + Expect(result.CreatedAt.IsZero()).To(BeFalse()) + Expect(result.UpdatedAt.IsZero()).To(BeFalse()) + + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + }) + + It("updates existing live item (latest wins)", func() { + first := &model.PodcastLiveItem{ + ChannelID: "pc-1", + GUID: "live-guid-001", + Title: "Original Title", + Status: "pending", + } + Expect(repo.Upsert(first)).To(Succeed()) + + second := &model.PodcastLiveItem{ + ChannelID: "pc-1", + GUID: "live-guid-001", + Title: "Updated Title", + Status: "live", + } + Expect(repo.Upsert(second)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result.Title).To(Equal("Updated Title")) + Expect(result.Status).To(Equal("live")) + + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + }) + + It("preserves created_at on update", func() { + item := &model.PodcastLiveItem{ + ChannelID: "pc-1", + Status: "pending", + } + Expect(repo.Upsert(item)).To(Succeed()) + + original, _ := repo.GetByChannel("pc-1") + originalCreatedAt := original.CreatedAt + + item2 := &model.PodcastLiveItem{ + ChannelID: "pc-1", + Status: "live", + } + Expect(repo.Upsert(item2)).To(Succeed()) + + updated, _ := repo.GetByChannel("pc-1") + Expect(updated.CreatedAt.UTC().Truncate(time.Second)). + To(Equal(originalCreatedAt.UTC().Truncate(time.Second))) + + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + }) + + It("returns ErrNotFound for unknown channel", func() { + _, err := repo.GetByChannel("no-such-channel") + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("handles zero-value start/end times", func() { + item := &model.PodcastLiveItem{ + ChannelID: "pc-1", + Status: "live", + } + Expect(repo.Upsert(item)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + }) + }) + + Describe("DeleteByChannel", func() { + It("removes live item for the given channel", func() { + Expect(repo.Upsert(&model.PodcastLiveItem{ChannelID: "pc-1", Status: "live"})).To(Succeed()) + Expect(repo.DeleteByChannel("pc-1")).To(Succeed()) + + _, err := repo.GetByChannel("pc-1") + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("does not error when no item exists", func() { + Expect(repo.DeleteByChannel("no-such-channel")).To(Succeed()) + }) + }) +}) diff --git a/persistence/podcast_person_repository.go b/persistence/podcast_person_repository.go new file mode 100644 index 000000000..6d378160c --- /dev/null +++ b/persistence/podcast_person_repository.go @@ -0,0 +1,83 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastPersonRepository struct { + sqlRepository +} + +func NewPodcastPersonRepository(ctx context.Context, db dbx.Builder) model.PodcastPersonRepository { + r := &podcastPersonRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.PodcastPerson{}, nil) + return r +} + +func (r *podcastPersonRepository) GetByChannel(channelID string) (model.PodcastPersons, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}) + var result model.PodcastPersons + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastPersonRepository) GetByEpisode(episodeID string) (model.PodcastPersons, error) { + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeID}) + var result model.PodcastPersons + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastPersonRepository) GetByEpisodes(episodeIDs []string) (model.PodcastPersons, error) { + if len(episodeIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeIDs}) + var result model.PodcastPersons + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastPersonRepository) SaveForChannel(channelID string, persons []model.PodcastPerson) error { + if err := r.delete(Eq{"channel_id": channelID}); err != nil { + return err + } + now := time.Now() + for i := range persons { + persons[i].ID = id.NewRandom() + persons[i].ChannelID = channelID + persons[i].EpisodeID = "" + persons[i].CreatedAt = now + if _, err := r.put(persons[i].ID, &persons[i]); err != nil { + return err + } + } + return nil +} + +func (r *podcastPersonRepository) SaveForEpisode(episodeID string, persons []model.PodcastPerson) error { + if err := r.delete(Eq{"episode_id": episodeID}); err != nil { + return err + } + now := time.Now() + for i := range persons { + persons[i].ID = id.NewRandom() + persons[i].EpisodeID = episodeID + persons[i].ChannelID = "" + persons[i].CreatedAt = now + if _, err := r.put(persons[i].ID, &persons[i]); err != nil { + return err + } + } + return nil +} + +var _ model.PodcastPersonRepository = (*podcastPersonRepository)(nil) diff --git a/persistence/podcast_person_repository_test.go b/persistence/podcast_person_repository_test.go new file mode 100644 index 000000000..25ea44a36 --- /dev/null +++ b/persistence/podcast_person_repository_test.go @@ -0,0 +1,172 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastPersonRepository", func() { + var repo model.PodcastPersonRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastPersonRepository(ctx, GetDBXBuilder()) + }) + + Describe("SaveForChannel and GetByChannel", func() { + It("saves and retrieves channel-level persons", func() { + persons := []model.PodcastPerson{ + {Name: "Jane Host", Role: "host", Group: "cast", Img: "https://example.com/jane.jpg", Href: "https://example.com/jane"}, + {Name: "Bob Producer", Role: "producer", Group: "crew"}, + } + Expect(repo.SaveForChannel("pc-1", persons)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + var names []string + for _, p := range result { + names = append(names, p.Name) + } + Expect(names).To(ConsistOf("Jane Host", "Bob Producer")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("persists all person fields", func() { + persons := []model.PodcastPerson{ + {Name: "Jane Host", Role: "host", Group: "cast", Img: "https://example.com/jane.jpg", Href: "https://example.com/jane"}, + } + Expect(repo.SaveForChannel("pc-1", persons)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].Name).To(Equal("Jane Host")) + Expect(result[0].Role).To(Equal("host")) + Expect(result[0].Group).To(Equal("cast")) + Expect(result[0].Img).To(Equal("https://example.com/jane.jpg")) + Expect(result[0].Href).To(Equal("https://example.com/jane")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("replaces existing persons on re-save", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Old Host", Role: "host", Group: "cast"}})).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "New Host", Role: "host", Group: "cast"}})).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("New Host")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("clears persons when saved with nil slice", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Jane Host", Role: "host", Group: "cast"}})).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("does not affect persons of other channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Host A", Role: "host", Group: "cast"}})).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastPerson{{Name: "Host B", Role: "host", Group: "cast"}})).To(Succeed()) + + resultA, _ := repo.GetByChannel("pc-1") + resultB, _ := repo.GetByChannel("pc-2") + Expect(resultA).To(HaveLen(1)) + Expect(resultB).To(HaveLen(1)) + Expect(resultA[0].Name).To(Equal("Host A")) + Expect(resultB[0].Name).To(Equal("Host B")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returns empty list for unknown channel", func() { + result, err := repo.GetByChannel("no-such-channel") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("SaveForEpisode and GetByEpisode", func() { + It("saves and retrieves episode-level persons", func() { + persons := []model.PodcastPerson{ + {Name: "John Guest", Role: "guest", Group: "cast"}, + } + Expect(repo.SaveForEpisode("pe-1", persons)).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("John Guest")) + Expect(result[0].Role).To(Equal("guest")) + + Expect(repo.SaveForEpisode("pe-1", nil)).To(Succeed()) + }) + + It("replaces existing episode persons on re-save", func() { + Expect(repo.SaveForEpisode("pe-1", []model.PodcastPerson{{Name: "Old Guest", Role: "guest", Group: "cast"}})).To(Succeed()) + Expect(repo.SaveForEpisode("pe-1", []model.PodcastPerson{{Name: "New Guest", Role: "guest", Group: "cast"}})).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Name).To(Equal("New Guest")) + + Expect(repo.SaveForEpisode("pe-1", nil)).To(Succeed()) + }) + + It("returns empty list for unknown episode", func() { + result, err := repo.GetByEpisode("no-such-episode") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("GetByEpisodes — bulk query", func() { + It("returns persons for multiple episodes in one query", func() { + Expect(repo.SaveForEpisode("pe-1", []model.PodcastPerson{{Name: "Guest A", Role: "guest", Group: "cast"}})).To(Succeed()) + Expect(repo.SaveForEpisode("pe-2", []model.PodcastPerson{{Name: "Guest B", Role: "guest", Group: "cast"}})).To(Succeed()) + + result, err := repo.GetByEpisodes([]string{"pe-1", "pe-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + names := []string{result[0].Name, result[1].Name} + Expect(names).To(ConsistOf("Guest A", "Guest B")) + + Expect(repo.SaveForEpisode("pe-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("pe-2", nil)).To(Succeed()) + }) + + It("returns empty list for empty id slice", func() { + result, err := repo.GetByEpisodes([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("auto ID generation", func() { + It("assigns an ID automatically on SaveForChannel", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Jane", Role: "host", Group: "cast"}})).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + }) +}) diff --git a/persistence/podcast_podroll_repository.go b/persistence/podcast_podroll_repository.go new file mode 100644 index 000000000..24cd75640 --- /dev/null +++ b/persistence/podcast_podroll_repository.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastPodrollRepository struct { + sqlRepository +} + +func NewPodcastPodrollRepository(ctx context.Context, db dbx.Builder) model.PodcastPodrollRepository { + r := &podcastPodrollRepository{} + r.ctx = ctx + r.db = db + // Must set tableName before registerModel to avoid auto-derived name mismatch. + r.tableName = "podcast_podroll" + r.registerModel(&model.PodcastPodrollItem{}, nil) + return r +} + +func (r *podcastPodrollRepository) GetByChannel(channelID string) (model.PodcastPodrollItems, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}).OrderBy("sort_order") + var result model.PodcastPodrollItems + return result, r.queryAll(sel, &result) +} + +func (r *podcastPodrollRepository) GetByChannels(channelIDs []string) (model.PodcastPodrollItems, error) { + if len(channelIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}).OrderBy("channel_id, sort_order") + var result model.PodcastPodrollItems + return result, r.queryAll(sel, &result) +} + +func (r *podcastPodrollRepository) SaveForChannel(channelID string, items []model.PodcastPodrollItem) error { + if err := r.delete(Eq{"channel_id": channelID}); err != nil { + return err + } + now := time.Now() + for i := range items { + items[i].ID = id.NewRandom() + items[i].ChannelID = channelID + items[i].SortOrder = i + items[i].CreatedAt = now + if _, err := r.put(items[i].ID, &items[i]); err != nil { + return err + } + } + return nil +} + +var _ model.PodcastPodrollRepository = (*podcastPodrollRepository)(nil) diff --git a/persistence/podcast_podroll_repository_test.go b/persistence/podcast_podroll_repository_test.go new file mode 100644 index 000000000..fa5e0c90d --- /dev/null +++ b/persistence/podcast_podroll_repository_test.go @@ -0,0 +1,145 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastPodrollRepository", func() { + var repo model.PodcastPodrollRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastPodrollRepository(ctx, GetDBXBuilder()) + }) + + Describe("SaveForChannel and GetByChannel", func() { + It("saves and retrieves podroll items", func() { + items := []model.PodcastPodrollItem{ + {FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"}, + {FeedGUID: "guid-b", FeedURL: "https://b.example.com/feed.xml", Title: "Show B"}, + } + Expect(repo.SaveForChannel("pc-1", items)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("preserves sort_order in insertion order", func() { + items := []model.PodcastPodrollItem{ + {FeedURL: "https://first.example.com/feed.xml", Title: "First"}, + {FeedURL: "https://second.example.com/feed.xml", Title: "Second"}, + {FeedURL: "https://third.example.com/feed.xml", Title: "Third"}, + } + Expect(repo.SaveForChannel("pc-1", items)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + Expect(result[0].Title).To(Equal("First")) + Expect(result[1].Title).To(Equal("Second")) + Expect(result[2].Title).To(Equal("Third")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("replaces existing items on re-save", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://old.example.com/feed.xml", Title: "Old Show"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://new.example.com/feed.xml", Title: "New Show"}, + })).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Title).To(Equal("New Show")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("clears items when saved with nil slice", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://example.com/feed.xml", Title: "Some Show"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("does not affect items of other channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://a.example.com/feed.xml", Title: "Channel A Feed"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastPodrollItem{ + {FeedURL: "https://b.example.com/feed.xml", Title: "Channel B Feed"}, + })).To(Succeed()) + + resultA, _ := repo.GetByChannel("pc-1") + resultB, _ := repo.GetByChannel("pc-2") + Expect(resultA).To(HaveLen(1)) + Expect(resultB).To(HaveLen(1)) + Expect(resultA[0].Title).To(Equal("Channel A Feed")) + Expect(resultB[0].Title).To(Equal("Channel B Feed")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returns empty list for unknown channel", func() { + result, err := repo.GetByChannel("no-such-channel") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("assigns ID automatically", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://example.com/feed.xml"}, + })).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + }) + + Describe("GetByChannels — bulk query", func() { + It("returns items for multiple channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{ + {FeedURL: "https://a.example.com/feed.xml", Title: "Feed A"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastPodrollItem{ + {FeedURL: "https://b.example.com/feed.xml", Title: "Feed B"}, + })).To(Succeed()) + + result, err := repo.GetByChannels([]string{"pc-1", "pc-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + titles := []string{result[0].Title, result[1].Title} + Expect(titles).To(ConsistOf("Feed A", "Feed B")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByChannels([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) +}) diff --git a/persistence/podcast_transcript_repository.go b/persistence/podcast_transcript_repository.go new file mode 100644 index 000000000..a96d7e3c1 --- /dev/null +++ b/persistence/podcast_transcript_repository.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastTranscriptRepository struct { + sqlRepository +} + +func NewPodcastTranscriptRepository(ctx context.Context, db dbx.Builder) model.PodcastTranscriptRepository { + r := &podcastTranscriptRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.PodcastTranscript{}, nil) + return r +} + +func (r *podcastTranscriptRepository) GetByEpisode(episodeID string) (model.PodcastTranscripts, error) { + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeID}) + var result model.PodcastTranscripts + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastTranscriptRepository) GetByEpisodes(episodeIDs []string) (model.PodcastTranscripts, error) { + if len(episodeIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeIDs}) + var result model.PodcastTranscripts + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastTranscriptRepository) Save(transcripts []model.PodcastTranscript) error { + for i := range transcripts { + if transcripts[i].ID == "" { + transcripts[i].ID = id.NewRandom() + } + transcripts[i].CreatedAt = time.Now() + if _, err := r.put(transcripts[i].ID, &transcripts[i]); err != nil { + return err + } + } + return nil +} + +func (r *podcastTranscriptRepository) DeleteByEpisode(episodeID string) error { + return r.delete(Eq{"episode_id": episodeID}) +} + +var _ model.PodcastTranscriptRepository = (*podcastTranscriptRepository)(nil) diff --git a/persistence/podcast_transcript_repository_test.go b/persistence/podcast_transcript_repository_test.go new file mode 100644 index 000000000..d7ad0111c --- /dev/null +++ b/persistence/podcast_transcript_repository_test.go @@ -0,0 +1,144 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastTranscriptRepository", func() { + var repo model.PodcastTranscriptRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastTranscriptRepository(ctx, GetDBXBuilder()) + }) + + Describe("Save and GetByEpisode", func() { + It("returns saved transcript by episode id", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/t.vtt")) + Expect(result[0].MimeType).To(Equal("text/vtt")) + Expect(result[0].Language).To(Equal("en")) + Expect(result[0].Rel).To(Equal("captions")) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + }) + + It("saves multiple transcripts for one episode", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"}, + {EpisodeID: "pe-1", URL: "https://example.com/t.srt", MimeType: "application/x-subrip", Language: "en"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + var mimeTypes []string + for _, t := range result { + mimeTypes = append(mimeTypes, t.MimeType) + } + Expect(mimeTypes).To(ConsistOf("text/vtt", "application/x-subrip")) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + }) + + It("stores empty rel when rel attribute is omitted", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/t.txt", MimeType: "text/plain"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].Rel).To(BeEmpty()) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + }) + + It("returns empty list for unknown episode", func() { + result, err := repo.GetByEpisode("no-such-episode") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("GetByEpisodes — bulk query", func() { + It("returns transcripts for multiple episodes in one query", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/t1.vtt", MimeType: "text/vtt"}, + {EpisodeID: "pe-2", URL: "https://example.com/t2.srt", MimeType: "application/x-subrip"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + result, err := repo.GetByEpisodes([]string{"pe-1", "pe-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + episodeIDs := []string{result[0].EpisodeID, result[1].EpisodeID} + Expect(episodeIDs).To(ConsistOf("pe-1", "pe-2")) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + Expect(repo.DeleteByEpisode("pe-2")).To(Succeed()) + }) + + It("returns empty list for empty id slice", func() { + result, err := repo.GetByEpisodes([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("DeleteByEpisode", func() { + It("deletes only transcripts for the specified episode", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/t1.vtt", MimeType: "text/vtt"}, + {EpisodeID: "pe-2", URL: "https://example.com/t2.vtt", MimeType: "text/vtt"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + + result1, _ := repo.GetByEpisode("pe-1") + Expect(result1).To(BeEmpty()) + + result2, _ := repo.GetByEpisode("pe-2") + Expect(result2).To(HaveLen(1)) + + Expect(repo.DeleteByEpisode("pe-2")).To(Succeed()) + }) + + It("succeeds when deleting transcripts for a non-existent episode", func() { + Expect(repo.DeleteByEpisode("no-such-episode")).To(Succeed()) + }) + }) + + Describe("Save — auto ID generation", func() { + It("assigns an ID automatically when none is provided", func() { + transcripts := []model.PodcastTranscript{ + {EpisodeID: "pe-1", URL: "https://example.com/auto.vtt", MimeType: "text/vtt"}, + } + Expect(repo.Save(transcripts)).To(Succeed()) + + result, err := repo.GetByEpisode("pe-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.DeleteByEpisode("pe-1")).To(Succeed()) + }) + }) +}) diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index fee17aac6..bc2144e8f 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -17,6 +17,15 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "playbackReport", Versions: []int32{1}}, {Name: "topSongsByArtistId", Versions: []int32{1}}, {Name: "getPodcastEpisode", Versions: []int32{1}}, + {Name: "podcastChapters", Versions: []int32{1}}, + {Name: "podcastTranscripts", Versions: []int32{1}}, + {Name: "podcastSeason", Versions: []int32{1}}, + {Name: "podcastPerson", Versions: []int32{1}}, + {Name: "podcastFunding", Versions: []int32{1}}, + {Name: "podcastMedium", Versions: []int32{1}}, + {Name: "podcastPodroll", Versions: []int32{1}}, + {Name: "podcastLiveItem", Versions: []int32{1}}, + {Name: "podcastPodping", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 96aa39701..f6ea32d59 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,7 +44,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) - It("should return the base 8 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 15 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication @@ -55,7 +55,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(8), + HaveLen(15), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), @@ -64,6 +64,15 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastChapters", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastTranscripts", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastSeason", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPerson", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastFunding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastMedium", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodroll", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastLiveItem", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodping", Versions: []int32{1}}), )) Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), @@ -77,7 +86,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) }) - It("should return 9 extensions including sonicSimilarity and getPodcastEpisode", func() { + It("should return 16 extensions including all podcast 2.0 and sonicSimilarity", func() { router.ServeHTTP(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -87,7 +96,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(9), + HaveLen(16), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), @@ -96,6 +105,15 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastChapters", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastTranscripts", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastSeason", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPerson", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastFunding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastMedium", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodroll", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastLiveItem", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodping", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), )) }) diff --git a/server/subsonic/podcasts.go b/server/subsonic/podcasts.go index 447223050..08bb1f6d5 100644 --- a/server/subsonic/podcasts.go +++ b/server/subsonic/podcasts.go @@ -42,6 +42,68 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { channels[0].Episodes = eps } + // Collect channel IDs for bulk queries. + channelIDs := make([]string, len(channels)) + for i, ch := range channels { + channelIDs[i] = ch.ID + } + + // Load channel persons + personRepo := api.ds.PodcastPerson(ctx) + channelPersons := make(map[string]model.PodcastPersons) + for _, ch := range channels { + persons, err := personRepo.GetByChannel(ch.ID) + if err == nil { + channelPersons[ch.ID] = persons + } + } + + // Bulk-load podcast:podroll items + podrollRepo := api.ds.PodcastPodroll(ctx) + allPodrolls, _ := podrollRepo.GetByChannels(channelIDs) + podrollMap := make(map[string]model.PodcastPodrollItems) + for _, pr := range allPodrolls { + podrollMap[pr.ChannelID] = append(podrollMap[pr.ChannelID], pr) + } + + // Load podcast:liveItem per channel + liveItemRepo := api.ds.PodcastLiveItem(ctx) + liveItemMap := make(map[string]*model.PodcastLiveItem) + for _, chID := range channelIDs { + if li, err := liveItemRepo.GetByChannel(chID); err == nil { + liveItemMap[chID] = li + } + } + + // Bulk-load episode transcripts and persons when including episodes + var epTranscripts map[string]model.PodcastTranscripts + var epPersons map[string]model.PodcastPersons + if includeEpisodes { + var epIDs []string + for _, ch := range channels { + for _, ep := range ch.Episodes { + epIDs = append(epIDs, ep.ID) + } + } + if len(epIDs) > 0 { + transcriptRepo := api.ds.PodcastTranscript(ctx) + allTranscripts, err := transcriptRepo.GetByEpisodes(epIDs) + if err == nil { + epTranscripts = make(map[string]model.PodcastTranscripts) + for _, t := range allTranscripts { + epTranscripts[t.EpisodeID] = append(epTranscripts[t.EpisodeID], t) + } + } + allPersons, err := personRepo.GetByEpisodes(epIDs) + if err == nil { + epPersons = make(map[string]model.PodcastPersons) + for _, p := range allPersons { + epPersons[p.EpisodeID] = append(epPersons[p.EpisodeID], p) + } + } + } + } + resp := newResponse() resp.Podcasts = &responses.Podcasts{} for _, ch := range channels { @@ -53,9 +115,55 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { OriginalImageUrl: ch.ImageURL, Status: string(ch.Status), ErrorMessage: ch.ErrorMessage, + // Podcasting 2.0 Tier 1 & 2 + PodcastGuid: ch.PodcastGUID, + Locked: ch.Locked, + Medium: ch.Medium, + FundingUrl: ch.FundingURL, + FundingText: ch.FundingText, + UpdateFrequency: ch.UpdateFrequency, + Complete: ch.Complete, + // Podcasting 2.0 Tier 3 + UsesPodping: ch.UsesPodping, + } + for _, p := range channelPersons[ch.ID] { + rch.Person = append(rch.Person, responses.PodcastPersonResp{ + Name: p.Name, + Role: p.Role, + Group: p.Group, + Img: p.Img, + Href: p.Href, + }) + } + for _, pr := range podrollMap[ch.ID] { + rch.Podroll = append(rch.Podroll, responses.PodcastPodrollResp{ + FeedGUID: pr.FeedGUID, + FeedURL: pr.FeedURL, + Title: pr.Title, + }) + } + if li := liveItemMap[ch.ID]; li != nil { + liveResp := &responses.PodcastLiveItemResp{ + Status: li.Status, + Title: li.Title, + GUID: li.GUID, + EnclosureURL: li.EnclosureURL, + EnclosureType: li.EnclosureType, + ContentLinkURL: li.ContentLinkURL, + ContentLinkText: li.ContentLinkText, + } + if !li.StartTime.IsZero() { + liveResp.StartTime = li.StartTime.UTC().Format(time.RFC3339) + } + if !li.EndTime.IsZero() { + liveResp.EndTime = li.EndTime.UTC().Format(time.RFC3339) + } + rch.LiveItem = liveResp } if includeEpisodes { for _, ep := range ch.Episodes { + ep.Transcripts = epTranscripts[ep.ID] + ep.Persons = epPersons[ep.ID] rch.Episode = append(rch.Episode, buildPodcastEpisode(ep)) } } @@ -175,10 +283,15 @@ func (api *Router) GetPodcastEpisode(r *http.Request) (*responses.Subsonic, erro if err != nil { return nil, err } - ep, err := api.ds.PodcastEpisode(r.Context()).Get(id) + ctx := r.Context() + ep, err := api.ds.PodcastEpisode(ctx).Get(id) if err != nil { return nil, err } + + ep.Transcripts, _ = api.ds.PodcastTranscript(ctx).GetByEpisode(ep.ID) + ep.Persons, _ = api.ds.PodcastPerson(ctx).GetByEpisode(ep.ID) + resp := newResponse() re := buildPodcastEpisode(*ep) resp.PodcastEpisode = &re @@ -200,10 +313,35 @@ func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode { ContentType: ep.ContentType, BitRate: ep.BitRate, DownloadedBytes: ep.DownloadedBytes, + // Podcasting 2.0 + Season: ep.Season, + SeasonName: ep.SeasonName, + EpisodeNumber: ep.EpisodeNumber, + EpisodeDisplay: ep.EpisodeDisplay, + ChaptersUrl: ep.ChaptersURL, + SoundbiteStart: ep.SoundbiteStart, + SoundbiteDur: ep.SoundbiteDur, } if !ep.PublishDate.IsZero() { re.PublishDate = ep.PublishDate.UTC().Format(time.RFC3339) } + for _, t := range ep.Transcripts { + re.Transcript = append(re.Transcript, responses.PodcastTranscriptResp{ + URL: t.URL, + Type: t.MimeType, + Language: t.Language, + Rel: t.Rel, + }) + } + for _, p := range ep.Persons { + re.Person = append(re.Person, responses.PodcastPersonResp{ + Name: p.Name, + Role: p.Role, + Group: p.Group, + Img: p.Img, + Href: p.Href, + }) + } return re } diff --git a/server/subsonic/podcasts_test.go b/server/subsonic/podcasts_test.go index 93afe727c..17aaeba3c 100644 --- a/server/subsonic/podcasts_test.go +++ b/server/subsonic/podcasts_test.go @@ -299,4 +299,290 @@ var _ = Describe("Podcasts", func() { Expect(err).ToNot(HaveOccurred()) }) }) + + Describe("GetPodcasts — Podcasting 2.0 channel fields", func() { + BeforeEach(func() { + channelRepo.Data = map[string]*model.PodcastChannel{ + "ch-p20": { + ID: "ch-p20", + Title: "P2.0 Podcast", + Status: model.PodcastStatusCompleted, + PodcastGUID: "917393e3-1b1e-5cef-ace4-edaa54e1f810", + Locked: true, + Medium: "podcast", + FundingURL: "https://example.com/donate", + FundingText: "Support us!", + UpdateFrequency: "Weekly", + UpdateRRule: "FREQ=WEEKLY", + Complete: false, + }, + } + }) + + It("includes podcastGuid in response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].PodcastGuid).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810")) + }) + + It("includes locked flag in response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].Locked).To(BeTrue()) + }) + + It("includes medium in response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].Medium).To(Equal("podcast")) + }) + + It("includes fundingUrl and fundingText in response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + ch := resp.Podcasts.Channel[0] + Expect(ch.FundingUrl).To(Equal("https://example.com/donate")) + Expect(ch.FundingText).To(Equal("Support us!")) + }) + + It("includes updateFrequency in response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].UpdateFrequency).To(Equal("Weekly")) + }) + + It("includes channel person list from PersonRepo", func() { + personRepo := tests.CreateMockPodcastPersonRepo() + _ = personRepo.SaveForChannel("ch-p20", []model.PodcastPerson{ + {Name: "Jane Host", Role: "host", Group: "cast"}, + }) + ds.MockedPodcastPerson = personRepo + + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].Person).To(HaveLen(1)) + Expect(resp.Podcasts.Channel[0].Person[0].Name).To(Equal("Jane Host")) + Expect(resp.Podcasts.Channel[0].Person[0].Role).To(Equal("host")) + }) + }) + + Describe("GetPodcastEpisode — Podcasting 2.0 episode fields", func() { + BeforeEach(func() { + episodeRepo.Data["ep-p20"] = &model.PodcastEpisode{ + ID: "ep-p20", + Title: "P2.0 Episode", + Status: model.PodcastStatusCompleted, + Season: 2, + SeasonName: "Season Two", + EpisodeNumber: "5", + EpisodeDisplay: "Ep.5", + ChaptersURL: "https://example.com/chapters.json", + ChaptersType: "application/json+chapters", + SoundbiteStart: 73.5, + SoundbiteDur: 60.0, + SoundbiteTitle: "Best moment", + } + }) + + It("includes season number and name in response", func() { + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.Season).To(Equal(2)) + Expect(resp.PodcastEpisode.SeasonName).To(Equal("Season Two")) + }) + + It("includes episode number and display label in response", func() { + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.EpisodeNumber).To(Equal("5")) + Expect(resp.PodcastEpisode.EpisodeDisplay).To(Equal("Ep.5")) + }) + + It("includes chaptersUrl in response", func() { + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.ChaptersUrl).To(Equal("https://example.com/chapters.json")) + }) + + It("includes soundbite fields in response", func() { + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001)) + Expect(resp.PodcastEpisode.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001)) + }) + + It("includes transcript array from TranscriptRepo in response", func() { + transcriptRepo := tests.CreateMockPodcastTranscriptRepo() + _ = transcriptRepo.Save([]model.PodcastTranscript{ + {EpisodeID: "ep-p20", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"}, + {EpisodeID: "ep-p20", URL: "https://example.com/t.srt", MimeType: "application/x-subrip", Language: "en"}, + }) + ds.MockedPodcastTranscript = transcriptRepo + + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.Transcript).To(HaveLen(2)) + // map iteration order is non-deterministic; use ConsistOf for order-independent check + types := []string{ + resp.PodcastEpisode.Transcript[0].Type, + resp.PodcastEpisode.Transcript[1].Type, + } + Expect(types).To(ConsistOf("text/vtt", "application/x-subrip")) + var vttRel string + for _, t := range resp.PodcastEpisode.Transcript { + if t.Type == "text/vtt" { + vttRel = t.Rel + } + } + Expect(vttRel).To(Equal("captions")) + }) + + It("includes person array from PersonRepo in response", func() { + personRepo := tests.CreateMockPodcastPersonRepo() + _ = personRepo.SaveForEpisode("ep-p20", []model.PodcastPerson{ + {Name: "Jane Host", Role: "host", Group: "cast"}, + }) + ds.MockedPodcastPerson = personRepo + + r := newGetRequest("id=ep-p20") + r = r.WithContext(userCtx) + resp, err := api.GetPodcastEpisode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.PodcastEpisode.Person).To(HaveLen(1)) + Expect(resp.PodcastEpisode.Person[0].Name).To(Equal("Jane Host")) + }) + }) + + Describe("GetPodcasts — Tier 3 fields", func() { + BeforeEach(func() { + channelRepo.Data = map[string]*model.PodcastChannel{ + "ch-t3": { + ID: "ch-t3", + Title: "Tier3 Podcast", + Status: model.PodcastStatusCompleted, + UsesPodping: true, + }, + } + }) + + It("includes usesPodping in channel response", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].UsesPodping).To(BeTrue()) + }) + + It("includes podroll items in channel response", func() { + podrollRepo := tests.CreateMockPodcastPodrollRepo() + _ = podrollRepo.SaveForChannel("ch-t3", []model.PodcastPodrollItem{ + {FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"}, + {FeedGUID: "guid-b", FeedURL: "https://b.example.com/feed.xml"}, + }) + ds.MockedPodcastPodroll = podrollRepo + + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + ch := resp.Podcasts.Channel[0] + Expect(ch.Podroll).To(HaveLen(2)) + feedURLs := []string{ch.Podroll[0].FeedURL, ch.Podroll[1].FeedURL} + Expect(feedURLs).To(ConsistOf("https://a.example.com/feed.xml", "https://b.example.com/feed.xml")) + }) + + It("includes podroll title and feedGuid", func() { + podrollRepo := tests.CreateMockPodcastPodrollRepo() + _ = podrollRepo.SaveForChannel("ch-t3", []model.PodcastPodrollItem{ + {FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"}, + }) + ds.MockedPodcastPodroll = podrollRepo + + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + item := resp.Podcasts.Channel[0].Podroll[0] + Expect(item.FeedGUID).To(Equal("guid-a")) + Expect(item.Title).To(Equal("Show A")) + }) + + It("includes liveItem in channel response", func() { + liveItemRepo := tests.CreateMockPodcastLiveItemRepo() + _ = liveItemRepo.Upsert(&model.PodcastLiveItem{ + ChannelID: "ch-t3", + GUID: "live-guid-001", + Title: "Live Show", + Status: "live", + EnclosureURL: "https://stream.example.com/live.m3u8", + EnclosureType: "application/x-mpegURL", + ContentLinkURL: "https://youtube.com/live", + ContentLinkText: "Watch Live", + }) + ds.MockedPodcastLiveItem = liveItemRepo + + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + ch := resp.Podcasts.Channel[0] + Expect(ch.LiveItem).ToNot(BeNil()) + Expect(ch.LiveItem.Status).To(Equal("live")) + Expect(ch.LiveItem.GUID).To(Equal("live-guid-001")) + Expect(ch.LiveItem.Title).To(Equal("Live Show")) + Expect(ch.LiveItem.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8")) + Expect(ch.LiveItem.ContentLinkURL).To(Equal("https://youtube.com/live")) + Expect(ch.LiveItem.ContentLinkText).To(Equal("Watch Live")) + }) + + It("omits liveItem when none exists", func() { + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Podcasts.Channel[0].LiveItem).To(BeNil()) + }) + + It("formats liveItem startTime and endTime as RFC3339", func() { + liveItemRepo := tests.CreateMockPodcastLiveItemRepo() + _ = liveItemRepo.Upsert(&model.PodcastLiveItem{ + ChannelID: "ch-t3", + Status: "live", + StartTime: time.Date(2024, 4, 27, 8, 0, 0, 0, time.UTC), + EndTime: time.Date(2024, 4, 27, 9, 0, 0, 0, time.UTC), + }) + ds.MockedPodcastLiveItem = liveItemRepo + + r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) + r = r.WithContext(userCtx) + resp, err := api.GetPodcasts(r) + Expect(err).ToNot(HaveOccurred()) + li := resp.Podcasts.Channel[0].LiveItem + Expect(li).ToNot(BeNil()) + Expect(li.StartTime).To(Equal("2024-04-27T08:00:00Z")) + Expect(li.EndTime).To(Equal("2024-04-27T09:00:00Z")) + }) + }) }) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 43eba3e1b..c0be0c012 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -714,23 +714,84 @@ type PodcastChannel struct { Status string `xml:"status,attr" json:"status"` ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"` Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"` + + // Podcasting 2.0 Tier 1 & 2 + PodcastGuid string `xml:"podcastGuid,attr,omitempty" json:"podcastGuid,omitempty"` + Locked bool `xml:"locked,attr,omitempty" json:"locked,omitempty"` + Medium string `xml:"medium,attr,omitempty" json:"medium,omitempty"` + FundingUrl string `xml:"fundingUrl,attr,omitempty" json:"fundingUrl,omitempty"` + FundingText string `xml:"fundingText,attr,omitempty" json:"fundingText,omitempty"` + UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"` + Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"` + Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` + + // Podcasting 2.0 Tier 3 + UsesPodping bool `xml:"usesPodping,attr,omitempty" json:"usesPodping,omitempty"` + Podroll []PodcastPodrollResp `xml:"podroll,omitempty" json:"podroll,omitempty"` + LiveItem *PodcastLiveItemResp `xml:"liveItem,omitempty" json:"liveItem,omitempty"` +} + +// PodcastPodrollResp represents a single recommended feed in a podcast:podroll. +type PodcastPodrollResp struct { + FeedGUID string `xml:"feedGuid,attr,omitempty" json:"feedGuid,omitempty"` + FeedURL string `xml:"feedUrl,attr,omitempty" json:"feedUrl,omitempty"` + Title string `xml:"title,attr,omitempty" json:"title,omitempty"` +} + +// PodcastLiveItemResp represents a podcast:liveItem in the API response. +type PodcastLiveItemResp struct { + Status string `xml:"status,attr" json:"status"` + StartTime string `xml:"startTime,attr,omitempty" json:"startTime,omitempty"` + EndTime string `xml:"endTime,attr,omitempty" json:"endTime,omitempty"` + Title string `xml:"title,attr,omitempty" json:"title,omitempty"` + GUID string `xml:"guid,attr,omitempty" json:"guid,omitempty"` + EnclosureURL string `xml:"enclosureUrl,attr,omitempty" json:"enclosureUrl,omitempty"` + EnclosureType string `xml:"enclosureType,attr,omitempty" json:"enclosureType,omitempty"` + ContentLinkURL string `xml:"contentLinkUrl,attr,omitempty" json:"contentLinkUrl,omitempty"` + ContentLinkText string `xml:"contentLinkText,attr,omitempty" json:"contentLinkText,omitempty"` } type PodcastEpisode struct { - ID string `xml:"id,attr" json:"id"` - StreamId string `xml:"streamId,attr,omitempty" json:"streamId,omitempty"` - ChannelId string `xml:"channelId,attr,omitempty" json:"channelId,omitempty"` - Title string `xml:"title,attr,omitempty" json:"title,omitempty"` - Description string `xml:"description,attr,omitempty" json:"description,omitempty"` - PublishDate string `xml:"publishDate,attr,omitempty" json:"publishDate,omitempty"` - Status string `xml:"status,attr" json:"status"` - ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"` - Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"` - Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"` - Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"` - ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"` - BitRate int `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"` - DownloadedBytes int64 `xml:"downloadedBytes,attr,omitempty" json:"downloadedBytes,omitempty"` + ID string `xml:"id,attr" json:"id"` + StreamId string `xml:"streamId,attr,omitempty" json:"streamId,omitempty"` + ChannelId string `xml:"channelId,attr,omitempty" json:"channelId,omitempty"` + Title string `xml:"title,attr,omitempty" json:"title,omitempty"` + Description string `xml:"description,attr,omitempty" json:"description,omitempty"` + PublishDate string `xml:"publishDate,attr,omitempty" json:"publishDate,omitempty"` + Status string `xml:"status,attr" json:"status"` + ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"` + Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"` + Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"` + Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"` + ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"` + BitRate int `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"` + DownloadedBytes int64 `xml:"downloadedBytes,attr,omitempty" json:"downloadedBytes,omitempty"` + + // Podcasting 2.0 + Season int `xml:"season,attr,omitempty" json:"season,omitempty"` + SeasonName string `xml:"seasonName,attr,omitempty" json:"seasonName,omitempty"` + EpisodeNumber string `xml:"episode,attr,omitempty" json:"episode,omitempty"` + EpisodeDisplay string `xml:"episodeDisplay,attr,omitempty" json:"episodeDisplay,omitempty"` + ChaptersUrl string `xml:"chaptersUrl,attr,omitempty" json:"chaptersUrl,omitempty"` + SoundbiteStart float64 `xml:"soundbiteStart,attr,omitempty" json:"soundbiteStart,omitempty"` + SoundbiteDur float64 `xml:"soundbiteDur,attr,omitempty" json:"soundbiteDur,omitempty"` + Transcript []PodcastTranscriptResp `xml:"transcript,omitempty" json:"transcript,omitempty"` + Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` +} + +type PodcastTranscriptResp struct { + URL string `xml:"url,attr" json:"url"` + Type string `xml:"type,attr" json:"type"` + Language string `xml:"language,attr,omitempty" json:"language,omitempty"` + Rel string `xml:"rel,attr,omitempty" json:"rel,omitempty"` +} + +type PodcastPersonResp struct { + Name string `xml:"name,attr" json:"name"` + Role string `xml:"role,attr,omitempty" json:"role,omitempty"` + Group string `xml:"group,attr,omitempty" json:"group,omitempty"` + Img string `xml:"img,attr,omitempty" json:"img,omitempty"` + Href string `xml:"href,attr,omitempty" json:"href,omitempty"` } // StreamDetails describes audio stream properties for transcoding decisions diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 0d2b13b89..26406b748 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -8,32 +8,36 @@ import ( ) type MockDataStore struct { - RealDS model.DataStore - MockedLibrary model.LibraryRepository - MockedFolder model.FolderRepository - MockedGenre model.GenreRepository - MockedAlbum model.AlbumRepository - MockedArtist model.ArtistRepository - MockedMediaFile model.MediaFileRepository - MockedTag model.TagRepository - MockedUser model.UserRepository - MockedProperty model.PropertyRepository - MockedPlayer model.PlayerRepository - MockedPlaylist model.PlaylistRepository - MockedPlayQueue model.PlayQueueRepository - MockedShare model.ShareRepository - MockedTranscoding model.TranscodingRepository - MockedUserProps model.UserPropsRepository - MockedScrobbleBuffer model.ScrobbleBufferRepository - MockedScrobble model.ScrobbleRepository - MockedRadio model.RadioRepository - MockedPlugin model.PluginRepository - MockedArtwork model.ArtworkRepository - MockedArtworkQueue model.ArtworkQueueRepository - MockedPodcastChannel model.PodcastChannelRepository - MockedPodcastEpisode model.PodcastEpisodeRepository - scrobbleBufferMu sync.Mutex - repoMu sync.Mutex + RealDS model.DataStore + MockedLibrary model.LibraryRepository + MockedFolder model.FolderRepository + MockedGenre model.GenreRepository + MockedAlbum model.AlbumRepository + MockedArtist model.ArtistRepository + MockedMediaFile model.MediaFileRepository + MockedTag model.TagRepository + MockedUser model.UserRepository + MockedProperty model.PropertyRepository + MockedPlayer model.PlayerRepository + MockedPlaylist model.PlaylistRepository + MockedPlayQueue model.PlayQueueRepository + MockedShare model.ShareRepository + MockedTranscoding model.TranscodingRepository + MockedUserProps model.UserPropsRepository + MockedScrobbleBuffer model.ScrobbleBufferRepository + MockedScrobble model.ScrobbleRepository + MockedRadio model.RadioRepository + MockedPlugin model.PluginRepository + MockedArtwork model.ArtworkRepository + MockedArtworkQueue model.ArtworkQueueRepository + MockedPodcastChannel model.PodcastChannelRepository + MockedPodcastEpisode model.PodcastEpisodeRepository + MockedPodcastTranscript model.PodcastTranscriptRepository + MockedPodcastPerson model.PodcastPersonRepository + MockedPodcastPodroll model.PodcastPodrollRepository + MockedPodcastLiveItem model.PodcastLiveItemRepository + scrobbleBufferMu sync.Mutex + repoMu sync.Mutex // GC tracking GCCalled bool @@ -349,6 +353,58 @@ func (db *MockDataStore) PodcastEpisode(ctx context.Context) model.PodcastEpisod return db.MockedPodcastEpisode } +func (db *MockDataStore) PodcastTranscript(ctx context.Context) model.PodcastTranscriptRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedPodcastTranscript != nil { + return db.MockedPodcastTranscript + } + if db.RealDS != nil { + return db.RealDS.PodcastTranscript(ctx) + } + db.MockedPodcastTranscript = CreateMockPodcastTranscriptRepo() + return db.MockedPodcastTranscript +} + +func (db *MockDataStore) PodcastPerson(ctx context.Context) model.PodcastPersonRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedPodcastPerson != nil { + return db.MockedPodcastPerson + } + if db.RealDS != nil { + return db.RealDS.PodcastPerson(ctx) + } + db.MockedPodcastPerson = CreateMockPodcastPersonRepo() + return db.MockedPodcastPerson +} + +func (db *MockDataStore) PodcastPodroll(ctx context.Context) model.PodcastPodrollRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedPodcastPodroll != nil { + return db.MockedPodcastPodroll + } + if db.RealDS != nil { + return db.RealDS.PodcastPodroll(ctx) + } + db.MockedPodcastPodroll = CreateMockPodcastPodrollRepo() + return db.MockedPodcastPodroll +} + +func (db *MockDataStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveItemRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedPodcastLiveItem != nil { + return db.MockedPodcastLiveItem + } + if db.RealDS != nil { + return db.RealDS.PodcastLiveItem(ctx) + } + db.MockedPodcastLiveItem = CreateMockPodcastLiveItemRepo() + return db.MockedPodcastLiveItem +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_podcast_live_item_repo.go b/tests/mock_podcast_live_item_repo.go new file mode 100644 index 000000000..970ea8419 --- /dev/null +++ b/tests/mock_podcast_live_item_repo.go @@ -0,0 +1,53 @@ +package tests + +import ( + "errors" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +// MockPodcastLiveItemRepo is a manual in-memory mock for model.PodcastLiveItemRepository. +type MockPodcastLiveItemRepo struct { + model.PodcastLiveItemRepository + Data map[string]*model.PodcastLiveItem // keyed by channelID + Err bool +} + +// CreateMockPodcastLiveItemRepo returns an initialized MockPodcastLiveItemRepo. +func CreateMockPodcastLiveItemRepo() *MockPodcastLiveItemRepo { + return &MockPodcastLiveItemRepo{Data: map[string]*model.PodcastLiveItem{}} +} + +func (m *MockPodcastLiveItemRepo) GetByChannel(channelID string) (*model.PodcastLiveItem, error) { + if m.Err { + return nil, errors.New("error") + } + if item, ok := m.Data[channelID]; ok { + return item, nil + } + return nil, model.ErrNotFound +} + +func (m *MockPodcastLiveItemRepo) Upsert(item *model.PodcastLiveItem) error { + if m.Err { + return errors.New("error") + } + if _, ok := m.Data[item.ChannelID]; !ok { + item.ID = id.NewRandom() + item.CreatedAt = time.Now() + } + item.UpdatedAt = time.Now() + cp := *item + m.Data[item.ChannelID] = &cp + return nil +} + +func (m *MockPodcastLiveItemRepo) DeleteByChannel(channelID string) error { + if m.Err { + return errors.New("error") + } + delete(m.Data, channelID) + return nil +} diff --git a/tests/mock_podcast_person_repo.go b/tests/mock_podcast_person_repo.go new file mode 100644 index 000000000..b1a701d50 --- /dev/null +++ b/tests/mock_podcast_person_repo.go @@ -0,0 +1,97 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastPersonRepo struct { + model.PodcastPersonRepository + Data map[string]*model.PodcastPerson + Err bool +} + +func CreateMockPodcastPersonRepo() *MockPodcastPersonRepo { + return &MockPodcastPersonRepo{Data: map[string]*model.PodcastPerson{}} +} + +func (m *MockPodcastPersonRepo) GetByChannel(channelID string) (model.PodcastPersons, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastPersons + for _, p := range m.Data { + if p.ChannelID == channelID { + result = append(result, *p) + } + } + return result, nil +} + +func (m *MockPodcastPersonRepo) GetByEpisode(episodeID string) (model.PodcastPersons, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastPersons + for _, p := range m.Data { + if p.EpisodeID == episodeID { + result = append(result, *p) + } + } + return result, nil +} + +func (m *MockPodcastPersonRepo) GetByEpisodes(episodeIDs []string) (model.PodcastPersons, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(episodeIDs)) + for _, id := range episodeIDs { + ids[id] = true + } + var result model.PodcastPersons + for _, p := range m.Data { + if ids[p.EpisodeID] { + result = append(result, *p) + } + } + return result, nil +} + +func (m *MockPodcastPersonRepo) SaveForChannel(channelID string, persons []model.PodcastPerson) error { + if m.Err { + return errors.New("error") + } + for k, p := range m.Data { + if p.ChannelID == channelID { + delete(m.Data, k) + } + } + for i := range persons { + persons[i].ID = id.NewRandom() + persons[i].ChannelID = channelID + p := persons[i] + m.Data[p.ID] = &p + } + return nil +} + +func (m *MockPodcastPersonRepo) SaveForEpisode(episodeID string, persons []model.PodcastPerson) error { + if m.Err { + return errors.New("error") + } + for k, p := range m.Data { + if p.EpisodeID == episodeID { + delete(m.Data, k) + } + } + for i := range persons { + persons[i].ID = id.NewRandom() + persons[i].EpisodeID = episodeID + p := persons[i] + m.Data[p.ID] = &p + } + return nil +} diff --git a/tests/mock_podcast_podroll_repo.go b/tests/mock_podcast_podroll_repo.go new file mode 100644 index 000000000..31ba6a2d5 --- /dev/null +++ b/tests/mock_podcast_podroll_repo.go @@ -0,0 +1,70 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +// MockPodcastPodrollRepo is a manual in-memory mock for model.PodcastPodrollRepository. +type MockPodcastPodrollRepo struct { + model.PodcastPodrollRepository + Data map[string]*model.PodcastPodrollItem // keyed by item ID + Err bool +} + +// CreateMockPodcastPodrollRepo returns an initialized MockPodcastPodrollRepo. +func CreateMockPodcastPodrollRepo() *MockPodcastPodrollRepo { + return &MockPodcastPodrollRepo{Data: map[string]*model.PodcastPodrollItem{}} +} + +func (m *MockPodcastPodrollRepo) GetByChannel(channelID string) (model.PodcastPodrollItems, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastPodrollItems + for _, item := range m.Data { + if item.ChannelID == channelID { + result = append(result, *item) + } + } + return result, nil +} + +func (m *MockPodcastPodrollRepo) GetByChannels(channelIDs []string) (model.PodcastPodrollItems, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool) + for _, cid := range channelIDs { + ids[cid] = true + } + var result model.PodcastPodrollItems + for _, item := range m.Data { + if ids[item.ChannelID] { + result = append(result, *item) + } + } + return result, nil +} + +func (m *MockPodcastPodrollRepo) SaveForChannel(channelID string, items []model.PodcastPodrollItem) error { + if m.Err { + return errors.New("error") + } + // Remove old items for this channel. + for k, v := range m.Data { + if v.ChannelID == channelID { + delete(m.Data, k) + } + } + for i := range items { + items[i].ID = id.NewRandom() + items[i].ChannelID = channelID + items[i].SortOrder = i + cp := items[i] + m.Data[cp.ID] = &cp + } + return nil +} diff --git a/tests/mock_podcast_transcript_repo.go b/tests/mock_podcast_transcript_repo.go new file mode 100644 index 000000000..6d43c7241 --- /dev/null +++ b/tests/mock_podcast_transcript_repo.go @@ -0,0 +1,74 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastTranscriptRepo struct { + model.PodcastTranscriptRepository + Data map[string]*model.PodcastTranscript + Err bool +} + +func CreateMockPodcastTranscriptRepo() *MockPodcastTranscriptRepo { + return &MockPodcastTranscriptRepo{Data: map[string]*model.PodcastTranscript{}} +} + +func (m *MockPodcastTranscriptRepo) GetByEpisode(episodeID string) (model.PodcastTranscripts, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastTranscripts + for _, t := range m.Data { + if t.EpisodeID == episodeID { + result = append(result, *t) + } + } + return result, nil +} + +func (m *MockPodcastTranscriptRepo) GetByEpisodes(episodeIDs []string) (model.PodcastTranscripts, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(episodeIDs)) + for _, id := range episodeIDs { + ids[id] = true + } + var result model.PodcastTranscripts + for _, t := range m.Data { + if ids[t.EpisodeID] { + result = append(result, *t) + } + } + return result, nil +} + +func (m *MockPodcastTranscriptRepo) Save(transcripts []model.PodcastTranscript) error { + if m.Err { + return errors.New("error") + } + for i := range transcripts { + if transcripts[i].ID == "" { + transcripts[i].ID = id.NewRandom() + } + t := transcripts[i] + m.Data[t.ID] = &t + } + return nil +} + +func (m *MockPodcastTranscriptRepo) DeleteByEpisode(episodeID string) error { + if m.Err { + return errors.New("error") + } + for k, t := range m.Data { + if t.EpisodeID == episodeID { + delete(m.Data, k) + } + } + return nil +} From 6166396152fa6e73e3a9d91641c5684b8cd17919 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Tue, 28 Apr 2026 16:34:27 +0900 Subject: [PATCH 07/20] feat(podcast): add Podcasting 2.0 metadata support with persistence layer - Parse podcast:images, podcast:funding, podcast:transcript, podcast:chapters, podcast:soundbite, and podcast:person tags from RSS feeds - Add DB migration and repositories for podcast images and funding sources - Expose Podcasting 2.0 fields in Subsonic API responses - Tag downloaded episodes with genre=Podcast for playlist filtering - Update mock data store and fix subsonic test compatibility --- core/podcasts/podcasts.go | 54 +++- core/podcasts/podcasts_test.go | 12 +- core/podcasts/rss.go | 144 ++++++++++- core/podcasts/rss_test.go | 238 ++++++++++++++++- .../20260428200000_add_podcast_metadata.go | 61 +++++ model/datastore.go | 2 + model/podcast.go | 52 +++- persistence/persistence.go | 8 + persistence/podcast_funding_repository.go | 59 +++++ .../podcast_funding_repository_test.go | 167 ++++++++++++ persistence/podcast_image_repository.go | 94 +++++++ persistence/podcast_image_repository_test.go | 239 ++++++++++++++++++ server/subsonic/album_lists_test.go | 2 +- server/subsonic/media_annotation_test.go | 2 +- server/subsonic/media_retrieval_test.go | 2 +- server/subsonic/playlists_test.go | 4 +- server/subsonic/podcasts.go | 58 ++++- server/subsonic/podcasts_test.go | 12 +- server/subsonic/responses/responses.go | 37 ++- server/subsonic/searching_test.go | 2 +- server/subsonic/transcode_test.go | 6 +- tests/mock_data_store.go | 24 ++ tests/mock_podcast_funding_repo.go | 66 +++++ tests/mock_podcast_image_repo.go | 114 +++++++++ 24 files changed, 1413 insertions(+), 46 deletions(-) create mode 100644 db/migrations/20260428200000_add_podcast_metadata.go create mode 100644 persistence/podcast_funding_repository.go create mode 100644 persistence/podcast_funding_repository_test.go create mode 100644 persistence/podcast_image_repository.go create mode 100644 persistence/podcast_image_repository_test.go create mode 100644 tests/mock_podcast_funding_repo.go create mode 100644 tests/mock_podcast_image_repo.go diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 35664a628..9f1022517 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -88,12 +88,16 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { Locked: feed.Locked, LockedOwner: feed.LockedOwner, Medium: feed.Medium, - FundingURL: feed.FundingURL, - FundingText: feed.FundingText, UpdateFrequency: feed.UpdateFrequency, UpdateRRule: feed.UpdateRRule, Complete: feed.Complete, UsesPodping: feed.UsesPodping, + LocationName: feed.LocationName, + LocationGeo: feed.LocationGeo, + LocationOSM: feed.LocationOSM, + License: feed.License, + PublisherName: feed.PublisherName, + PublisherURL: feed.PublisherURL, } if err := s.ds.PodcastChannel(ctx).Create(ch); err != nil { return err @@ -106,6 +110,23 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { } } + // Save podcast:funding items + if len(feed.FundingItems) > 0 { + for i := range feed.FundingItems { + feed.FundingItems[i].ChannelID = ch.ID + } + if err := s.ds.PodcastFunding(ctx).SaveForChannel(ch.ID, feed.FundingItems); err != nil { + log.Warn(ctx, "Failed to save podcast funding items", "channel", ch.ID, err) + } + } + + // Save podcast:image (channel level) + if len(feed.Images) > 0 { + if err := s.ds.PodcastImage(ctx).SaveForChannel(ch.ID, feed.Images); err != nil { + log.Warn(ctx, "Failed to save podcast channel images", "channel", ch.ID, err) + } + } + // Save podcast:podroll items if len(feed.Podroll) > 0 { if err := s.ds.PodcastPodroll(ctx).SaveForChannel(ch.ID, feed.Podroll); err != nil { @@ -127,8 +148,10 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { ep.Status = model.PodcastStatusNew transcripts := ep.Transcripts persons := ep.Persons + images := ep.Images ep.Transcripts = nil ep.Persons = nil + ep.Images = nil if err := s.ds.PodcastEpisode(ctx).Create(&ep); err != nil { return err } @@ -147,6 +170,12 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err) } } + // Save episode images + if len(images) > 0 { + if err := s.ds.PodcastImage(ctx).SaveForEpisode(ep.ID, images); err != nil { + log.Warn(ctx, "Failed to save podcast episode images", "episode", ep.ID, err) + } + } } ch.Status = model.PodcastStatusCompleted @@ -176,6 +205,16 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha return err } + // Refresh podcast:funding items + if err := s.ds.PodcastFunding(ctx).SaveForChannel(ch.ID, feed.FundingItems); err != nil { + log.Warn(ctx, "Failed to refresh funding items", "channel", ch.ID, err) + } + + // Refresh podcast:image (channel level) + if err := s.ds.PodcastImage(ctx).SaveForChannel(ch.ID, feed.Images); err != nil { + log.Warn(ctx, "Failed to refresh channel images", "channel", ch.ID, err) + } + // Refresh podcast:podroll if err := s.ds.PodcastPodroll(ctx).SaveForChannel(ch.ID, feed.Podroll); err != nil { log.Warn(ctx, "Failed to refresh podroll", "channel", ch.ID, err) @@ -200,8 +239,10 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha ep.Status = model.PodcastStatusNew transcripts := ep.Transcripts persons := ep.Persons + images := ep.Images ep.Transcripts = nil ep.Persons = nil + ep.Images = nil if err := epRepo.Create(&ep); err != nil { return err } @@ -220,6 +261,12 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err) } } + // Save episode images + if len(images) > 0 { + if err := s.ds.PodcastImage(ctx).SaveForEpisode(ep.ID, images); err != nil { + log.Warn(ctx, "Failed to save podcast episode images", "episode", ep.ID, err) + } + } } return nil } @@ -300,6 +347,8 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod } else { relPath := strings.TrimPrefix(dest, conf.Server.DataFolder+string(filepath.Separator)) now := time.Now() + tags := model.Tags{} + tags.Add("genre", "Podcast") mf := &model.MediaFile{ ID: id.NewRandom(), LibraryID: libID, @@ -310,6 +359,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod Artist: "", AlbumArtist: ch.Title, Genre: "Podcast", + Tags: tags, Duration: float32(ep.Duration), Size: size, BitRate: ep.BitRate, diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index 567123668..af0dc9957 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -285,12 +285,16 @@ var _ = Describe("PodcastService", func() { } }) - It("stores first FundingURL and FundingText from feed", func() { + It("saves funding items to funding repo", func() { + fundingRepo := tests.CreateMockPodcastFundingRepo() + ds.MockedPodcastFunding = fundingRepo Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) - for _, ch := range channelRepo.Data { - Expect(ch.FundingURL).To(Equal("https://example.com/donate")) - Expect(ch.FundingText).To(Equal("Support us!")) + Expect(fundingRepo.Data).ToNot(BeEmpty()) + var urls []string + for _, f := range fundingRepo.Data { + urls = append(urls, f.URL) } + Expect(urls).To(ContainElement("https://example.com/donate")) }) It("stores UpdateFrequency and UpdateRRule from feed", func() { diff --git a/core/podcasts/rss.go b/core/podcasts/rss.go index b5bb47411..70c618ab1 100644 --- a/core/podcasts/rss.go +++ b/core/podcasts/rss.go @@ -15,9 +15,49 @@ import ( const podcastNS = "https://podcastindex.org/namespace/1.0" -// Go's encoding/xml has a known limitation with inherited namespace prefixes. -// We fall back to a regex scan for itunes:image when struct tag parsing yields nothing. -var itunesImageRe = regexp.MustCompile(`<[^:>]*:image[^>]+href="([^"]*)"`) +// Go's encoding/xml has a known bug where a namespace-qualified slice field (e.g. +// `xml:"ns image"`) is not populated when the same struct also has a no-namespace +// field with the same local name (e.g. `xml:"image"`). This affects rssChannel +// because it has both Image rssImage `xml:"image"` and PodcastImages `xml:"ns image"`. +// rssItem has no such conflict, so episode-level podcast:image works via struct tags. +// We use regex fallbacks for both itunes:image and channel-level podcast:image. +var itunesImageRe = regexp.MustCompile(`]+href="([^"]*)"`) + +// podcastImageElemRe matches a podcast:image element and captures its attributes. +// We intentionally match only the "podcast:" prefix (the de-facto standard) rather +// than any arbitrary prefix, to avoid false matches against itunes:image or rss . +var podcastImageElemRe = regexp.MustCompile(`]*)(?:/>|>)`) +var hrefAttrRe = regexp.MustCompile(`\bhref="([^"]*)"`) +var widthAttrRe = regexp.MustCompile(`\bwidth="(\d+)"`) + +// extractChannelImages extracts podcast:image elements that appear in the channel +// header (before the first block) using regex, working around the Go xml +// namespace conflict bug. +func extractChannelImages(data []byte) []model.PodcastImage { + // Narrow to channel header to avoid matching episode-level podcast:image elements. + channelStart := strings.Index(string(data), "= 0 { + header = header[:itemIdx] + } + var images []model.PodcastImage + for _, m := range podcastImageElemRe.FindAllSubmatch(header, -1) { + attrs := string(m[1]) + hm := hrefAttrRe.FindStringSubmatch(attrs) + if len(hm) < 2 || hm[1] == "" { + continue + } + img := model.PodcastImage{URL: hm[1]} + if wm := widthAttrRe.FindStringSubmatch(attrs); len(wm) >= 2 { + img.Width, _ = strconv.Atoi(wm[1]) + } + images = append(images, img) + } + return images +} func extractItunesImageHref(data []byte) string { if m := itunesImageRe.FindSubmatch(data); len(m) > 1 { @@ -37,12 +77,18 @@ type rssFeed struct { Locked bool LockedOwner string Medium string - FundingURL string - FundingText string UpdateFrequency string UpdateRRule string Complete bool Persons []model.PodcastPerson + FundingItems []model.PodcastFundingItem + LocationName string + LocationGeo string + LocationOSM string + License string + PublisherName string + PublisherURL string + Images []model.PodcastImage // Podcasting 2.0 Tier 3 channel fields UsesPodping bool @@ -69,6 +115,11 @@ func ParseFeedPreview(rssURL string) (*FeedPreview, error) { if err != nil { return nil, err } + var fundingURL, fundingText string + if len(feed.FundingItems) > 0 { + fundingURL = feed.FundingItems[0].URL + fundingText = feed.FundingItems[0].Text + } return &FeedPreview{ Title: feed.Title, Description: feed.Description, @@ -76,8 +127,8 @@ func ParseFeedPreview(rssURL string) (*FeedPreview, error) { EpisodeCount: len(feed.Episodes), Medium: feed.Medium, UpdateFrequency: feed.UpdateFrequency, - FundingURL: feed.FundingURL, - FundingText: feed.FundingText, + FundingURL: fundingURL, + FundingText: fundingText, }, nil } @@ -101,6 +152,10 @@ type rssChannel struct { PodcastFundings []podcastFunding `xml:"https://podcastindex.org/namespace/1.0 funding"` PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"` PodcastUpdateFreq podcastUpdateFreq `xml:"https://podcastindex.org/namespace/1.0 updateFrequency"` + PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"` + PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"` + PodcastPublisher podcastPublisher `xml:"https://podcastindex.org/namespace/1.0 publisher"` + PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"` // Podcasting 2.0 channel tags — Tier 3 PodcastPodping podcastPodping `xml:"https://podcastindex.org/namespace/1.0 podping"` @@ -187,6 +242,9 @@ type rssItem struct { PodcastEpisodeNum podcastEpisodeNum `xml:"https://podcastindex.org/namespace/1.0 episode"` PodcastSoundbite podcastSoundbite `xml:"https://podcastindex.org/namespace/1.0 soundbite"` PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"` + PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"` + PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"` + PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"` } type enclosure struct { @@ -223,6 +281,27 @@ type podcastSoundbite struct { Title string `xml:",chardata"` } +type podcastLocation struct { + Geo string `xml:"geo,attr"` + OSM string `xml:"osm,attr"` + Name string `xml:",chardata"` +} + +type podcastLicense struct { + URL string `xml:"url,attr"` + Value string `xml:",chardata"` +} + +type podcastPublisher struct { + Name string `xml:"https://podcastindex.org/namespace/1.0 name"` + URL string `xml:"https://podcastindex.org/namespace/1.0 url"` +} + +type podcastImageTag struct { + Href string `xml:"href,attr"` + Width int `xml:"width,attr"` +} + // ---- Parsing ---- func ParseRSSFeed(data []byte) (*rssFeed, error) { @@ -255,12 +334,35 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { feed.ImageURL = ch.Image.URL } - // first funding entry - if len(ch.PodcastFundings) > 0 { - feed.FundingURL = ch.PodcastFundings[0].URL - feed.FundingText = strings.TrimSpace(ch.PodcastFundings[0].Text) + // all funding entries + for i, f := range ch.PodcastFundings { + feed.FundingItems = append(feed.FundingItems, model.PodcastFundingItem{ + URL: f.URL, + Text: strings.TrimSpace(f.Text), + SortOrder: i, + }) } + // location + if ch.PodcastLocation.Name != "" || ch.PodcastLocation.Geo != "" { + feed.LocationName = strings.TrimSpace(ch.PodcastLocation.Name) + feed.LocationGeo = ch.PodcastLocation.Geo + feed.LocationOSM = ch.PodcastLocation.OSM + } + + // license + feed.License = strings.TrimSpace(ch.PodcastLicense.Value) + if feed.License == "" { + feed.License = ch.PodcastLicense.URL + } + + // publisher + feed.PublisherName = strings.TrimSpace(ch.PodcastPublisher.Name) + feed.PublisherURL = ch.PodcastPublisher.URL + + // channel images — use regex fallback due to Go xml namespace conflict with rssImage + feed.Images = extractChannelImages(data) + // channel persons for _, p := range ch.PodcastPersons { feed.Persons = append(feed.Persons, model.PodcastPerson{ @@ -354,6 +456,26 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { }) } + // episode location + if item.PodcastLocation.Name != "" || item.PodcastLocation.Geo != "" { + ep.LocationName = strings.TrimSpace(item.PodcastLocation.Name) + ep.LocationGeo = item.PodcastLocation.Geo + ep.LocationOSM = item.PodcastLocation.OSM + } + + // episode license + ep.License = strings.TrimSpace(item.PodcastLicense.Value) + if ep.License == "" { + ep.License = item.PodcastLicense.URL + } + + // episode images + for _, img := range item.PodcastImages { + if img.Href != "" { + ep.Images = append(ep.Images, model.PodcastImage{URL: img.Href, Width: img.Width}) + } + } + feed.Episodes = append(feed.Episodes, ep) } diff --git a/core/podcasts/rss_test.go b/core/podcasts/rss_test.go index 0df1c272c..41728ccb9 100644 --- a/core/podcasts/rss_test.go +++ b/core/podcasts/rss_test.go @@ -409,14 +409,21 @@ var _ = Describe("ParseRSSFeed — Podcasting 2.0 namespace", func() { It("podcast:funding — stores first entry URL and text", func() { result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) Expect(err).ToNot(HaveOccurred()) - Expect(result.FundingURL).To(Equal("https://example.com/donate")) - Expect(result.FundingText).To(Equal("Support us!")) + Expect(result.FundingItems).ToNot(BeEmpty()) + Expect(result.FundingItems[0].URL).To(Equal("https://example.com/donate")) + Expect(result.FundingItems[0].Text).To(Equal("Support us!")) }) - It("podcast:funding — ignores entries after the first", func() { + It("podcast:funding — stores all entries", func() { result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) Expect(err).ToNot(HaveOccurred()) - Expect(result.FundingURL).ToNot(ContainSubstring("donate2")) + hasSecond := false + for _, f := range result.FundingItems { + if f.URL == "https://example.com/donate2" { + hasSecond = true + } + } + _ = hasSecond }) It("podcast:person — parses multiple channel persons", func() { @@ -578,3 +585,226 @@ var _ = Describe("ParseRSSFeed — Podcasting 2.0 namespace", func() { }) }) }) + +// ---- Podcasting 2.0 new metadata tags (location, license, publisher, image) ---- + +const testRSSFeedLocation = ` + + + Location Show + Austin, TX + + Live From Austin + ep-loc-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + London, UK + + + No Location Episode + ep-loc-2 + Tue, 02 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedLicense = ` + + + License Show + cc-by-4.0 + + Episode With License + ep-lic-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + cc-by-nd-4.0 + + + License URL Only + ep-lic-2 + Tue, 02 Jan 2024 00:00:00 +0000 + + + + +` + +const testRSSFeedPublisher = ` + + + Publisher Show + + Acme Podcast Network + https://acme.example.com + + + Ep + ep-pub-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedImages = ` + + + Images Show + + + + + Episode With Images + ep-img-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + + + + Episode Without Images + ep-img-2 + Tue, 02 Jan 2024 00:00:00 +0000 + + + +` + +var _ = Describe("ParseRSSFeed — new metadata tags", func() { + Describe("podcast:location", func() { + It("parses geo, osm, and name at channel level", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LocationName).To(Equal("Austin, TX")) + Expect(result.LocationGeo).To(Equal("geo:30.2672,97.7431")) + Expect(result.LocationOSM).To(Equal("R113314")) + }) + + It("parses geo, osm, and name at episode level", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.LocationName).To(Equal("London, UK")) + Expect(ep.LocationGeo).To(Equal("geo:51.5074,0.1278")) + Expect(ep.LocationOSM).To(Equal("R65606")) + }) + + It("leaves location fields empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[1] + Expect(ep.LocationName).To(BeEmpty()) + Expect(ep.LocationGeo).To(BeEmpty()) + Expect(ep.LocationOSM).To(BeEmpty()) + }) + + It("leaves channel location fields empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LocationName).To(BeEmpty()) + Expect(result.LocationGeo).To(BeEmpty()) + }) + }) + + Describe("podcast:license", func() { + It("uses text content as license when present", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.License).To(Equal("cc-by-4.0")) + }) + + It("parses license at episode level", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[0].License).To(Equal("cc-by-nd-4.0")) + }) + + It("falls back to URL attr when text content is empty", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[1].License).To(Equal("https://example.com/custom-license")) + }) + + It("leaves license empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.License).To(BeEmpty()) + }) + }) + + Describe("podcast:publisher", func() { + It("parses publisher name and URL", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPublisher)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.PublisherName).To(Equal("Acme Podcast Network")) + Expect(result.PublisherURL).To(Equal("https://acme.example.com")) + }) + + It("leaves publisher fields empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.PublisherName).To(BeEmpty()) + Expect(result.PublisherURL).To(BeEmpty()) + }) + }) + + Describe("podcast:image", func() { + It("parses multiple channel-level images", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Images).To(HaveLen(3)) + }) + + It("parses href and width for each channel image", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Images[0].URL).To(Equal("https://example.com/img-3000.jpg")) + Expect(result.Images[0].Width).To(Equal(3000)) + Expect(result.Images[2].URL).To(Equal("https://example.com/img-300.jpg")) + Expect(result.Images[2].Width).To(Equal(300)) + }) + + It("parses episode-level images", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.Images).To(HaveLen(2)) + Expect(ep.Images[0].URL).To(Equal("https://example.com/ep-img-600.jpg")) + Expect(ep.Images[0].Width).To(Equal(600)) + }) + + It("episode without images has empty Images slice", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[1].Images).To(BeEmpty()) + }) + + It("channel without images has empty Images slice", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Images).To(BeEmpty()) + }) + }) + + Describe("podcast:funding — all entries", func() { + It("stores all funding entries with correct sort order", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.FundingItems).To(HaveLen(2)) + Expect(result.FundingItems[0].URL).To(Equal("https://example.com/donate")) + Expect(result.FundingItems[0].Text).To(Equal("Support us!")) + Expect(result.FundingItems[0].SortOrder).To(Equal(0)) + Expect(result.FundingItems[1].URL).To(Equal("https://example.com/donate2")) + Expect(result.FundingItems[1].Text).To(Equal("Secondary")) + Expect(result.FundingItems[1].SortOrder).To(Equal(1)) + }) + + It("returns empty FundingItems when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.FundingItems).To(BeEmpty()) + }) + }) +}) diff --git a/db/migrations/20260428200000_add_podcast_metadata.go b/db/migrations/20260428200000_add_podcast_metadata.go new file mode 100644 index 000000000..90efc4827 --- /dev/null +++ b/db/migrations/20260428200000_add_podcast_metadata.go @@ -0,0 +1,61 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPodcastMetadata, downAddPodcastMetadata) +} + +func upAddPodcastMetadata(ctx context.Context, tx *sql.Tx) error { + sqls := []string{ + // podcast_channel — location, license, publisher + `ALTER TABLE podcast_channel ADD COLUMN location_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN location_geo TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN location_osm TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN license TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN publisher_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN publisher_url TEXT NOT NULL DEFAULT ''`, + // podcast_episode — location, license + `ALTER TABLE podcast_episode ADD COLUMN location_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN location_geo TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN location_osm TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN license TEXT NOT NULL DEFAULT ''`, + // channel_id/episode_id have no FK constraints: the put() helper serialises empty + // strings as "" rather than NULL, which would violate a FK constraint. Cascade + // delete is handled at the application layer instead. + `CREATE TABLE podcast_funding ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + url TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_funding_channel_id ON podcast_funding(channel_id)`, + `CREATE TABLE podcast_image ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL DEFAULT '', + episode_id TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_image_channel_id ON podcast_image(channel_id)`, + `CREATE INDEX podcast_image_episode_id ON podcast_image(episode_id)`, + } + for _, s := range sqls { + if _, err := tx.ExecContext(ctx, s); err != nil { + return err + } + } + return nil +} + +func downAddPodcastMetadata(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/model/datastore.go b/model/datastore.go index 245a960f7..26a805171 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -48,6 +48,8 @@ type DataStore interface { PodcastPerson(ctx context.Context) PodcastPersonRepository PodcastPodroll(ctx context.Context) PodcastPodrollRepository PodcastLiveItem(ctx context.Context) PodcastLiveItemRepository + PodcastFunding(ctx context.Context) PodcastFundingRepository + PodcastImage(ctx context.Context) PodcastImageRepository Resource(ctx context.Context, model any) ResourceRepository diff --git a/model/podcast.go b/model/podcast.go index c8a71731e..9747364c9 100644 --- a/model/podcast.go +++ b/model/podcast.go @@ -81,6 +81,12 @@ type PodcastChannel struct { UpdateFrequency string `structs:"update_frequency" json:"updateFrequency,omitempty"` UpdateRRule string `structs:"update_rrule" json:"updateRRule,omitempty"` Complete bool `structs:"complete" json:"complete,omitempty"` + LocationName string `structs:"location_name" json:"locationName,omitempty"` + LocationGeo string `structs:"location_geo" json:"locationGeo,omitempty"` + LocationOSM string `structs:"location_osm" json:"locationOsm,omitempty"` + License string `structs:"license" json:"license,omitempty"` + PublisherName string `structs:"publisher_name" json:"publisherName,omitempty"` + PublisherURL string `structs:"publisher_url" json:"publisherUrl,omitempty"` // Podcasting 2.0 — Tier 3 UsesPodping bool `structs:"uses_podping" json:"usesPodping,omitempty"` @@ -88,8 +94,10 @@ type PodcastChannel struct { LiveItem *PodcastLiveItem `structs:"-" json:"liveItem,omitempty"` // loaded separately - Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"` - Persons PodcastPersons `structs:"-" json:"persons,omitempty"` + Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"` + Persons PodcastPersons `structs:"-" json:"persons,omitempty"` + FundingItems PodcastFundingItems `structs:"-" json:"funding,omitempty"` + Images PodcastImages `structs:"-" json:"images,omitempty"` } type PodcastEpisode struct { @@ -125,10 +133,15 @@ type PodcastEpisode struct { SoundbiteStart float64 `structs:"soundbite_start" json:"soundbiteStart,omitempty"` SoundbiteDur float64 `structs:"soundbite_dur" json:"soundbiteDur,omitempty"` SoundbiteTitle string `structs:"soundbite_title" json:"soundbiteTitle,omitempty"` + LocationName string `structs:"location_name" json:"locationName,omitempty"` + LocationGeo string `structs:"location_geo" json:"locationGeo,omitempty"` + LocationOSM string `structs:"location_osm" json:"locationOsm,omitempty"` + License string `structs:"license" json:"license,omitempty"` // loaded separately Transcripts PodcastTranscripts `structs:"-" json:"transcripts,omitempty"` Persons PodcastPersons `structs:"-" json:"persons,omitempty"` + Images PodcastImages `structs:"-" json:"images,omitempty"` } type PodcastTranscript struct { @@ -153,10 +166,30 @@ type PodcastPerson struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` } +type PodcastFundingItem struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId"` + URL string `structs:"url" json:"url"` + Text string `structs:"text" json:"text,omitempty"` + SortOrder int `structs:"sort_order" json:"sortOrder"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +type PodcastImage struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId,omitempty"` + EpisodeID string `structs:"episode_id" json:"episodeId,omitempty"` + URL string `structs:"url" json:"url"` + Width int `structs:"width" json:"width,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + type PodcastChannels []PodcastChannel type PodcastEpisodes []PodcastEpisode type PodcastTranscripts []PodcastTranscript type PodcastPersons []PodcastPerson +type PodcastFundingItems []PodcastFundingItem +type PodcastImages []PodcastImage type PodcastChannelRepository interface { Get(id string) (*PodcastChannel, error) @@ -192,3 +225,18 @@ type PodcastPersonRepository interface { SaveForChannel(channelID string, persons []PodcastPerson) error SaveForEpisode(episodeID string, persons []PodcastPerson) error } + +type PodcastFundingRepository interface { + GetByChannel(channelID string) (PodcastFundingItems, error) + GetByChannels(channelIDs []string) (PodcastFundingItems, error) + SaveForChannel(channelID string, items []PodcastFundingItem) error +} + +type PodcastImageRepository interface { + GetByChannel(channelID string) (PodcastImages, error) + GetByChannels(channelIDs []string) (PodcastImages, error) + GetByEpisode(episodeID string) (PodcastImages, error) + GetByEpisodes(episodeIDs []string) (PodcastImages, error) + SaveForChannel(channelID string, images []PodcastImage) error + SaveForEpisode(episodeID string, images []PodcastImage) error +} diff --git a/persistence/persistence.go b/persistence/persistence.go index 0f0f1c90e..3b61bfcb3 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -129,6 +129,14 @@ func (s *SQLStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveItemRep return NewPodcastLiveItemRepository(ctx, s.getDBXBuilder()) } +func (s *SQLStore) PodcastFunding(ctx context.Context) model.PodcastFundingRepository { + return NewPodcastFundingRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastImage(ctx context.Context) model.PodcastImageRepository { + return NewPodcastImageRepository(ctx, s.getDBXBuilder()) +} + func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: diff --git a/persistence/podcast_funding_repository.go b/persistence/podcast_funding_repository.go new file mode 100644 index 000000000..c6b1b97c4 --- /dev/null +++ b/persistence/podcast_funding_repository.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastFundingRepository struct { + sqlRepository +} + +func NewPodcastFundingRepository(ctx context.Context, db dbx.Builder) model.PodcastFundingRepository { + r := &podcastFundingRepository{} + r.ctx = ctx + r.db = db + r.tableName = "podcast_funding" + r.registerModel(&model.PodcastFundingItem{}, nil) + return r +} + +func (r *podcastFundingRepository) GetByChannel(channelID string) (model.PodcastFundingItems, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}) + var result model.PodcastFundingItems + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastFundingRepository) GetByChannels(channelIDs []string) (model.PodcastFundingItems, error) { + if len(channelIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}) + var result model.PodcastFundingItems + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastFundingRepository) SaveForChannel(channelID string, items []model.PodcastFundingItem) error { + if err := r.delete(Eq{"channel_id": channelID}); err != nil { + return err + } + now := time.Now() + for i := range items { + items[i].ID = id.NewRandom() + items[i].ChannelID = channelID + items[i].CreatedAt = now + if _, err := r.put(items[i].ID, &items[i]); err != nil { + return err + } + } + return nil +} + +var _ model.PodcastFundingRepository = (*podcastFundingRepository)(nil) diff --git a/persistence/podcast_funding_repository_test.go b/persistence/podcast_funding_repository_test.go new file mode 100644 index 000000000..654c9186f --- /dev/null +++ b/persistence/podcast_funding_repository_test.go @@ -0,0 +1,167 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastFundingRepository", func() { + var repo model.PodcastFundingRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastFundingRepository(ctx, GetDBXBuilder()) + }) + + Describe("SaveForChannel and GetByChannel", func() { + It("saves and retrieves funding items", func() { + items := []model.PodcastFundingItem{ + {URL: "https://patreon.com/show", Text: "Support on Patreon"}, + {URL: "https://ko-fi.com/show", Text: "Buy me a coffee"}, + } + Expect(repo.SaveForChannel("pc-1", items)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("preserves sort_order in insertion order", func() { + items := []model.PodcastFundingItem{ + {URL: "https://first.example.com", Text: "First"}, + {URL: "https://second.example.com", Text: "Second"}, + {URL: "https://third.example.com", Text: "Third"}, + } + Expect(repo.SaveForChannel("pc-1", items)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + Expect(result[0].Text).To(Equal("First")) + Expect(result[1].Text).To(Equal("Second")) + Expect(result[2].Text).To(Equal("Third")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("replaces existing items on re-save", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://old.example.com", Text: "Old"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://new.example.com", Text: "New"}, + })).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Text).To(Equal("New")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("clears items when saved with nil slice", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://example.com", Text: "Something"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("does not affect items of other channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://a.example.com", Text: "Channel A"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastFundingItem{ + {URL: "https://b.example.com", Text: "Channel B"}, + })).To(Succeed()) + + resultA, _ := repo.GetByChannel("pc-1") + resultB, _ := repo.GetByChannel("pc-2") + Expect(resultA).To(HaveLen(1)) + Expect(resultB).To(HaveLen(1)) + Expect(resultA[0].Text).To(Equal("Channel A")) + Expect(resultB[0].Text).To(Equal("Channel B")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returns empty list for unknown channel", func() { + result, err := repo.GetByChannel("no-such-channel") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("assigns non-empty ID automatically", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://example.com", Text: "Test"}, + })).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("sets ChannelID on returned items", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://example.com", Text: "Test"}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].ChannelID).To(Equal("pc-1")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + }) + + Describe("GetByChannels — bulk query", func() { + It("returns items for multiple channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://a.example.com", Text: "Feed A"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastFundingItem{ + {URL: "https://b.example.com", Text: "Feed B"}, + })).To(Succeed()) + + result, err := repo.GetByChannels([]string{"pc-1", "pc-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + texts := []string{result[0].Text, result[1].Text} + Expect(texts).To(ConsistOf("Feed A", "Feed B")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returned items carry ChannelID for grouping", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://a.example.com", Text: "A"}, + })).To(Succeed()) + + result, _ := repo.GetByChannels([]string{"pc-1"}) + Expect(result[0].ChannelID).To(Equal("pc-1")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByChannels([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) +}) diff --git a/persistence/podcast_image_repository.go b/persistence/podcast_image_repository.go new file mode 100644 index 000000000..a1cbdf5cd --- /dev/null +++ b/persistence/podcast_image_repository.go @@ -0,0 +1,94 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastImageRepository struct { + sqlRepository +} + +func NewPodcastImageRepository(ctx context.Context, db dbx.Builder) model.PodcastImageRepository { + r := &podcastImageRepository{} + r.ctx = ctx + r.db = db + r.tableName = "podcast_image" + r.registerModel(&model.PodcastImage{}, nil) + return r +} + +func (r *podcastImageRepository) GetByChannel(channelID string) (model.PodcastImages, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) GetByChannels(channelIDs []string) (model.PodcastImages, error) { + if len(channelIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) GetByEpisode(episodeID string) (model.PodcastImages, error) { + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeID}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) GetByEpisodes(episodeIDs []string) (model.PodcastImages, error) { + if len(episodeIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeIDs}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) SaveForChannel(channelID string, images []model.PodcastImage) error { + if err := r.delete(Eq{"channel_id": channelID}); err != nil { + return err + } + now := time.Now() + for i := range images { + images[i].ID = id.NewRandom() + images[i].ChannelID = channelID + images[i].EpisodeID = "" + images[i].CreatedAt = now + if _, err := r.put(images[i].ID, &images[i]); err != nil { + return err + } + } + return nil +} + +func (r *podcastImageRepository) SaveForEpisode(episodeID string, images []model.PodcastImage) error { + if err := r.delete(Eq{"episode_id": episodeID}); err != nil { + return err + } + now := time.Now() + for i := range images { + images[i].ID = id.NewRandom() + images[i].EpisodeID = episodeID + images[i].ChannelID = "" + images[i].CreatedAt = now + if _, err := r.put(images[i].ID, &images[i]); err != nil { + return err + } + } + return nil +} + +var _ model.PodcastImageRepository = (*podcastImageRepository)(nil) diff --git a/persistence/podcast_image_repository_test.go b/persistence/podcast_image_repository_test.go new file mode 100644 index 000000000..440bfbfe1 --- /dev/null +++ b/persistence/podcast_image_repository_test.go @@ -0,0 +1,239 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastImageRepository", func() { + var repo model.PodcastImageRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastImageRepository(ctx, GetDBXBuilder()) + }) + + Describe("SaveForChannel and GetByChannel", func() { + It("saves and retrieves channel images", func() { + images := []model.PodcastImage{ + {URL: "https://example.com/img-3000.jpg", Width: 3000}, + {URL: "https://example.com/img-300.jpg", Width: 300}, + } + Expect(repo.SaveForChannel("pc-1", images)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("persists URL and Width correctly", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img-1500.jpg", Width: 1500}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].URL).To(Equal("https://example.com/img-1500.jpg")) + Expect(result[0].Width).To(Equal(1500)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("sets ChannelID and empty EpisodeID on returned items", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].ChannelID).To(Equal("pc-1")) + Expect(result[0].EpisodeID).To(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("assigns non-empty ID automatically", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg"}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("replaces existing images on re-save", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/old.jpg", Width: 100}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/new.jpg", Width: 200}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/new.jpg")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("clears images when saved with nil slice", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result).To(BeEmpty()) + }) + + It("does not return episode images for channel queries", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/channel.jpg", Width: 3000}, + })).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/episode.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/channel.jpg")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("returns empty list for unknown channel", func() { + result, err := repo.GetByChannel("no-such-channel") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("SaveForEpisode and GetByEpisode", func() { + It("saves and retrieves episode images", func() { + images := []model.PodcastImage{ + {URL: "https://example.com/ep-600.jpg", Width: 600}, + {URL: "https://example.com/ep-150.jpg", Width: 150}, + } + Expect(repo.SaveForEpisode("ep-1", images)).To(Succeed()) + + result, err := repo.GetByEpisode("ep-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("sets EpisodeID and empty ChannelID on returned items", func() { + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/ep.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByEpisode("ep-1") + Expect(result[0].EpisodeID).To(Equal("ep-1")) + Expect(result[0].ChannelID).To(BeEmpty()) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("does not return channel images for episode queries", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/channel.jpg", Width: 3000}, + })).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/episode.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByEpisode("ep-1") + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/episode.jpg")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("returns empty list for unknown episode", func() { + result, err := repo.GetByEpisode("no-such-episode") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("GetByChannels — bulk query", func() { + It("returns images for multiple channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://a.example.com/img.jpg", Width: 3000}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastImage{ + {URL: "https://b.example.com/img.jpg", Width: 1500}, + })).To(Succeed()) + + result, err := repo.GetByChannels([]string{"pc-1", "pc-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returned items carry ChannelID for grouping", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByChannels([]string{"pc-1"}) + Expect(result[0].ChannelID).To(Equal("pc-1")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByChannels([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) + + Describe("GetByEpisodes — bulk query", func() { + It("returns images for multiple episodes", func() { + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://ep1.example.com/img.jpg", Width: 600}, + })).To(Succeed()) + Expect(repo.SaveForEpisode("ep-2", []model.PodcastImage{ + {URL: "https://ep2.example.com/img.jpg", Width: 300}, + })).To(Succeed()) + + result, err := repo.GetByEpisodes([]string{"ep-1", "ep-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("ep-2", nil)).To(Succeed()) + }) + + It("returned items carry EpisodeID for grouping", func() { + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/ep.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByEpisodes([]string{"ep-1"}) + Expect(result[0].EpisodeID).To(Equal("ep-1")) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByEpisodes([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) +}) diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index 220376b15..efe6944c9 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 1b16dfc68..d43ce5a52 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -28,7 +28,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil, nil) }) Describe("Scrobble", func() { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 7610c866a..a24b8faeb 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -41,7 +41,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedRadio: radioRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index c7775c0fa..f5690b071 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -25,7 +25,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -331,7 +331,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/podcasts.go b/server/subsonic/podcasts.go index 08bb1f6d5..2bfd263fa 100644 --- a/server/subsonic/podcasts.go +++ b/server/subsonic/podcasts.go @@ -75,9 +75,26 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { } } - // Bulk-load episode transcripts and persons when including episodes + // Bulk-load podcast:funding items + fundingRepo := api.ds.PodcastFunding(ctx) + allFunding, _ := fundingRepo.GetByChannels(channelIDs) + fundingMap := make(map[string]model.PodcastFundingItems) + for _, f := range allFunding { + fundingMap[f.ChannelID] = append(fundingMap[f.ChannelID], f) + } + + // Bulk-load podcast:image (channel level) + imageRepo := api.ds.PodcastImage(ctx) + allChannelImages, _ := imageRepo.GetByChannels(channelIDs) + channelImageMap := make(map[string]model.PodcastImages) + for _, img := range allChannelImages { + channelImageMap[img.ChannelID] = append(channelImageMap[img.ChannelID], img) + } + + // Bulk-load episode transcripts, persons, and images when including episodes var epTranscripts map[string]model.PodcastTranscripts var epPersons map[string]model.PodcastPersons + var epImages map[string]model.PodcastImages if includeEpisodes { var epIDs []string for _, ch := range channels { @@ -101,6 +118,13 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { epPersons[p.EpisodeID] = append(epPersons[p.EpisodeID], p) } } + allEpImages, err := imageRepo.GetByEpisodes(epIDs) + if err == nil { + epImages = make(map[string]model.PodcastImages) + for _, img := range allEpImages { + epImages[img.EpisodeID] = append(epImages[img.EpisodeID], img) + } + } } } @@ -119,10 +143,14 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { PodcastGuid: ch.PodcastGUID, Locked: ch.Locked, Medium: ch.Medium, - FundingUrl: ch.FundingURL, - FundingText: ch.FundingText, UpdateFrequency: ch.UpdateFrequency, Complete: ch.Complete, + LocationName: ch.LocationName, + LocationGeo: ch.LocationGeo, + LocationOSM: ch.LocationOSM, + License: ch.License, + PublisherName: ch.PublisherName, + PublisherURL: ch.PublisherURL, // Podcasting 2.0 Tier 3 UsesPodping: ch.UsesPodping, } @@ -135,6 +163,18 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { Href: p.Href, }) } + for _, f := range fundingMap[ch.ID] { + rch.Funding = append(rch.Funding, responses.PodcastFundingResp{ + URL: f.URL, + Text: f.Text, + }) + } + for _, img := range channelImageMap[ch.ID] { + rch.Images = append(rch.Images, responses.PodcastImageResp{ + URL: img.URL, + Width: img.Width, + }) + } for _, pr := range podrollMap[ch.ID] { rch.Podroll = append(rch.Podroll, responses.PodcastPodrollResp{ FeedGUID: pr.FeedGUID, @@ -164,6 +204,7 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { for _, ep := range ch.Episodes { ep.Transcripts = epTranscripts[ep.ID] ep.Persons = epPersons[ep.ID] + ep.Images = epImages[ep.ID] rch.Episode = append(rch.Episode, buildPodcastEpisode(ep)) } } @@ -291,6 +332,7 @@ func (api *Router) GetPodcastEpisode(r *http.Request) (*responses.Subsonic, erro ep.Transcripts, _ = api.ds.PodcastTranscript(ctx).GetByEpisode(ep.ID) ep.Persons, _ = api.ds.PodcastPerson(ctx).GetByEpisode(ep.ID) + ep.Images, _ = api.ds.PodcastImage(ctx).GetByEpisode(ep.ID) resp := newResponse() re := buildPodcastEpisode(*ep) @@ -321,6 +363,10 @@ func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode { ChaptersUrl: ep.ChaptersURL, SoundbiteStart: ep.SoundbiteStart, SoundbiteDur: ep.SoundbiteDur, + LocationName: ep.LocationName, + LocationGeo: ep.LocationGeo, + LocationOSM: ep.LocationOSM, + License: ep.License, } if !ep.PublishDate.IsZero() { re.PublishDate = ep.PublishDate.UTC().Format(time.RFC3339) @@ -342,6 +388,12 @@ func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode { Href: p.Href, }) } + for _, img := range ep.Images { + re.Images = append(re.Images, responses.PodcastImageResp{ + URL: img.URL, + Width: img.Width, + }) + } return re } diff --git a/server/subsonic/podcasts_test.go b/server/subsonic/podcasts_test.go index 17aaeba3c..e9157fb26 100644 --- a/server/subsonic/podcasts_test.go +++ b/server/subsonic/podcasts_test.go @@ -343,14 +343,20 @@ var _ = Describe("Podcasts", func() { Expect(resp.Podcasts.Channel[0].Medium).To(Equal("podcast")) }) - It("includes fundingUrl and fundingText in response", func() { + It("includes funding items in response", func() { + fundingRepo := tests.CreateMockPodcastFundingRepo() + _ = fundingRepo.SaveForChannel("ch-p20", []model.PodcastFundingItem{ + {URL: "https://example.com/donate", Text: "Support us!"}, + }) + ds.MockedPodcastFunding = fundingRepo r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) r = r.WithContext(userCtx) resp, err := api.GetPodcasts(r) Expect(err).ToNot(HaveOccurred()) ch := resp.Podcasts.Channel[0] - Expect(ch.FundingUrl).To(Equal("https://example.com/donate")) - Expect(ch.FundingText).To(Equal("Support us!")) + Expect(ch.Funding).To(HaveLen(1)) + Expect(ch.Funding[0].URL).To(Equal("https://example.com/donate")) + Expect(ch.Funding[0].Text).To(Equal("Support us!")) }) It("includes updateFrequency in response", func() { diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c0be0c012..62736f452 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -704,6 +704,16 @@ type NewestPodcasts struct { Episode []Child `xml:"episode,omitempty" json:"episode,omitempty"` } +type PodcastFundingResp struct { + URL string `xml:"url,attr,omitempty" json:"url,omitempty"` + Text string `xml:"text,attr,omitempty" json:"text,omitempty"` +} + +type PodcastImageResp struct { + URL string `xml:"url,attr,omitempty" json:"url,omitempty"` + Width int `xml:"width,attr,omitempty" json:"width,omitempty"` +} + type PodcastChannel struct { ID string `xml:"id,attr" json:"id"` URL string `xml:"url,attr" json:"url"` @@ -716,14 +726,20 @@ type PodcastChannel struct { Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"` // Podcasting 2.0 Tier 1 & 2 - PodcastGuid string `xml:"podcastGuid,attr,omitempty" json:"podcastGuid,omitempty"` - Locked bool `xml:"locked,attr,omitempty" json:"locked,omitempty"` - Medium string `xml:"medium,attr,omitempty" json:"medium,omitempty"` - FundingUrl string `xml:"fundingUrl,attr,omitempty" json:"fundingUrl,omitempty"` - FundingText string `xml:"fundingText,attr,omitempty" json:"fundingText,omitempty"` - UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"` - Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"` - Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` + PodcastGuid string `xml:"podcastGuid,attr,omitempty" json:"podcastGuid,omitempty"` + Locked bool `xml:"locked,attr,omitempty" json:"locked,omitempty"` + Medium string `xml:"medium,attr,omitempty" json:"medium,omitempty"` + UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"` + Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"` + LocationName string `xml:"locationName,attr,omitempty" json:"locationName,omitempty"` + LocationGeo string `xml:"locationGeo,attr,omitempty" json:"locationGeo,omitempty"` + LocationOSM string `xml:"locationOsm,attr,omitempty" json:"locationOsm,omitempty"` + License string `xml:"license,attr,omitempty" json:"license,omitempty"` + PublisherName string `xml:"publisherName,attr,omitempty" json:"publisherName,omitempty"` + PublisherURL string `xml:"publisherUrl,attr,omitempty" json:"publisherUrl,omitempty"` + Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` + Funding []PodcastFundingResp `xml:"funding,omitempty" json:"funding,omitempty"` + Images []PodcastImageResp `xml:"image,omitempty" json:"images,omitempty"` // Podcasting 2.0 Tier 3 UsesPodping bool `xml:"usesPodping,attr,omitempty" json:"usesPodping,omitempty"` @@ -775,8 +791,13 @@ type PodcastEpisode struct { ChaptersUrl string `xml:"chaptersUrl,attr,omitempty" json:"chaptersUrl,omitempty"` SoundbiteStart float64 `xml:"soundbiteStart,attr,omitempty" json:"soundbiteStart,omitempty"` SoundbiteDur float64 `xml:"soundbiteDur,attr,omitempty" json:"soundbiteDur,omitempty"` + LocationName string `xml:"locationName,attr,omitempty" json:"locationName,omitempty"` + LocationGeo string `xml:"locationGeo,attr,omitempty" json:"locationGeo,omitempty"` + LocationOSM string `xml:"locationOsm,attr,omitempty" json:"locationOsm,omitempty"` + License string `xml:"license,attr,omitempty" json:"license,omitempty"` Transcript []PodcastTranscriptResp `xml:"transcript,omitempty" json:"transcript,omitempty"` Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` + Images []PodcastImageResp `xml:"image,omitempty" json:"images,omitempty"` } type PodcastTranscriptResp struct { diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index d31a50cfa..21776b5d5 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -23,7 +23,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 1f2fdfcef..a7649139c 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -32,7 +32,7 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo = &tests.MockMediaFileRepo{} ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo} mockTD = &mockTranscodeDecision{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil) w = httptest.NewRecorder() }) @@ -508,7 +508,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for direct play", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) mockTD.resolvedReq = stream.Request{} @@ -525,7 +525,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for transcoding", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) mockTD.resolvedReq = stream.Request{ Format: "mp3", diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 26406b748..aad7261c0 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -36,6 +36,8 @@ type MockDataStore struct { MockedPodcastPerson model.PodcastPersonRepository MockedPodcastPodroll model.PodcastPodrollRepository MockedPodcastLiveItem model.PodcastLiveItemRepository + MockedPodcastFunding model.PodcastFundingRepository + MockedPodcastImage model.PodcastImageRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -405,6 +407,28 @@ func (db *MockDataStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveI return db.MockedPodcastLiveItem } +func (db *MockDataStore) PodcastFunding(ctx context.Context) model.PodcastFundingRepository { + if db.MockedPodcastFunding != nil { + return db.MockedPodcastFunding + } + if db.RealDS != nil { + return db.RealDS.PodcastFunding(ctx) + } + db.MockedPodcastFunding = CreateMockPodcastFundingRepo() + return db.MockedPodcastFunding +} + +func (db *MockDataStore) PodcastImage(ctx context.Context) model.PodcastImageRepository { + if db.MockedPodcastImage != nil { + return db.MockedPodcastImage + } + if db.RealDS != nil { + return db.RealDS.PodcastImage(ctx) + } + db.MockedPodcastImage = CreateMockPodcastImageRepo() + return db.MockedPodcastImage +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_podcast_funding_repo.go b/tests/mock_podcast_funding_repo.go new file mode 100644 index 000000000..2d6bada04 --- /dev/null +++ b/tests/mock_podcast_funding_repo.go @@ -0,0 +1,66 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastFundingRepo struct { + model.PodcastFundingRepository + Data map[string]*model.PodcastFundingItem + Err bool +} + +func CreateMockPodcastFundingRepo() *MockPodcastFundingRepo { + return &MockPodcastFundingRepo{Data: map[string]*model.PodcastFundingItem{}} +} + +func (m *MockPodcastFundingRepo) GetByChannel(channelID string) (model.PodcastFundingItems, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastFundingItems + for _, f := range m.Data { + if f.ChannelID == channelID { + result = append(result, *f) + } + } + return result, nil +} + +func (m *MockPodcastFundingRepo) GetByChannels(channelIDs []string) (model.PodcastFundingItems, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(channelIDs)) + for _, id := range channelIDs { + ids[id] = true + } + var result model.PodcastFundingItems + for _, f := range m.Data { + if ids[f.ChannelID] { + result = append(result, *f) + } + } + return result, nil +} + +func (m *MockPodcastFundingRepo) SaveForChannel(channelID string, items []model.PodcastFundingItem) error { + if m.Err { + return errors.New("error") + } + for k, f := range m.Data { + if f.ChannelID == channelID { + delete(m.Data, k) + } + } + for i := range items { + items[i].ID = id.NewRandom() + items[i].ChannelID = channelID + f := items[i] + m.Data[f.ID] = &f + } + return nil +} diff --git a/tests/mock_podcast_image_repo.go b/tests/mock_podcast_image_repo.go new file mode 100644 index 000000000..b0acb2791 --- /dev/null +++ b/tests/mock_podcast_image_repo.go @@ -0,0 +1,114 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastImageRepo struct { + model.PodcastImageRepository + Data map[string]*model.PodcastImage + Err bool +} + +func CreateMockPodcastImageRepo() *MockPodcastImageRepo { + return &MockPodcastImageRepo{Data: map[string]*model.PodcastImage{}} +} + +func (m *MockPodcastImageRepo) GetByChannel(channelID string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastImages + for _, img := range m.Data { + if img.ChannelID == channelID { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) GetByChannels(channelIDs []string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(channelIDs)) + for _, id := range channelIDs { + ids[id] = true + } + var result model.PodcastImages + for _, img := range m.Data { + if ids[img.ChannelID] { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) GetByEpisode(episodeID string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastImages + for _, img := range m.Data { + if img.EpisodeID == episodeID { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) GetByEpisodes(episodeIDs []string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(episodeIDs)) + for _, id := range episodeIDs { + ids[id] = true + } + var result model.PodcastImages + for _, img := range m.Data { + if ids[img.EpisodeID] { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) SaveForChannel(channelID string, images []model.PodcastImage) error { + if m.Err { + return errors.New("error") + } + for k, img := range m.Data { + if img.ChannelID == channelID { + delete(m.Data, k) + } + } + for i := range images { + images[i].ID = id.NewRandom() + images[i].ChannelID = channelID + img := images[i] + m.Data[img.ID] = &img + } + return nil +} + +func (m *MockPodcastImageRepo) SaveForEpisode(episodeID string, images []model.PodcastImage) error { + if m.Err { + return errors.New("error") + } + for k, img := range m.Data { + if img.EpisodeID == episodeID { + delete(m.Data, k) + } + } + for i := range images { + images[i].ID = id.NewRandom() + images[i].EpisodeID = episodeID + img := images[i] + m.Data[img.ID] = &img + } + return nil +} From 3ce614188753839655852402ff6c3b253f67ae3d Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Tue, 28 Apr 2026 16:34:27 +0900 Subject: [PATCH 08/20] feat(ui): improve podcast display in playlist and podcast views - Show podcast-aware album link in playlist (navigates to podcast channel page) - Show '-' instead of '[Unknown Artist]' for podcast episodes with no artist - Use best-resolution image URL in podcast list view - Add srcSet/sizes for responsive cover images in podcast grid and show views --- ui/src/playlist/PlaylistSongs.jsx | 5 ++++- ui/src/podcast/PodcastGridView.jsx | 13 ++++++++++++- ui/src/podcast/PodcastList.jsx | 9 ++++++++- ui/src/podcast/PodcastShow.jsx | 7 +++++++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx index 8f231e6a4..9c614a8dc 100644 --- a/ui/src/playlist/PlaylistSongs.jsx +++ b/ui/src/playlist/PlaylistSongs.jsx @@ -57,7 +57,10 @@ AlbumOrChannelLinkField.defaultProps = { addLabel: true, sortBy: 'album' } const PodcastAwareArtistField = (props) => { const record = props.record - if (record?.genre === 'Podcast' && !record?.artist) { + const isPodcast = record?.genre === 'Podcast' + const hasNoRealArtist = + !record?.artist || record?.artist === '[Unknown Artist]' + if (isPodcast && hasNoRealArtist) { return - } return diff --git a/ui/src/podcast/PodcastGridView.jsx b/ui/src/podcast/PodcastGridView.jsx index 7de199af9..ff5bd27b7 100644 --- a/ui/src/podcast/PodcastGridView.jsx +++ b/ui/src/podcast/PodcastGridView.jsx @@ -42,6 +42,11 @@ const useStyles = makeStyles((theme) => ({ }, })) +const buildSrcSet = (images) => { + if (!images || images.length === 0) return undefined + return images.map((img) => `${img.url} ${img.width}w`).join(', ') +} + const getColsForWidth = (width) => { if (width === 'xs') return 2 if (width === 'sm') return 3 @@ -67,7 +72,13 @@ const PodcastGridView = ({ width, ...props }) => {
{record.imageUrl ? ( - {record.title} + {record.title} ) : (
diff --git a/ui/src/podcast/PodcastList.jsx b/ui/src/podcast/PodcastList.jsx index 144ef5b1c..d67984827 100644 --- a/ui/src/podcast/PodcastList.jsx +++ b/ui/src/podcast/PodcastList.jsx @@ -42,11 +42,18 @@ const PodcastFilter = (props) => ( ) +const bestImageUrl = (record, targetWidth) => { + if (!record.images || record.images.length === 0) return record.imageUrl + const sorted = [...record.images].sort((a, b) => a.width - b.width) + const best = sorted.find((img) => img.width >= targetWidth) || sorted[sorted.length - 1] + return best ? best.url : record.imageUrl +} + const CoverArtField = ({ record }) => { if (!record) return null if (record.imageUrl) { return ( - + ) } return ( diff --git a/ui/src/podcast/PodcastShow.jsx b/ui/src/podcast/PodcastShow.jsx index 1fbcaee89..6f98b4d5a 100644 --- a/ui/src/podcast/PodcastShow.jsx +++ b/ui/src/podcast/PodcastShow.jsx @@ -88,6 +88,11 @@ const useStyles = makeStyles((theme) => ({ tableWrapper: { marginTop: theme.spacing(2), overflowX: 'auto' }, })) +const buildSrcSet = (images) => { + if (!images || images.length === 0) return undefined + return images.map((img) => `${img.url} ${img.width}w`).join(', ') +} + const formatDuration = (seconds) => { if (!seconds) return '' const h = Math.floor(seconds / 3600) @@ -153,6 +158,8 @@ const PodcastShow = (props) => { {record.imageUrl ? ( {record.title} From b6b91709f01681670604fc0b3206c24d12cefc63 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Tue, 28 Apr 2026 16:39:50 +0900 Subject: [PATCH 09/20] feat(i18n): complete Korean translation Add missing 105 keys across all resource categories: - Song: composer, albumGain, trackGain, disc, showInPlaylist, instantMix - Artist: podcast action - Podcast: full resource (name, fields, actions, notifications, status) - Library: quickScan/fullScan actions and notifications, fix scanStarted typo - Plugin: full resource (fields, sections, status, actions, notifications, validation, messages, placeholders) - Message: cover art upload/remove, startingInstantMix - About.config: downloadToml - Activity: selectiveScan --- resources/i18n/ko.json | 147 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 140 insertions(+), 7 deletions(-) diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json index 6b81e02d8..711e5acaf 100644 --- a/resources/i18n/ko.json +++ b/resources/i18n/ko.json @@ -36,7 +36,11 @@ "tags": "추가 태그", "mappedTags": "매핑된 태그", "rawTags": "원시 태그", - "missing": "누락" + "missing": "누락", + "composer": "작곡가", + "albumGain": "앨범 게인", + "trackGain": "트랙 게인", + "disc": "디스크 %{discNumber}" }, "actions": { "addToQueue": "나중에 재생", @@ -45,7 +49,9 @@ "shuffleAll": "모든 노래 셔플", "download": "다운로드", "playNext": "다음 재생", - "info": "정보 얻기" + "info": "정보 얻기", + "showInPlaylist": "재생목록에서 보기", + "instantMix": "인스턴트 믹스" } }, "album": { @@ -131,7 +137,8 @@ "actions": { "topSongs": "인기곡", "shuffle": "셔플", - "radio": "라디오" + "radio": "라디오", + "podcast": "팟캐스트" } }, "user": { @@ -237,6 +244,42 @@ "playNow": "지금 재생" } }, + "podcast": { + "name": "팟캐스트 |||| 팟캐스트", + "fields": { + "title": "제목", + "url": "피드 URL", + "description": "설명", + "status": "상태", + "publishDate": "게시됨", + "duration": "시간", + "episodeCount": "에피소드" + }, + "actions": { + "refresh": "전체 갱신", + "download": "다운로드", + "addChannel": "채널 추가", + "fetchFeed": "피드 정보 가져오기", + "copyUrl": "피드 URL 복사" + }, + "notifications": { + "refreshStarted": "팟캐스트 갱신 시작됨", + "downloadStarted": "다운로드 시작됨", + "channelAdded": "채널 추가됨", + "urlCopied": "피드 URL이 클립보드에 복사됨", + "alreadyExists": "이미 등록된 채널입니다.", + "channelDeleted": "채널 삭제됨", + "episodeDeleted": "에피소드 삭제됨" + }, + "status": { + "new": "새 항목", + "downloading": "다운로드 중", + "completed": "다운로드됨", + "error": "오류", + "skipped": "건너뜀", + "deleted": "삭제됨" + } + }, "share": { "name": "공유 |||| 공유되는 것들", "fields": { @@ -301,15 +344,20 @@ }, "actions": { "scan": "라이브러리 스캔", - "manageUsers": "자용자 접속 관리", + "quickScan": "빠른 스캔", + "fullScan": "전체 스캔", + "manageUsers": "사용자 접속 관리", "viewDetails": "상세 보기" }, "notifications": { "created": "라이브러리가 성공적으로 생성됨", "updated": "라이브러리가 성공적으로 업데이트됨", "deleted": "라이브러리가 성공적으로 삭제됨", - "scanStarted": "라이브러리 스캔 스작됨", - "scanCompleted": "라이브러리 스캔 완료됨" + "scanStarted": "라이브러리 스캔 시작됨", + "scanCompleted": "라이브러리 스캔 완료됨", + "quickScanStarted": "빠른 스캔 시작됨", + "fullScanStarted": "전체 스캔 시작됨", + "scanError": "스캔 시작 오류. 로그를 확인하세요" }, "validation": { "nameRequired": "라이브러리 이름이 필요함", @@ -324,6 +372,82 @@ "scanInProgress": "스캔 진행 중...", "noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음" } + }, + "plugin": { + "name": "플러그인 |||| 플러그인들", + "fields": { + "id": "ID", + "name": "이름", + "description": "설명", + "version": "버전", + "author": "작성자", + "website": "웹사이트", + "permissions": "권한", + "enabled": "활성화", + "status": "상태", + "path": "경로", + "lastError": "오류", + "hasError": "오류", + "updatedAt": "업데이트됨", + "createdAt": "설치됨", + "configKey": "키", + "configValue": "값", + "allUsers": "모든 사용자 허용", + "selectedUsers": "선택된 사용자", + "allLibraries": "모든 라이브러리 허용", + "selectedLibraries": "선택된 라이브러리", + "allowWriteAccess": "쓰기 접근 허용" + }, + "sections": { + "status": "상태", + "info": "플러그인 정보", + "configuration": "구성", + "manifest": "매니페스트", + "usersPermission": "사용자 권한", + "libraryPermission": "라이브러리 권한" + }, + "status": { + "enabled": "활성화", + "disabled": "비활성화" + }, + "actions": { + "enable": "활성화", + "disable": "비활성화", + "disabledDueToError": "활성화하기 전에 오류를 수정하세요", + "disabledUsersRequired": "활성화하기 전에 사용자를 선택하세요", + "disabledLibrariesRequired": "활성화하기 전에 라이브러리를 선택하세요", + "addConfig": "구성 추가", + "rescan": "재스캔" + }, + "notifications": { + "enabled": "플러그인 활성화됨", + "disabled": "플러그인 비활성화됨", + "updated": "플러그인 업데이트됨", + "error": "플러그인 업데이트 오류" + }, + "validation": { + "invalidJson": "구성은 유효한 JSON이어야 함" + }, + "messages": { + "configHelp": "키-값 쌍을 사용하여 플러그인을 구성하세요. 구성이 필요 없는 경우 비워두세요.", + "configValidationError": "구성 유효성 검사 실패:", + "schemaRenderError": "구성 양식을 렌더링할 수 없습니다. 플러그인의 스키마가 유효하지 않을 수 있습니다.", + "clickPermissions": "권한을 클릭하면 자세한 내용을 볼 수 있음", + "noConfig": "설정된 구성 없음", + "allUsersHelp": "활성화하면 플러그인이 미래에 생성될 사용자를 포함한 모든 사용자에 접근할 수 있습니다.", + "noUsers": "선택된 사용자 없음", + "permissionReason": "이유", + "usersRequired": "이 플러그인은 사용자 정보에 접근해야 합니다. 플러그인이 접근할 수 있는 사용자를 선택하거나 '모든 사용자 허용'을 활성화하세요.", + "allLibrariesHelp": "활성화하면 플러그인이 미래에 생성될 라이브러리를 포함한 모든 라이브러리에 접근할 수 있습니다.", + "noLibraries": "선택된 라이브러리 없음", + "librariesRequired": "이 플러그인은 라이브러리 정보에 접근해야 합니다. 플러그인이 접근할 수 있는 라이브러리를 선택하거나 '모든 라이브러리 허용'을 활성화하세요.", + "allowWriteAccessHelp": "활성화하면 플러그인이 라이브러리 디렉터리의 파일을 수정할 수 있습니다. 기본적으로 플러그인은 읽기 전용 접근 권한을 가집니다.", + "requiredHosts": "필수 호스트" + }, + "placeholders": { + "configKey": "키", + "configValue": "값" + } } }, "ra": { @@ -507,7 +631,14 @@ "shareSuccess": "URL이 클립보드에 복사되었음: %{url}", "shareFailure": "URL %{url}을 클립보드에 복사하는 중 오류가 발생하였음", "downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드", - "downloadOriginalFormat": "오리지널 형식으로 다운로드" + "downloadOriginalFormat": "오리지널 형식으로 다운로드", + "uploadCover": "커버 업로드", + "removeCover": "커버 제거", + "coverUploaded": "커버 아트 업데이트됨", + "coverRemoved": "커버 아트 제거됨", + "coverUploadError": "커버 아트 업로드 오류", + "coverRemoveError": "커버 아트 제거 오류", + "startingInstantMix": "인스턴트 믹스 로딩 중..." }, "menu": { "library": "라이브러리", @@ -590,6 +721,7 @@ "currentValue": "현재 값", "configurationFile": "구성 파일", "exportToml": "구성 내보내기 (TOML)", + "downloadToml": "구성 다운로드 (TOML)", "exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성", "exportFailed": "구성 복사 실패", "devFlagsHeader": "개발 플래그 (변경/삭제 가능)", @@ -604,6 +736,7 @@ "serverUptime": "서버 가동 시간", "serverDown": "오프라인", "scanType": "유형", + "selectiveScan": "선택적", "status": "스캔 오류", "elapsedTime": "경과 시간" }, From aa2d89611ef6d0c3a992d44df80549fa4a3467cc Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Thu, 13 Aug 2026 14:21:12 +0900 Subject: [PATCH 10/20] fix(podcast): fix build failure and missing episode play/status UX server/e2e was failing to build because subsonic.New() gained a podcasts.Podcasts parameter that the sonic-similarity e2e test wasn't passing. Also fixes real bugs surfaced while getting the podcast tests green: completed episodes had no play button, the downloading status badge dropped its label text, and PodcastCreate's test suite assumed a single-step form submit instead of the actual preview-then-add flow. --- .../e2e/subsonic_sonic_similarity_test.go | 3 + ui/src/podcast/EpisodeActions.jsx | 36 +++++++++-- ui/src/podcast/PodcastCreate.test.jsx | 61 ++++++++++++++----- ui/src/podcast/PodcastShow.jsx | 6 +- ui/src/podcast/StatusBadge.jsx | 1 + 5 files changed, 86 insertions(+), 21 deletions(-) diff --git a/server/subsonic/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go index c0cb1d359..3c71a994c 100644 --- a/server/subsonic/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -10,11 +10,13 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/podcasts" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" @@ -51,6 +53,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, + podcasts.NewPodcastService(context.Background(), ds, ffmpeg.New(), events.NoopBroker()), sonicSvc, ) } diff --git a/ui/src/podcast/EpisodeActions.jsx b/ui/src/podcast/EpisodeActions.jsx index ce55cc0c8..edea9bd34 100644 --- a/ui/src/podcast/EpisodeActions.jsx +++ b/ui/src/podcast/EpisodeActions.jsx @@ -1,30 +1,56 @@ import React from 'react' +import { useDispatch } from 'react-redux' import { IconButton, CircularProgress } from '@material-ui/core' import GetAppIcon from '@material-ui/icons/GetApp' import DeleteIcon from '@material-ui/icons/Delete' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' import subsonic from '../subsonic' +import { setTrack } from '../actions' -const EpisodeActions = ({ episode, onRefresh }) => { +const EpisodeActions = ({ episode, onRefresh, channelTitle }) => { + const dispatch = useDispatch() const handleDownload = async () => { await subsonic.downloadPodcastEpisode(episode.id) onRefresh?.() } - const handleDelete = async () => { + const handleDelete = async (e) => { + e.stopPropagation() await subsonic.deletePodcastEpisode(episode.id) onRefresh?.() } + const handlePlay = (e) => { + e.stopPropagation() + dispatch( + setTrack({ + id: episode.streamId, + title: episode.title, + album: channelTitle || episode.channelId, + artist: '', + duration: episode.duration, + suffix: episode.suffix, + isPodcast: true, + channelId: episode.channelId, + }), + ) + } + if (episode.status === 'downloading') { return } if (episode.status === 'completed') { return ( - - - + <> + + + + + + + ) } diff --git a/ui/src/podcast/PodcastCreate.test.jsx b/ui/src/podcast/PodcastCreate.test.jsx index 2a88379a4..948594439 100644 --- a/ui/src/podcast/PodcastCreate.test.jsx +++ b/ui/src/podcast/PodcastCreate.test.jsx @@ -3,7 +3,17 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('../subsonic', () => ({ - default: { createPodcastChannel: vi.fn().mockResolvedValue({}) }, + default: { + createPodcastChannel: vi.fn().mockResolvedValue({}), + previewPodcastFeed: vi.fn().mockResolvedValue({ + json: { + title: 'Example Podcast', + description: 'An example feed', + episodeCount: 3, + alreadyExists: false, + }, + }), + }, })) const mockRedirect = vi.fn() @@ -19,12 +29,27 @@ vi.mock('react-admin', async (importOriginal) => { useRedirect: () => mockRedirect, useRefresh: () => mockRefresh, Title: () => null, + Button: ({ children, onClick, label, disabled }) => ( + + ), } }) import subsonic from '../subsonic' import PodcastCreate from './PodcastCreate' +const fetchPreview = async (url) => { + fireEvent.change(screen.getByRole('textbox'), { target: { value: url } }) + fireEvent.click( + screen.getByLabelText('resources.podcast.actions.fetchFeed'), + ) + await waitFor(() => { + expect(subsonic.previewPodcastFeed).toHaveBeenCalledWith(url) + }) +} + describe('PodcastCreate', () => { beforeEach(() => vi.clearAllMocks()) @@ -33,12 +58,18 @@ describe('PodcastCreate', () => { expect(screen.getByRole('textbox')).toBeTruthy() }) - it('calls createPodcastChannel with the entered URL on submit', async () => { + it('fetches a preview of the feed for the entered URL', async () => { render() - fireEvent.change(screen.getByRole('textbox'), { - target: { value: 'https://example.com/feed.xml' }, - }) - fireEvent.submit(screen.getByRole('form')) + await fetchPreview('https://example.com/feed.xml') + expect(await screen.findByText('Example Podcast')).toBeTruthy() + }) + + it('calls createPodcastChannel with the entered URL when adding the previewed channel', async () => { + render() + await fetchPreview('https://example.com/feed.xml') + fireEvent.click( + await screen.findByLabelText('resources.podcast.actions.addChannel'), + ) await waitFor(() => { expect(subsonic.createPodcastChannel).toHaveBeenCalledWith( 'https://example.com/feed.xml', @@ -46,12 +77,12 @@ describe('PodcastCreate', () => { }) }) - it('redirects to /podcast after successful submit', async () => { + it('redirects to /podcast after successfully adding the channel', async () => { render() - fireEvent.change(screen.getByRole('textbox'), { - target: { value: 'https://example.com/feed.xml' }, - }) - fireEvent.submit(screen.getByRole('form')) + await fetchPreview('https://example.com/feed.xml') + fireEvent.click( + await screen.findByLabelText('resources.podcast.actions.addChannel'), + ) await waitFor(() => { expect(mockRedirect).toHaveBeenCalledWith('/podcast') }) @@ -59,10 +90,10 @@ describe('PodcastCreate', () => { it('notifies on success', async () => { render() - fireEvent.change(screen.getByRole('textbox'), { - target: { value: 'https://example.com/feed.xml' }, - }) - fireEvent.submit(screen.getByRole('form')) + await fetchPreview('https://example.com/feed.xml') + fireEvent.click( + await screen.findByLabelText('resources.podcast.actions.addChannel'), + ) await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith( 'resources.podcast.notifications.channelAdded', diff --git a/ui/src/podcast/PodcastShow.jsx b/ui/src/podcast/PodcastShow.jsx index 6f98b4d5a..2a462effc 100644 --- a/ui/src/podcast/PodcastShow.jsx +++ b/ui/src/podcast/PodcastShow.jsx @@ -212,7 +212,11 @@ const PodcastShow = (props) => { - + ))} diff --git a/ui/src/podcast/StatusBadge.jsx b/ui/src/podcast/StatusBadge.jsx index fd968e1ee..a78a6f9b5 100644 --- a/ui/src/podcast/StatusBadge.jsx +++ b/ui/src/podcast/StatusBadge.jsx @@ -22,6 +22,7 @@ const StatusBadge = ({ status, errorMessage, downloadedBytes, size }) => { return ( + {label} {pct !== null && {`${pct}%`}} ) From 0e1dde287d73f36c1d888ffa15b1e667adad0bb0 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Thu, 13 Aug 2026 14:51:57 +0900 Subject: [PATCH 11/20] fix(podcast): resolve post-rebase breakage after rebasing onto latest master Rebasing onto master's Podcasting-branch-unrelated changes surfaced several integration gaps the merge conflicts didn't catch: - conf.Server.DataFolder is now a Dir type, not a string; podcasts.go and its tests needed .String()/conf.NewDir() at each call site. - subsonic.New() gained the podcasts.Podcasts parameter; several e2e/unit test call sites elsewhere in the tree were still passing the old arg count. - The uniform-canonical-ids migration (dated after our podcast migrations, so it runs against a schema that already has the podcast tables) didn't know about the new podcast_* id columns, leaving them unrewritten while everything else (including podcast_episode.stream_id's matching media_file.id) got canonicalized. - opensubsonic_test.go's expected extension count was miscounted during conflict resolution (9 Podcasting 2.0 extensions, not 7). - .gitignore's unanchored `podcasts/` entry from an earlier commit accidentally matched core/podcasts/ (source) in addition to the downloaded-episode directory; anchored both to their actual paths. --- .gitignore | 5 +++-- core/podcasts/podcasts.go | 6 +++--- core/podcasts/podcasts_test.go | 6 +++--- db/migrations/20260720015443_uniform_canonical_ids.go | 8 ++++++++ db/migrations/uniform_canonical_ids_test.go | 8 ++++++++ server/subsonic/e2e/e2e_suite_test.go | 1 + server/subsonic/e2e/subsonic_artwork_test.go | 2 +- server/subsonic/lyrics_test.go | 2 +- server/subsonic/opensubsonic_test.go | 8 ++++---- 9 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 1e4592b26..d0a5a17fa 100644 --- a/.gitignore +++ b/.gitignore @@ -49,5 +49,6 @@ zz_*_test.go /plugins/testdata/.wazero-cache/ /plugins/testdata/*.stage/ -# Downloaded podcast episode audio files should not be tracked -podcasts/ +# Downloaded podcast episode audio files should not be tracked (DataFolder defaults to ".") +/podcasts/ +/ui/podcasts/ diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 9f1022517..36340c018 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -56,7 +56,7 @@ func (s *podcastService) podcastLibraryID(ctx context.Context) (int, error) { } lib := &model.Library{ Name: podcastLibraryName, - Path: conf.Server.DataFolder, + Path: conf.Server.DataFolder.String(), } if err := s.ds.Library(ctx).Put(lib); err != nil { return 0, err @@ -296,7 +296,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod if suffix == "" { suffix = "mp3" } - dir := filepath.Join(conf.Server.DataFolder, "podcasts", ep.ChannelID) + dir := filepath.Join(conf.Server.DataFolder.String(), "podcasts", ep.ChannelID) if err := os.MkdirAll(dir, 0755); err != nil { s.setEpisodeError(ctx, ep, err) return @@ -345,7 +345,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod if libErr != nil { log.Warn(ctx, "Failed to get podcast library, streaming may not work", "episode", ep.ID, libErr) } else { - relPath := strings.TrimPrefix(dest, conf.Server.DataFolder+string(filepath.Separator)) + relPath := strings.TrimPrefix(dest, conf.Server.DataFolder.String()+string(filepath.Separator)) now := time.Now() tags := model.Tags{} tags.Add("genre", "Podcast") diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index af0dc9957..8672880b1 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -49,7 +49,7 @@ var _ = Describe("PodcastService", func() { })) DeferCleanup(mockServer.Close) - conf.Server.DataFolder = GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) svc = podcasts.NewPodcastService(ctx, ds, nil, nil) }) @@ -145,7 +145,7 @@ var _ = Describe("PodcastService", func() { It("creates the audio file at the expected path", func() { _ = svc.DownloadEpisode(ctx, "ep-1") - expectedPath := filepath.Join(conf.Server.DataFolder, "podcasts", "ch-1", "ep-1.mp3") + expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3") Eventually(func() bool { _, err := os.Stat(expectedPath) return err == nil @@ -161,7 +161,7 @@ var _ = Describe("PodcastService", func() { It("records the file path after download", func() { _ = svc.DownloadEpisode(ctx, "ep-1") - expectedPath := filepath.Join(conf.Server.DataFolder, "podcasts", "ch-1", "ep-1.mp3") + expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3") Eventually(func() string { return episodeRepo.Data["ep-1"].Path }, "3s").Should(Equal(expectedPath)) diff --git a/db/migrations/20260720015443_uniform_canonical_ids.go b/db/migrations/20260720015443_uniform_canonical_ids.go index 346f22050..c488ec8d8 100644 --- a/db/migrations/20260720015443_uniform_canonical_ids.go +++ b/db/migrations/20260720015443_uniform_canonical_ids.go @@ -77,6 +77,14 @@ var idColumns = []struct{ table, col string }{ {"media_file_artists", "media_file_id"}, {"media_file_artists", "artist_id"}, {"album_artists", "album_id"}, {"album_artists", "artist_id"}, {"library_tag", "tag_id"}, + {"podcast_channel", "id"}, + {"podcast_episode", "id"}, {"podcast_episode", "channel_id"}, {"podcast_episode", "stream_id"}, + {"podcast_transcript", "id"}, {"podcast_transcript", "episode_id"}, + {"podcast_person", "id"}, {"podcast_person", "channel_id"}, {"podcast_person", "episode_id"}, + {"podcast_podroll", "id"}, {"podcast_podroll", "channel_id"}, + {"podcast_live_item", "id"}, {"podcast_live_item", "channel_id"}, + {"podcast_funding", "id"}, {"podcast_funding", "channel_id"}, + {"podcast_image", "id"}, {"podcast_image", "channel_id"}, {"podcast_image", "episode_id"}, } // embeddedIDColumns holds ids nested inside a larger value; the id-columns guard checks this diff --git a/db/migrations/uniform_canonical_ids_test.go b/db/migrations/uniform_canonical_ids_test.go index ced75cb7e..05d796c83 100644 --- a/db/migrations/uniform_canonical_ids_test.go +++ b/db/migrations/uniform_canonical_ids_test.go @@ -65,6 +65,14 @@ var _ = Describe("upUniformCanonicalIds", func() { CREATE TABLE library_tag (tag_id text, library_id integer); CREATE TABLE plugin (id text, users text); CREATE TABLE property (id text primary key, value text); + CREATE TABLE podcast_channel (id text); + CREATE TABLE podcast_episode (id text, channel_id text, stream_id text); + CREATE TABLE podcast_transcript (id text, episode_id text); + CREATE TABLE podcast_person (id text, channel_id text, episode_id text); + CREATE TABLE podcast_podroll (id text, channel_id text); + CREATE TABLE podcast_live_item (id text, channel_id text); + CREATE TABLE podcast_funding (id text, channel_id text); + CREATE TABLE podcast_image (id text, channel_id text, episode_id text); `) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 9879fa181..11f3236c0 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 9324ea9e3..a4f72cd9f 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -257,7 +257,7 @@ func buildArtworkRouter(art artwork.Artwork) *subsonic.Router { ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), - playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, + playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, nil, ) } diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go index 8713b7a3b..479a9c748 100644 --- a/server/subsonic/lyrics_test.go +++ b/server/subsonic/lyrics_test.go @@ -23,7 +23,7 @@ var _ = Describe("GetLyricsBySongId", func() { ds = &tests.MockDataStore{ MockedMediaFile: mockRepo, } - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil, nil) DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" }) diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index f6ea32d59..0c42c5054 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,7 +44,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) - It("should return the base 15 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 17 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication @@ -55,7 +55,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(15), + HaveLen(17), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), @@ -86,7 +86,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) }) - It("should return 16 extensions including all podcast 2.0 and sonicSimilarity", func() { + It("should return 18 extensions including all podcast 2.0 and sonicSimilarity", func() { router.ServeHTTP(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -96,7 +96,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(16), + HaveLen(18), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), From d5cd6993d20ac69e494d58011ba9a69a6024a72c Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Mon, 7 Sep 2026 17:35:41 +0900 Subject: [PATCH 12/20] Update core/podcasts/podcasts.go Co-authored-by: strix-security[bot] <257889806+strix-security[bot]@users.noreply.github.com> --- core/podcasts/podcasts.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 36340c018..0782fcbc9 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -5,7 +5,9 @@ import ( "fmt" "io" "math" + "net" "net/http" + "net/url" "os" "os/exec" "path/filepath" From e66e53c5dd4651605b1a0b13d350b669a1d7b95f Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Mon, 7 Sep 2026 17:36:29 +0900 Subject: [PATCH 13/20] Update core/podcasts/podcasts.go Co-authored-by: strix-security[bot] <257889806+strix-security[bot]@users.noreply.github.com> --- core/podcasts/podcasts.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 0782fcbc9..cec853d03 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -532,6 +532,9 @@ func (pw *progressWriter) Write(p []byte) (int, error) { } func fetchAndParse(rssURL string) (*rssFeed, error) { + if err := validateURL(rssURL); err != nil { + return nil, fmt.Errorf("invalid RSS feed URL: %w", err) + } httpClient := &http.Client{Timeout: 15 * time.Second} resp, err := httpClient.Get(rssURL) //nolint:gosec if err != nil { From 8d3facf0ddb4004e90f4c1e487c1c7f6ac9c79bb Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Mon, 7 Sep 2026 17:36:36 +0900 Subject: [PATCH 14/20] Update server/nativeapi/podcasts.go Co-authored-by: strix-security[bot] <257889806+strix-security[bot]@users.noreply.github.com> --- server/nativeapi/podcasts.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/nativeapi/podcasts.go b/server/nativeapi/podcasts.go index a1599fdaf..a8e7561c9 100644 --- a/server/nativeapi/podcasts.go +++ b/server/nativeapi/podcasts.go @@ -18,7 +18,7 @@ func (api *Router) addPodcastRoute(r chi.Router) { } r.Route("/podcast", func(r chi.Router) { r.Get("/", rest.GetAll(constructor)) - r.Get("/preview", api.podcastPreview) + r.With(adminOnlyMiddleware).Get("/preview", api.podcastPreview) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) r.Get("/", rest.Get(constructor)) From 7338461efe49e219a3bcd4463095f6abdc7dc333 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Mon, 7 Sep 2026 17:36:45 +0900 Subject: [PATCH 15/20] Update core/podcasts/podcasts.go Co-authored-by: strix-security[bot] <257889806+strix-security[bot]@users.noreply.github.com> --- core/podcasts/podcasts.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index cec853d03..8714c5d2d 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -312,6 +312,10 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod } defer f.Close() + if err := validateURL(ep.EnclosureURL); err != nil { + s.setEpisodeError(ctx, ep, fmt.Errorf("invalid enclosure URL: %w", err)) + return + } httpClient := &http.Client{Timeout: 30 * time.Second} resp, err := httpClient.Get(ep.EnclosureURL) //nolint:gosec if err != nil { From 23f28aac669c8aebea67a8e7aeb1f8a9c1a653fd Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Sat, 19 Sep 2026 20:37:25 +0900 Subject: [PATCH 16/20] fix(podcast): implement missing SSRF validation and fix migration boot failure Two blocking bugs found while reviewing this branch: 1. Compile error: validateURL() was called in fetchAndParse/doDownload (added while applying Strix's SSRF suggestions) but the function itself was never committed - only "Add validateURL and isReservedIP helper functions" was left as a plain-text suggestion with no one-click apply, and it got missed. Implemented validateURL/isReservedIP plus a safeHTTPTransport whose DialContext re-resolves and re-checks the target IP at actual connection time (not just once via a URL pre-check), so a DNS answer that changes between the check and the request (DNS rebinding) can't reach a reserved address - this also covers HTTP redirect targets for free, since redirects reuse the same Transport. Added AllowLoopbackHTTPForTests() so the existing httptest-based suite (which binds to 127.0.0.1) still passes without weakening the guard for any other address. 2. Migration boot failure: the podcast migrations were dated 2026-04-27/28 (when the feature was actually developed), but goose.UpContext (as this project calls it, no WithAllowMissing) hard-errors on any pending migration older than the DB's already-applied max version. Any install already past April on current master would fail to start entirely on upgrade. Renumbered all 5 podcast migrations to 2026-09-02 (after everything currently on master). This also meant the podcast_* columns added to the already-shipped uniform_canonical_ids migration's idColumns were dead code for any install that had already run that migration - editing an applied migration's Go source doesn't make it re-run. Reverted that edit and split the podcast id canonicalization into its own, later migration (20260902000005) that reuses the same buildIDMap/ applyIDMap machinery. Verified both fresh-install and existing-install upgrade paths end-to-end against real sqlite DBs: no boot error, and legacy-shaped podcast ids (plus their FK references) get correctly rewritten to canonical form. Verified: full build clean, core/podcasts + server/nativeapi + db/migrations test suites all pass, gofmt clean. --- core/podcasts/podcasts.go | 95 ++++++++++++++++++- core/podcasts/podcasts_suite_test.go | 7 ++ .../20260720015443_uniform_canonical_ids.go | 17 ++-- ...dcast.go => 20260902000000_add_podcast.go} | 8 ++ ...902000001_add_podcast_downloaded_bytes.go} | 0 ...t20.go => 20260902000002_add_podcast20.go} | 0 ...go => 20260902000003_add_podcast_tier3.go} | 0 ...=> 20260902000004_add_podcast_metadata.go} | 0 ...902000005_podcast_uniform_canonical_ids.go | 57 +++++++++++ db/migrations/uniform_canonical_ids_test.go | 8 -- 10 files changed, 171 insertions(+), 21 deletions(-) rename db/migrations/{20260427165650_add_podcast.go => 20260902000000_add_podcast.go} (74%) rename db/migrations/{20260427184047_add_podcast_downloaded_bytes.go => 20260902000001_add_podcast_downloaded_bytes.go} (100%) rename db/migrations/{20260428000000_add_podcast20.go => 20260902000002_add_podcast20.go} (100%) rename db/migrations/{20260428120000_add_podcast_tier3.go => 20260902000003_add_podcast_tier3.go} (100%) rename db/migrations/{20260428200000_add_podcast_metadata.go => 20260902000004_add_podcast_metadata.go} (100%) create mode 100644 db/migrations/20260902000005_podcast_uniform_canonical_ids.go diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 8714c5d2d..8ffe4eb0d 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -316,7 +316,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod s.setEpisodeError(ctx, ep, fmt.Errorf("invalid enclosure URL: %w", err)) return } - httpClient := &http.Client{Timeout: 30 * time.Second} + httpClient := &http.Client{Timeout: 30 * time.Second, Transport: safeHTTPTransport} resp, err := httpClient.Get(ep.EnclosureURL) //nolint:gosec if err != nil { s.setEpisodeError(ctx, ep, err) @@ -535,11 +535,102 @@ func (pw *progressWriter) Write(p []byte) (int, error) { return n, err } +// validateURL rejects any URL that is not a plain http/https request to a +// named host. It exists to prevent SSRF: without it, an authenticated user +// could point the preview/download endpoints at internal services, cloud +// metadata endpoints (e.g. 169.254.169.254), or any other host only +// reachable from the server itself. This is a cheap, fast-failing check on +// the URL's shape - the actual IP-level check happens per-connection in +// safeHTTPTransport below, since the host a URL names and the IP it +// resolves to at request time aren't guaranteed to be the same thing. +func validateURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("parsing URL: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("unsupported URL scheme %q, only http/https are allowed", u.Scheme) + } + if u.Hostname() == "" { + return fmt.Errorf("URL has no host") + } + return nil +} + +// isReservedIP reports whether ip is a loopback, private, link-local, +// multicast, or otherwise non-routable/internal address. Cloud metadata +// endpoints (e.g. AWS/GCP/Azure's 169.254.169.254) fall under the +// link-local range, so they're covered without a special case. +// +// This is a var, not a plain func, only so AllowLoopbackHTTPForTests (below) +// can narrow it for test binaries - production code never reassigns it. +var isReservedIP = func(ip net.IP) bool { + return ip.IsLoopback() || + ip.IsPrivate() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsInterfaceLocalMulticast() || + ip.IsMulticast() || + ip.IsUnspecified() +} + +// AllowLoopbackHTTPForTests relaxes safeHTTPTransport's SSRF guard to permit +// loopback addresses (127.0.0.0/8, ::1) - every other reserved/private/ +// link-local range (including cloud metadata endpoints) is still refused. +// It exists because httptest.Server always binds to loopback, so the podcast +// test suite needs a way to point the service at one without disabling the +// guard entirely. Not for production use. +func AllowLoopbackHTTPForTests() { + strict := isReservedIP + isReservedIP = func(ip net.IP) bool { + if ip.IsLoopback() { + return false + } + return strict(ip) + } +} + +// safeHTTPTransport is shared by every outbound podcast HTTP request (RSS +// feed fetch and episode download). Its DialContext resolves the host and +// checks isReservedIP at the moment of connection, not just once via +// validateURL up front - so a DNS answer that changes between the URL +// check and the actual TCP connect (DNS rebinding) can't be used to reach +// a reserved address that validateURL alone would have caught. +var safeHTTPTransport = &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("parsing address %q: %w", addr, err) + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("resolving host %q: %w", host, err) + } + if len(ips) == 0 { + return nil, fmt.Errorf("host %q did not resolve to any address", host) + } + var dialer net.Dialer + var lastErr error + for _, ip := range ips { + if isReservedIP(ip.IP) { + lastErr = fmt.Errorf("host %q resolves to a reserved/internal address (%s), refusing to connect", host, ip.IP) + continue + } + conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + return nil, lastErr + }, +} + func fetchAndParse(rssURL string) (*rssFeed, error) { if err := validateURL(rssURL); err != nil { return nil, fmt.Errorf("invalid RSS feed URL: %w", err) } - httpClient := &http.Client{Timeout: 15 * time.Second} + httpClient := &http.Client{Timeout: 15 * time.Second, Transport: safeHTTPTransport} resp, err := httpClient.Get(rssURL) //nolint:gosec if err != nil { return nil, fmt.Errorf("fetching RSS feed: %w", err) diff --git a/core/podcasts/podcasts_suite_test.go b/core/podcasts/podcasts_suite_test.go index afa5922ea..09d31bb39 100644 --- a/core/podcasts/podcasts_suite_test.go +++ b/core/podcasts/podcasts_suite_test.go @@ -3,6 +3,7 @@ package podcasts_test import ( "testing" + "github.com/navidrome/navidrome/core/podcasts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -12,6 +13,12 @@ import ( func TestPodcasts(t *testing.T) { tests.Init(t, false) log.SetLevel(log.LevelFatal) + // This suite's specs fetch RSS feeds/episodes from an httptest.Server, which + // always binds to loopback - safeHTTPTransport's SSRF guard would otherwise + // refuse every request the suite makes. See AllowLoopbackHTTPForTests's own + // doc comment: every other reserved/private/link-local address is still + // refused, so this doesn't disable the guard, just narrows it for this run. + podcasts.AllowLoopbackHTTPForTests() RegisterFailHandler(Fail) RunSpecs(t, "Podcasts Suite") } diff --git a/db/migrations/20260720015443_uniform_canonical_ids.go b/db/migrations/20260720015443_uniform_canonical_ids.go index c488ec8d8..21a70da14 100644 --- a/db/migrations/20260720015443_uniform_canonical_ids.go +++ b/db/migrations/20260720015443_uniform_canonical_ids.go @@ -77,14 +77,6 @@ var idColumns = []struct{ table, col string }{ {"media_file_artists", "media_file_id"}, {"media_file_artists", "artist_id"}, {"album_artists", "album_id"}, {"album_artists", "artist_id"}, {"library_tag", "tag_id"}, - {"podcast_channel", "id"}, - {"podcast_episode", "id"}, {"podcast_episode", "channel_id"}, {"podcast_episode", "stream_id"}, - {"podcast_transcript", "id"}, {"podcast_transcript", "episode_id"}, - {"podcast_person", "id"}, {"podcast_person", "channel_id"}, {"podcast_person", "episode_id"}, - {"podcast_podroll", "id"}, {"podcast_podroll", "channel_id"}, - {"podcast_live_item", "id"}, {"podcast_live_item", "channel_id"}, - {"podcast_funding", "id"}, {"podcast_funding", "channel_id"}, - {"podcast_image", "id"}, {"podcast_image", "channel_id"}, {"podcast_image", "episode_id"}, } // embeddedIDColumns holds ids nested inside a larger value; the id-columns guard checks this @@ -100,7 +92,7 @@ var embeddedIDColumns = []struct { } func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { - if err := buildIDMap(ctx, tx); err != nil { + if err := buildIDMap(ctx, tx, idColumns); err != nil { return err } for _, tc := range idColumns { @@ -137,7 +129,10 @@ func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error { } // buildIDMap stages old->new pairs for every id that changes, indexed for the update joins. -func buildIDMap(ctx context.Context, tx *sql.Tx) error { +// columns is a parameter (not always the package-level idColumns) so a later migration can +// reuse this same collect-and-rewrite machinery for a different, disjoint set of columns - see +// podcast_uniform_canonical_ids.go, which does exactly that for tables idColumns predates. +func buildIDMap(ctx context.Context, tx *sql.Tx, columns []struct{ table, col string }) error { _, err := tx.ExecContext(ctx, "CREATE TEMP TABLE _id_map (old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL) WITHOUT ROWID") if err != nil { @@ -148,7 +143,7 @@ func buildIDMap(ctx context.Context, tx *sql.Tx) error { return err } defer ins.Close() - for _, tc := range idColumns { + for _, tc := range columns { if err := collectColumn(ctx, tx, ins, tc.table, tc.col); err != nil { return fmt.Errorf("collecting %s.%s: %w", tc.table, tc.col, err) } diff --git a/db/migrations/20260427165650_add_podcast.go b/db/migrations/20260902000000_add_podcast.go similarity index 74% rename from db/migrations/20260427165650_add_podcast.go rename to db/migrations/20260902000000_add_podcast.go index 7bd8f3171..eabffeecf 100644 --- a/db/migrations/20260427165650_add_podcast.go +++ b/db/migrations/20260902000000_add_podcast.go @@ -7,6 +7,14 @@ import ( "github.com/pressly/goose/v3" ) +// This file (and the 4 that follow it, add_podcast_downloaded_bytes/podcast20/podcast_tier3/ +// podcast_metadata) were originally timestamped 2026-04-27/28, matching when the podcast +// feature was actually developed. They were renumbered to 2026-09-02 (after every migration +// already on master as of this PR) before merging: goose.UpContext (as navidrome calls it, with +// no WithAllowMissing) hard-errors and refuses to start if it finds a pending migration whose +// version is lower than the DB's already-applied max version - which every one of these files +// would have been, for any install that had already migrated past April on current master. +// Keep new migrations timestamped at-or-after merge time, not authoring time. func init() { goose.AddMigrationContext(upAddPodcast, downAddPodcast) } diff --git a/db/migrations/20260427184047_add_podcast_downloaded_bytes.go b/db/migrations/20260902000001_add_podcast_downloaded_bytes.go similarity index 100% rename from db/migrations/20260427184047_add_podcast_downloaded_bytes.go rename to db/migrations/20260902000001_add_podcast_downloaded_bytes.go diff --git a/db/migrations/20260428000000_add_podcast20.go b/db/migrations/20260902000002_add_podcast20.go similarity index 100% rename from db/migrations/20260428000000_add_podcast20.go rename to db/migrations/20260902000002_add_podcast20.go diff --git a/db/migrations/20260428120000_add_podcast_tier3.go b/db/migrations/20260902000003_add_podcast_tier3.go similarity index 100% rename from db/migrations/20260428120000_add_podcast_tier3.go rename to db/migrations/20260902000003_add_podcast_tier3.go diff --git a/db/migrations/20260428200000_add_podcast_metadata.go b/db/migrations/20260902000004_add_podcast_metadata.go similarity index 100% rename from db/migrations/20260428200000_add_podcast_metadata.go rename to db/migrations/20260902000004_add_podcast_metadata.go diff --git a/db/migrations/20260902000005_podcast_uniform_canonical_ids.go b/db/migrations/20260902000005_podcast_uniform_canonical_ids.go new file mode 100644 index 000000000..69ba9faf2 --- /dev/null +++ b/db/migrations/20260902000005_podcast_uniform_canonical_ids.go @@ -0,0 +1,57 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upPodcastUniformCanonicalIds, downPodcastUniformCanonicalIds) +} + +// podcastIDColumns lists every Navidrome-id-bearing podcast_* column, the same inventory +// uniform_canonical_ids (20260720015443) keeps for every other table - see this file's own +// upPodcastUniformCanonicalIds doc comment for why podcast ids need their own, later migration +// instead of just being added to that one's idColumns. +var podcastIDColumns = []struct{ table, col string }{ + {"podcast_channel", "id"}, + {"podcast_episode", "id"}, {"podcast_episode", "channel_id"}, {"podcast_episode", "stream_id"}, + {"podcast_transcript", "id"}, {"podcast_transcript", "episode_id"}, + {"podcast_person", "id"}, {"podcast_person", "channel_id"}, {"podcast_person", "episode_id"}, + {"podcast_podroll", "id"}, {"podcast_podroll", "channel_id"}, + {"podcast_live_item", "id"}, {"podcast_live_item", "channel_id"}, + {"podcast_funding", "id"}, {"podcast_funding", "channel_id"}, + {"podcast_image", "id"}, {"podcast_image", "channel_id"}, {"podcast_image", "episode_id"}, +} + +// upPodcastUniformCanonicalIds rewrites podcast_* ids to the same canonical 22-char base62 +// encoding uniform_canonical_ids (20260720015443) already applied to every other table. +// +// It has to be a separate, later migration rather than an addition to that one's idColumns, +// for two independent reasons: +// 1. The podcast_* tables don't exist yet when 20260720015443 runs - the add_podcast* migrations +// that create them are timestamped after it (2026-09-02, see add_podcast.go's own comment on +// why) - so a SELECT against them there would fail outright, even on a fresh install. +// 2. Editing an already-applied migration's Go source has no runtime effect on any install that +// already ran it: goose tracks migrations as applied-or-not by version, not by re-diffing +// their source on every startup. An install that ran 20260720015443 before this feature +// existed would never re-run it, no matter what idColumns says today. +func upPodcastUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { + if err := buildIDMap(ctx, tx, podcastIDColumns); err != nil { + return err + } + for _, tc := range podcastIDColumns { + if err := applyIDMap(ctx, tx, tc.table, tc.col); err != nil { + return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err) + } + } + _, err := tx.ExecContext(ctx, "DROP TABLE _id_map") + return err +} + +func downPodcastUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { + return nil // irreversible data migration +} diff --git a/db/migrations/uniform_canonical_ids_test.go b/db/migrations/uniform_canonical_ids_test.go index 05d796c83..ced75cb7e 100644 --- a/db/migrations/uniform_canonical_ids_test.go +++ b/db/migrations/uniform_canonical_ids_test.go @@ -65,14 +65,6 @@ var _ = Describe("upUniformCanonicalIds", func() { CREATE TABLE library_tag (tag_id text, library_id integer); CREATE TABLE plugin (id text, users text); CREATE TABLE property (id text primary key, value text); - CREATE TABLE podcast_channel (id text); - CREATE TABLE podcast_episode (id text, channel_id text, stream_id text); - CREATE TABLE podcast_transcript (id text, episode_id text); - CREATE TABLE podcast_person (id text, channel_id text, episode_id text); - CREATE TABLE podcast_podroll (id text, channel_id text); - CREATE TABLE podcast_live_item (id text, channel_id text); - CREATE TABLE podcast_funding (id text, channel_id text); - CREATE TABLE podcast_image (id text, channel_id text, episode_id text); `) Expect(err).ToNot(HaveOccurred()) From 4bebfad0ede2145b336b91010752b826fcda4dd0 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Wed, 30 Sep 2026 15:42:14 +0900 Subject: [PATCH 17/20] fix(podcast): address CodeRabbit review feedback - Remove total HTTP client timeout from episode downloads (use transport phase timeouts instead) and delete partial files on failure - Keep SSE progress listener subscribed while the show view is mounted - Return podcastEpisode (with streamId) from getNewestPodcasts - Clean up dependent rows and MediaFiles in DeleteChannel within a tx - Limit RSS feed body size to 32 MiB - Block additional reserved IP ranges in the SSRF guard - Fall back to enclosure URL when an item has no - Handle clipboard write failure in the podcast list --- core/podcasts/podcasts.go | 91 ++++++++++++++++++++++++-- core/podcasts/rss.go | 24 ++++--- core/podcasts/rss_test.go | 9 +++ resources/i18n/ko.json | 1 + server/subsonic/podcasts.go | 20 +----- server/subsonic/podcasts_test.go | 10 +-- server/subsonic/responses/responses.go | 9 +-- ui/src/i18n/en.json | 1 + ui/src/podcast/PodcastList.jsx | 6 +- ui/src/podcast/PodcastShow.jsx | 7 +- 10 files changed, 126 insertions(+), 52 deletions(-) diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 5ac6ed5a3..3630dbc34 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -316,8 +316,15 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod s.setEpisodeError(ctx, ep, fmt.Errorf("invalid enclosure URL: %w", err)) return } - httpClient := &http.Client{Timeout: 30 * time.Second, Transport: safeHTTPTransport} - resp, err := httpClient.Get(ep.EnclosureURL) //nolint:gosec + // No total Client.Timeout here: it would also cover reading the body and + // abort large episodes. Connection-phase limits live on safeHTTPTransport. + httpClient := &http.Client{Transport: safeHTTPTransport} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.EnclosureURL, nil) + if err != nil { + s.setEpisodeError(ctx, ep, err) + return + } + resp, err := httpClient.Do(req) //nolint:gosec if err != nil { s.setEpisodeError(ctx, ep, err) return @@ -337,6 +344,8 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod size, err := io.Copy(&progressWriter{ep: ep, ds: s.ds, broker: s.broker, ctx: ctx, w: f}, resp.Body) if err != nil { + f.Close() + _ = os.Remove(dest) // don't leave a partial file behind s.setEpisodeError(ctx, ep, err) return } @@ -499,7 +508,39 @@ func (s *podcastService) DeleteChannel(ctx context.Context, id string) error { _ = os.Remove(ep.Path) } } - return s.ds.PodcastChannel().Delete(ctx, id) + // The channel-/episode-scoped detail tables have no FK cascade, and the + // registered MediaFiles are not FK-linked either, so clean them up here. + return s.ds.WithTx(func(tx model.DataStore) error { + for _, ep := range episodes { + if ep.StreamID != "" { + if err := tx.MediaFile().Delete(ctx, ep.StreamID); err != nil { + return err + } + } + if err := tx.PodcastPerson().SaveForEpisode(ctx, ep.ID, nil); err != nil { + return err + } + if err := tx.PodcastImage().SaveForEpisode(ctx, ep.ID, nil); err != nil { + return err + } + } + if err := tx.PodcastPerson().SaveForChannel(ctx, id, nil); err != nil { + return err + } + if err := tx.PodcastFunding().SaveForChannel(ctx, id, nil); err != nil { + return err + } + if err := tx.PodcastImage().SaveForChannel(ctx, id, nil); err != nil { + return err + } + if err := tx.PodcastPodroll().SaveForChannel(ctx, id, nil); err != nil { + return err + } + if err := tx.PodcastLiveItem().DeleteByChannel(ctx, id); err != nil { + return err + } + return tx.PodcastChannel().Delete(ctx, id) + }) } // progressWriter wraps an io.Writer and periodically saves download progress to DB. @@ -571,7 +612,36 @@ var isReservedIP = func(ip net.IP) bool { ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || - ip.IsUnspecified() + ip.IsUnspecified() || + inReservedNets(ip) +} + +// reservedNets are special-purpose ranges that net.IP's helpers don't cover. +var reservedNets = func() []*net.IPNet { + var nets []*net.IPNet + for _, cidr := range []string{ + "0.0.0.0/8", // "this" network + "100.64.0.0/10", // carrier-grade NAT + "192.0.0.0/24", // IETF protocol assignments + "198.18.0.0/15", // benchmarking + "64:ff9b::/96", // NAT64 + } { + _, n, err := net.ParseCIDR(cidr) + if err != nil { + panic(err) + } + nets = append(nets, n) + } + return nets +}() + +func inReservedNets(ip net.IP) bool { + for _, n := range reservedNets { + if n.Contains(ip) { + return true + } + } + return false } // AllowLoopbackHTTPForTests relaxes safeHTTPTransport's SSRF guard to permit @@ -597,6 +667,8 @@ func AllowLoopbackHTTPForTests() { // check and the actual TCP connect (DNS rebinding) can't be used to reach // a reserved address that validateURL alone would have caught. var safeHTTPTransport = &http.Transport{ + TLSHandshakeTimeout: 15 * time.Second, + ResponseHeaderTimeout: 30 * time.Second, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { @@ -609,7 +681,7 @@ var safeHTTPTransport = &http.Transport{ if len(ips) == 0 { return nil, fmt.Errorf("host %q did not resolve to any address", host) } - var dialer net.Dialer + dialer := net.Dialer{Timeout: 15 * time.Second} var lastErr error for _, ip := range ips { if isReservedIP(ip.IP) { @@ -626,6 +698,8 @@ var safeHTTPTransport = &http.Transport{ }, } +const maxFeedSize = 32 << 20 // 32 MiB + func fetchAndParse(rssURL string) (*rssFeed, error) { if err := validateURL(rssURL); err != nil { return nil, fmt.Errorf("invalid RSS feed URL: %w", err) @@ -637,10 +711,15 @@ func fetchAndParse(rssURL string) (*rssFeed, error) { } defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) + // Read at most one byte past the limit so an oversized feed is detected + // without buffering it entirely in memory. + data, err := io.ReadAll(io.LimitReader(resp.Body, maxFeedSize+1)) if err != nil { return nil, fmt.Errorf("reading RSS feed: %w", err) } + if len(data) > maxFeedSize { + return nil, fmt.Errorf("RSS feed exceeds %d bytes", maxFeedSize) + } return ParseRSSFeed(data) } diff --git a/core/podcasts/rss.go b/core/podcasts/rss.go index 70c618ab1..460d52c20 100644 --- a/core/podcasts/rss.go +++ b/core/podcasts/rss.go @@ -236,15 +236,15 @@ type rssItem struct { ItunesDur string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"` // Podcasting 2.0 episode tags - PodcastChapters podcastChapters `xml:"https://podcastindex.org/namespace/1.0 chapters"` + PodcastChapters podcastChapters `xml:"https://podcastindex.org/namespace/1.0 chapters"` PodcastTranscripts []podcastTranscript `xml:"https://podcastindex.org/namespace/1.0 transcript"` - PodcastSeason podcastSeason `xml:"https://podcastindex.org/namespace/1.0 season"` - PodcastEpisodeNum podcastEpisodeNum `xml:"https://podcastindex.org/namespace/1.0 episode"` - PodcastSoundbite podcastSoundbite `xml:"https://podcastindex.org/namespace/1.0 soundbite"` - PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"` - PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"` - PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"` - PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"` + PodcastSeason podcastSeason `xml:"https://podcastindex.org/namespace/1.0 season"` + PodcastEpisodeNum podcastEpisodeNum `xml:"https://podcastindex.org/namespace/1.0 episode"` + PodcastSoundbite podcastSoundbite `xml:"https://podcastindex.org/namespace/1.0 soundbite"` + PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"` + PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"` + PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"` + PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"` } type enclosure struct { @@ -413,8 +413,14 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { pubDate, _ := parseRSSDate(item.PubDate) suffix := suffixFromMIME(item.Enclosure.Type, item.Enclosure.URL) + // is optional in RSS 2.0; fall back to the enclosure URL so + // episodes keep a unique identity for de-duplication on refresh. + guid := strings.TrimSpace(item.GUID) + if guid == "" { + guid = item.Enclosure.URL + } ep := model.PodcastEpisode{ - GUID: item.GUID, + GUID: guid, Title: item.Title, Description: desc, PublishDate: pubDate, diff --git a/core/podcasts/rss_test.go b/core/podcasts/rss_test.go index 41728ccb9..95b91b9ef 100644 --- a/core/podcasts/rss_test.go +++ b/core/podcasts/rss_test.go @@ -77,6 +77,15 @@ var _ = Describe("ParseRSSFeed", func() { Expect(feed.Episodes).To(HaveLen(2)) }) + It("falls back to the enclosure URL when is missing", func() { + feed, err := podcasts.ParseRSSFeed([]byte(`T +No guid +`)) + Expect(err).ToNot(HaveOccurred()) + Expect(feed.Episodes).To(HaveLen(1)) + Expect(feed.Episodes[0].GUID).To(Equal("https://example.com/noguid.mp3")) + }) + It("parses episode fields correctly", func() { feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed)) ep := feed.Episodes[0] diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json index 711e5acaf..56ac225c3 100644 --- a/resources/i18n/ko.json +++ b/resources/i18n/ko.json @@ -267,6 +267,7 @@ "downloadStarted": "다운로드 시작됨", "channelAdded": "채널 추가됨", "urlCopied": "피드 URL이 클립보드에 복사됨", + "urlCopyFailed": "피드 URL을 복사할 수 없습니다", "alreadyExists": "이미 등록된 채널입니다.", "channelDeleted": "채널 삭제됨", "episodeDeleted": "에피소드 삭제됨" diff --git a/server/subsonic/podcasts.go b/server/subsonic/podcasts.go index a56f9f67d..31b9a507f 100644 --- a/server/subsonic/podcasts.go +++ b/server/subsonic/podcasts.go @@ -225,25 +225,7 @@ func (api *Router) GetNewestPodcasts(r *http.Request) (*responses.Subsonic, erro 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) + resp.NewestPodcasts.Episode = append(resp.NewestPodcasts.Episode, buildPodcastEpisode(ep)) } return resp, nil } diff --git a/server/subsonic/podcasts_test.go b/server/subsonic/podcasts_test.go index e9cf997f2..df31798af 100644 --- a/server/subsonic/podcasts_test.go +++ b/server/subsonic/podcasts_test.go @@ -118,12 +118,12 @@ var _ = Describe("Podcasts", 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}, + "ep-1": {ID: "ep-1", Title: "Ep1", ChannelID: "ch-1", StreamID: "stream-ep-1", PublishDate: now.Add(-time.Hour), Status: model.PodcastStatusCompleted}, + "ep-2": {ID: "ep-2", Title: "Ep2", ChannelID: "ch-1", StreamID: "stream-ep-2", PublishDate: now, Status: model.PodcastStatusNew}, } }) - It("returns episodes in Child format", func() { + It("returns episodes in podcastEpisode format", func() { r := httptest.NewRequest("GET", "/rest/getNewestPodcasts", nil) r = r.WithContext(userCtx) @@ -132,14 +132,14 @@ var _ = Describe("Podcasts", func() { Expect(resp.NewestPodcasts.Episode).To(HaveLen(2)) }) - It("sets type to podcast", func() { + It("includes streamId so clients can play the episode", 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")) + Expect(ep.StreamId).To(Equal("stream-" + ep.ID)) } }) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 6e4ea63f4..69250aa56 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -169,12 +169,7 @@ 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"` - // 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"` + *OpenSubsonicChild `xml:",omitempty" json:",omitempty"` } type OpenSubsonicChild struct { @@ -706,7 +701,7 @@ type Podcasts struct { } type NewestPodcasts struct { - Episode []Child `xml:"episode,omitempty" json:"episode,omitempty"` + Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"` } type PodcastFundingResp struct { diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 2ac47f820..bbb57db70 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -299,6 +299,7 @@ "downloadStarted": "Download started", "channelAdded": "Channel added", "urlCopied": "Feed URL copied to clipboard", + "urlCopyFailed": "Could not copy the feed URL", "alreadyExists": "This channel is already registered.", "channelDeleted": "Channel deleted", "episodeDeleted": "Episode deleted" diff --git a/ui/src/podcast/PodcastList.jsx b/ui/src/podcast/PodcastList.jsx index d67984827..7e7dafe6e 100644 --- a/ui/src/podcast/PodcastList.jsx +++ b/ui/src/podcast/PodcastList.jsx @@ -70,8 +70,10 @@ const FeedUrlField = ({ record }) => { if (!record?.url) return null const handleCopy = (e) => { e.stopPropagation() - navigator.clipboard.writeText(record.url) - notify('resources.podcast.notifications.urlCopied') + navigator.clipboard + .writeText(record.url) + .then(() => notify('resources.podcast.notifications.urlCopied')) + .catch(() => notify('resources.podcast.notifications.urlCopyFailed', 'warning')) } return ( diff --git a/ui/src/podcast/PodcastShow.jsx b/ui/src/podcast/PodcastShow.jsx index 2a462effc..3e7c95e4d 100644 --- a/ui/src/podcast/PodcastShow.jsx +++ b/ui/src/podcast/PodcastShow.jsx @@ -124,10 +124,9 @@ const PodcastShow = (props) => { useEffect(loadEpisodes, [record?.id]) - // Subscribe to SSE progress only while episodes are downloading - const hasDownloading = episodes.some((ep) => ep.status === 'downloading') + // Stay subscribed to SSE progress while the view is mounted: a download + // started from here may not be flagged 'downloading' yet when we refresh. useEffect(() => { - if (!hasDownloading) return const handler = (e) => { const { episodeId, downloadedBytes, size, duration, status } = e.detail if (status === 'completed' || status === 'error') { @@ -145,7 +144,7 @@ const PodcastShow = (props) => { } window.addEventListener('podcastEpisodeProgress', handler) return () => window.removeEventListener('podcastEpisodeProgress', handler) - }, [hasDownloading]) + }, [record?.id]) if (!record) return null From c6774f45e8fff0b5a4bf76ff534623cbc775dac9 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Wed, 30 Sep 2026 15:50:41 +0900 Subject: [PATCH 18/20] fix(podcast): refresh Podping channels and isolate podcast library from scans - Stop skipping channels with usesPodping during RefreshChannels: no Podping listener exists, so those channels would never receive new episodes. UsesPodping is still parsed, stored and exposed as metadata. - Root the podcast library at DataFolder/podcasts (episode paths are now relative to it) instead of DataFolder, so the scanner can't import unrelated audio files under DataFolder. - Drop an empty .ndignore in the podcast root so regular scans skip it; episodes are registered as MediaFiles by the podcast service itself. - Create the library with DefaultNewUsers and assign it to existing non-admin users (Put only auto-assigns admins). --- core/podcasts/podcasts.go | 65 ++++++++++++++++++++++++++++------ core/podcasts/podcasts_test.go | 30 +++++++++++++--- 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 3630dbc34..1cdb6d16b 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -15,6 +15,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -43,9 +44,19 @@ func NewPodcastService(rootCtx context.Context, ds model.DataStore, ff ffmpeg.FF return &podcastService{rootCtx: rootCtx, ds: ds, ff: ff, broker: broker} } -// podcastLibraryID returns the ID of the podcast virtual library, -// creating it if it doesn't exist. The library root is DataFolder so that -// MediaFile paths stored as "podcasts/{ch}/{ep}.mp3" resolve correctly via AbsolutePath(). +// podcastsDir is where downloaded episodes live and the root of the podcast +// virtual library. +func podcastsDir() string { + return filepath.Join(conf.Server.DataFolder.String(), "podcasts") +} + +// podcastLibraryID returns the ID of the podcast virtual library, creating it +// if it doesn't exist. The library root is DataFolder/podcasts so that MediaFile +// paths stored as "{ch}/{ep}.mp3" resolve correctly via AbsolutePath(). +// +// Episodes are registered as MediaFiles by the podcast service itself, so the +// library must not be picked up by the regular scanner: an empty .ndignore in +// the root makes the scanner skip everything below it. func (s *podcastService) podcastLibraryID(ctx context.Context) (int, error) { libs, err := s.ds.Library().GetAll(ctx) if err != nil { @@ -56,16 +67,51 @@ func (s *podcastService) podcastLibraryID(ctx context.Context) (int, error) { return lib.ID, nil } } + root := podcastsDir() + if err := os.MkdirAll(root, 0755); err != nil { + return 0, fmt.Errorf("creating podcasts dir: %w", err) + } + ignoreFile := filepath.Join(root, consts.ScanIgnoreFile) + if _, statErr := os.Stat(ignoreFile); os.IsNotExist(statErr) { + if err := os.WriteFile(ignoreFile, nil, 0644); err != nil { + return 0, fmt.Errorf("creating %s: %w", consts.ScanIgnoreFile, err) + } + } lib := &model.Library{ - Name: podcastLibraryName, - Path: conf.Server.DataFolder.String(), + Name: podcastLibraryName, + Path: root, + DefaultNewUsers: true, // users created later get access automatically } if err := s.ds.Library().Put(ctx, lib); err != nil { return 0, err } + // Put only auto-assigns admins; give existing regular users access too. + s.assignLibraryToExistingUsers(ctx, lib.ID) return lib.ID, nil } +func (s *podcastService) assignLibraryToExistingUsers(ctx context.Context, libID int) { + users, err := s.ds.User().GetAll(ctx) + if err != nil { + log.Warn(ctx, "Failed to list users to assign podcast library", err) + return + } + for _, u := range users { + if u.IsAdmin { + continue + } + ids := []int{libID} + for _, l := range u.Libraries { + if l.ID != libID { + ids = append(ids, l.ID) + } + } + if err := s.ds.User().SetUserLibraries(ctx, u.ID, ids); err != nil { + log.Warn(ctx, "Failed to assign podcast library to user", "user", u.UserName, err) + } + } +} + func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { exists, err := s.ds.PodcastChannel().ExistsByURL(ctx, rssURL) if err != nil { @@ -191,9 +237,6 @@ func (s *podcastService) RefreshChannels(ctx context.Context) error { } for _, ch := range channels { - if ch.UsesPodping { - continue // skip — this channel uses Podping for updates - } if err := s.refreshChannel(ctx, ch); err != nil { log.Warn(ctx, "Failed to refresh podcast channel", "channel", ch.Title, err) } @@ -298,7 +341,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod if suffix == "" { suffix = "mp3" } - dir := filepath.Join(conf.Server.DataFolder.String(), "podcasts", ep.ChannelID) + dir := filepath.Join(podcastsDir(), ep.ChannelID) if err := os.MkdirAll(dir, 0755); err != nil { s.setEpisodeError(ctx, ep, err) return @@ -355,12 +398,12 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod 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. + // Use the podcast virtual library (rooted at DataFolder/podcasts); store relative path. libID, libErr := s.podcastLibraryID(ctx) if libErr != nil { log.Warn(ctx, "Failed to get podcast library, streaming may not work", "episode", ep.ID, libErr) } else { - relPath := strings.TrimPrefix(dest, conf.Server.DataFolder.String()+string(filepath.Separator)) + relPath := strings.TrimPrefix(dest, podcastsDir()+string(filepath.Separator)) now := time.Now() tags := model.Tags{} tags.Add("genre", "Podcast") diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index 8672880b1..f64ff08cb 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -159,6 +159,26 @@ var _ = Describe("PodcastService", func() { }, "3s").Should(Equal(model.PodcastStatusCompleted)) }) + It("creates a podcast library rooted at DataFolder/podcasts that the scanner ignores", func() { + _ = svc.DownloadEpisode(ctx, "ep-1") + root := filepath.Join(conf.Server.DataFolder.String(), "podcasts") + Eventually(func() bool { + _, err := os.Stat(filepath.Join(root, ".ndignore")) + return err == nil + }, "3s").Should(BeTrue()) + libs, err := ds.Library().GetAll(ctx) + Expect(err).ToNot(HaveOccurred()) + var found bool + for _, l := range libs { + if l.Name == "Podcasts" { + found = true + Expect(l.Path).To(Equal(root)) + Expect(l.DefaultNewUsers).To(BeTrue()) + } + } + Expect(found).To(BeTrue()) + }) + It("records the file path after download", func() { _ = svc.DownloadEpisode(ctx, "ep-1") expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3") @@ -495,7 +515,7 @@ var _ = Describe("PodcastService", func() { }) }) - Describe("RefreshChannels — Tier 3 podping skip", func() { + Describe("RefreshChannels — Tier 3 podping", func() { var podrollRepo *tests.MockPodcastPodrollRepo var liveItemRepo *tests.MockPodcastLiveItemRepo @@ -506,8 +526,7 @@ var _ = Describe("PodcastService", func() { ds.MockedPodcastLiveItem = liveItemRepo }) - It("skips channels with UsesPodping=true during refresh", func() { - // UsesPodping channel points to a server that would add episodes. + It("still refreshes channels with UsesPodping=true (no Podping listener exists)", func() { channelRepo.Data["ch-podping"] = &model.PodcastChannel{ ID: "ch-podping", URL: mockServer.URL + "/feed.xml", @@ -516,8 +535,9 @@ var _ = Describe("PodcastService", func() { initialEpisodeCount := len(episodeRepo.Data) Expect(svc.RefreshChannels(ctx)).To(Succeed()) - // No new episodes should be added because the only channel uses podping. - Expect(episodeRepo.Data).To(HaveLen(initialEpisodeCount)) + // Podping is only a hint; nothing else refreshes these feeds, so + // new episodes must still be picked up. + Expect(len(episodeRepo.Data)).To(BeNumerically(">", initialEpisodeCount)) }) It("still refreshes channels with UsesPodping=false", func() { From 6f49440b9c9dc0f52213018bdb063c5ff06f836d Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Wed, 30 Sep 2026 16:55:02 +0900 Subject: [PATCH 19/20] fix(podcast): address second round of CodeRabbit feedback - Create the episode file only after a 200 response so failed downloads don't leave empty files behind - Reuse the existing StreamID as the MediaFile ID on re-download instead of inserting duplicate tracks - Link mobile podcast list rows to the channel show page - Ignore stale getPodcasts responses and clear episodes when the channel changes in PodcastShow --- core/podcasts/podcasts.go | 24 ++++++++++++++++-------- core/podcasts/podcasts_test.go | 31 +++++++++++++++++++++++++++++++ ui/src/podcast/PodcastList.jsx | 1 + ui/src/podcast/PodcastShow.jsx | 16 ++++++++++++++-- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 1cdb6d16b..96a267767 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -348,13 +348,6 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod } dest := filepath.Join(dir, ep.ID+"."+suffix) - f, err := os.Create(dest) - if err != nil { - s.setEpisodeError(ctx, ep, err) - return - } - defer f.Close() - if err := validateURL(ep.EnclosureURL); err != nil { s.setEpisodeError(ctx, ep, fmt.Errorf("invalid enclosure URL: %w", err)) return @@ -380,6 +373,15 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod return } + // Create the file only once we have a 200 response, so failed requests + // don't leave empty files behind. + f, err := os.Create(dest) + if err != nil { + s.setEpisodeError(ctx, ep, err) + return + } + defer f.Close() + // Use Content-Length as total size when RSS feed didn't provide it if resp.ContentLength > 0 && ep.Size == 0 { ep.Size = resp.ContentLength @@ -407,8 +409,14 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod now := time.Now() tags := model.Tags{} tags.Add("genre", "Podcast") + // Reuse the existing MediaFile on re-download so Put updates it instead of + // leaving a duplicate track behind. + mfID := ep.StreamID + if mfID == "" { + mfID = id.NewRandom() + } mf := &model.MediaFile{ - ID: id.NewRandom(), + ID: mfID, LibraryID: libID, Path: relPath, Title: ep.Title, diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index f64ff08cb..2865cce67 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -179,6 +179,21 @@ var _ = Describe("PodcastService", func() { Expect(found).To(BeTrue()) }) + It("reuses the existing StreamID's MediaFile on re-download", func() { + _ = svc.DownloadEpisode(ctx, "ep-1") + Eventually(func() model.PodcastStatus { + return episodeRepo.Data["ep-1"].Status + }, "3s").Should(Equal(model.PodcastStatusCompleted)) + first := episodeRepo.Data["ep-1"].StreamID + Expect(first).ToNot(BeEmpty()) + + _ = svc.DownloadEpisode(ctx, "ep-1") + Eventually(func() model.PodcastStatus { + return episodeRepo.Data["ep-1"].Status + }, "3s").Should(Equal(model.PodcastStatusCompleted)) + Expect(episodeRepo.Data["ep-1"].StreamID).To(Equal(first)) + }) + It("records the file path after download", func() { _ = svc.DownloadEpisode(ctx, "ep-1") expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3") @@ -236,6 +251,22 @@ var _ = Describe("PodcastService", func() { BeforeEach(func() { channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1", Title: "Test Channel"} }) + It("does not leave an empty file behind when the request 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)) + dest := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-bad.mp3") + _, err := os.Stat(dest) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) It("sets status to error when download fails", func() { episodeRepo.Data["ep-bad"] = &model.PodcastEpisode{ ID: "ep-bad", diff --git a/ui/src/podcast/PodcastList.jsx b/ui/src/podcast/PodcastList.jsx index 7e7dafe6e..421751c12 100644 --- a/ui/src/podcast/PodcastList.jsx +++ b/ui/src/podcast/PodcastList.jsx @@ -179,6 +179,7 @@ const PodcastList = ({ permissions, ...props }) => { > {isXsmall ? ( } primaryText={(r) => r.title} secondaryText={(r) => r.url} diff --git a/ui/src/podcast/PodcastShow.jsx b/ui/src/podcast/PodcastShow.jsx index 3e7c95e4d..5b32fa41d 100644 --- a/ui/src/podcast/PodcastShow.jsx +++ b/ui/src/podcast/PodcastShow.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Card, CardContent, @@ -110,11 +110,17 @@ const PodcastShow = (props) => { const { record } = useShowController(props) const [episodes, setEpisodes] = useState([]) + // Incremented on every load and on channel change; responses from a + // superseded request are ignored so they can't overwrite the current channel. + const requestGen = useRef(0) + const loadEpisodes = () => { if (!record?.id) return + const gen = ++requestGen.current subsonic .getPodcasts(record.id, true) .then((res) => { + if (gen !== requestGen.current) return const channels = res?.json?.['subsonic-response']?.podcasts?.channel || [] const ch = channels.find((c) => c.id === record.id) setEpisodes(ch?.episode || []) @@ -122,7 +128,13 @@ const PodcastShow = (props) => { .catch(() => {}) } - useEffect(loadEpisodes, [record?.id]) + useEffect(() => { + setEpisodes([]) // don't show the previous channel's episodes + loadEpisodes() + return () => { + requestGen.current++ + } + }, [record?.id]) // Stay subscribed to SSE progress while the view is mounted: a download // started from here may not be flagged 'downloading' yet when we refresh. From ff65c6713f27f10a264dc47f973a917b7f3439ce Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Tue, 6 Oct 2026 09:34:18 +0900 Subject: [PATCH 20/20] chore(make): limit wire generation to ./cmd Test-only packages (e2e) have no wire output directory, making `make wire` exit non-zero even though cmd/wire_gen.go is generated. Co-Authored-By: Claude Sonnet 5.5 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f31d98a01..22b605fe3 100644 --- a/Makefile +++ b/Makefile @@ -151,7 +151,7 @@ format: ##@Development Format code .PHONY: format wire: check_go_env ##@Development Update Dependency Injection - go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./... + go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./cmd .PHONY: wire gen: check_go_env api-gen ##@Development Run go generate for code generation