From 152a032ae170a62d1ac63b907a88fa8c9abcb0e5 Mon Sep 17 00:00:00 2001 From: Jiho Andrew Lee Date: Tue, 28 Apr 2026 12:40:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(podcast):=20implement=20Podcasting=202.0?= =?UTF-8?q?=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 . - // 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 = ` + + + 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 +}