diff --git a/core/podcasts/podcasts.go b/core/podcasts/podcasts.go index 35664a628..9f1022517 100644 --- a/core/podcasts/podcasts.go +++ b/core/podcasts/podcasts.go @@ -88,12 +88,16 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { Locked: feed.Locked, LockedOwner: feed.LockedOwner, Medium: feed.Medium, - FundingURL: feed.FundingURL, - FundingText: feed.FundingText, UpdateFrequency: feed.UpdateFrequency, UpdateRRule: feed.UpdateRRule, Complete: feed.Complete, UsesPodping: feed.UsesPodping, + LocationName: feed.LocationName, + LocationGeo: feed.LocationGeo, + LocationOSM: feed.LocationOSM, + License: feed.License, + PublisherName: feed.PublisherName, + PublisherURL: feed.PublisherURL, } if err := s.ds.PodcastChannel(ctx).Create(ch); err != nil { return err @@ -106,6 +110,23 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { } } + // Save podcast:funding items + if len(feed.FundingItems) > 0 { + for i := range feed.FundingItems { + feed.FundingItems[i].ChannelID = ch.ID + } + if err := s.ds.PodcastFunding(ctx).SaveForChannel(ch.ID, feed.FundingItems); err != nil { + log.Warn(ctx, "Failed to save podcast funding items", "channel", ch.ID, err) + } + } + + // Save podcast:image (channel level) + if len(feed.Images) > 0 { + if err := s.ds.PodcastImage(ctx).SaveForChannel(ch.ID, feed.Images); err != nil { + log.Warn(ctx, "Failed to save podcast channel images", "channel", ch.ID, err) + } + } + // Save podcast:podroll items if len(feed.Podroll) > 0 { if err := s.ds.PodcastPodroll(ctx).SaveForChannel(ch.ID, feed.Podroll); err != nil { @@ -127,8 +148,10 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { ep.Status = model.PodcastStatusNew transcripts := ep.Transcripts persons := ep.Persons + images := ep.Images ep.Transcripts = nil ep.Persons = nil + ep.Images = nil if err := s.ds.PodcastEpisode(ctx).Create(&ep); err != nil { return err } @@ -147,6 +170,12 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error { log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err) } } + // Save episode images + if len(images) > 0 { + if err := s.ds.PodcastImage(ctx).SaveForEpisode(ep.ID, images); err != nil { + log.Warn(ctx, "Failed to save podcast episode images", "episode", ep.ID, err) + } + } } ch.Status = model.PodcastStatusCompleted @@ -176,6 +205,16 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha return err } + // Refresh podcast:funding items + if err := s.ds.PodcastFunding(ctx).SaveForChannel(ch.ID, feed.FundingItems); err != nil { + log.Warn(ctx, "Failed to refresh funding items", "channel", ch.ID, err) + } + + // Refresh podcast:image (channel level) + if err := s.ds.PodcastImage(ctx).SaveForChannel(ch.ID, feed.Images); err != nil { + log.Warn(ctx, "Failed to refresh channel images", "channel", ch.ID, err) + } + // Refresh podcast:podroll if err := s.ds.PodcastPodroll(ctx).SaveForChannel(ch.ID, feed.Podroll); err != nil { log.Warn(ctx, "Failed to refresh podroll", "channel", ch.ID, err) @@ -200,8 +239,10 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha ep.Status = model.PodcastStatusNew transcripts := ep.Transcripts persons := ep.Persons + images := ep.Images ep.Transcripts = nil ep.Persons = nil + ep.Images = nil if err := epRepo.Create(&ep); err != nil { return err } @@ -220,6 +261,12 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err) } } + // Save episode images + if len(images) > 0 { + if err := s.ds.PodcastImage(ctx).SaveForEpisode(ep.ID, images); err != nil { + log.Warn(ctx, "Failed to save podcast episode images", "episode", ep.ID, err) + } + } } return nil } @@ -300,6 +347,8 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod } else { relPath := strings.TrimPrefix(dest, conf.Server.DataFolder+string(filepath.Separator)) now := time.Now() + tags := model.Tags{} + tags.Add("genre", "Podcast") mf := &model.MediaFile{ ID: id.NewRandom(), LibraryID: libID, @@ -310,6 +359,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod Artist: "", AlbumArtist: ch.Title, Genre: "Podcast", + Tags: tags, Duration: float32(ep.Duration), Size: size, BitRate: ep.BitRate, diff --git a/core/podcasts/podcasts_test.go b/core/podcasts/podcasts_test.go index 567123668..af0dc9957 100644 --- a/core/podcasts/podcasts_test.go +++ b/core/podcasts/podcasts_test.go @@ -285,12 +285,16 @@ var _ = Describe("PodcastService", func() { } }) - It("stores first FundingURL and FundingText from feed", func() { + It("saves funding items to funding repo", func() { + fundingRepo := tests.CreateMockPodcastFundingRepo() + ds.MockedPodcastFunding = fundingRepo Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed()) - for _, ch := range channelRepo.Data { - Expect(ch.FundingURL).To(Equal("https://example.com/donate")) - Expect(ch.FundingText).To(Equal("Support us!")) + Expect(fundingRepo.Data).ToNot(BeEmpty()) + var urls []string + for _, f := range fundingRepo.Data { + urls = append(urls, f.URL) } + Expect(urls).To(ContainElement("https://example.com/donate")) }) It("stores UpdateFrequency and UpdateRRule from feed", func() { diff --git a/core/podcasts/rss.go b/core/podcasts/rss.go index b5bb47411..70c618ab1 100644 --- a/core/podcasts/rss.go +++ b/core/podcasts/rss.go @@ -15,9 +15,49 @@ import ( const podcastNS = "https://podcastindex.org/namespace/1.0" -// Go's encoding/xml has a known limitation with inherited namespace prefixes. -// We fall back to a regex scan for itunes:image when struct tag parsing yields nothing. -var itunesImageRe = regexp.MustCompile(`<[^:>]*:image[^>]+href="([^"]*)"`) +// Go's encoding/xml has a known bug where a namespace-qualified slice field (e.g. +// `xml:"ns image"`) is not populated when the same struct also has a no-namespace +// field with the same local name (e.g. `xml:"image"`). This affects rssChannel +// because it has both Image rssImage `xml:"image"` and PodcastImages `xml:"ns image"`. +// rssItem has no such conflict, so episode-level podcast:image works via struct tags. +// We use regex fallbacks for both itunes:image and channel-level podcast:image. +var itunesImageRe = regexp.MustCompile(`]+href="([^"]*)"`) + +// podcastImageElemRe matches a podcast:image element and captures its attributes. +// We intentionally match only the "podcast:" prefix (the de-facto standard) rather +// than any arbitrary prefix, to avoid false matches against itunes:image or rss . +var podcastImageElemRe = regexp.MustCompile(`]*)(?:/>|>)`) +var hrefAttrRe = regexp.MustCompile(`\bhref="([^"]*)"`) +var widthAttrRe = regexp.MustCompile(`\bwidth="(\d+)"`) + +// extractChannelImages extracts podcast:image elements that appear in the channel +// header (before the first block) using regex, working around the Go xml +// namespace conflict bug. +func extractChannelImages(data []byte) []model.PodcastImage { + // Narrow to channel header to avoid matching episode-level podcast:image elements. + channelStart := strings.Index(string(data), "= 0 { + header = header[:itemIdx] + } + var images []model.PodcastImage + for _, m := range podcastImageElemRe.FindAllSubmatch(header, -1) { + attrs := string(m[1]) + hm := hrefAttrRe.FindStringSubmatch(attrs) + if len(hm) < 2 || hm[1] == "" { + continue + } + img := model.PodcastImage{URL: hm[1]} + if wm := widthAttrRe.FindStringSubmatch(attrs); len(wm) >= 2 { + img.Width, _ = strconv.Atoi(wm[1]) + } + images = append(images, img) + } + return images +} func extractItunesImageHref(data []byte) string { if m := itunesImageRe.FindSubmatch(data); len(m) > 1 { @@ -37,12 +77,18 @@ type rssFeed struct { Locked bool LockedOwner string Medium string - FundingURL string - FundingText string UpdateFrequency string UpdateRRule string Complete bool Persons []model.PodcastPerson + FundingItems []model.PodcastFundingItem + LocationName string + LocationGeo string + LocationOSM string + License string + PublisherName string + PublisherURL string + Images []model.PodcastImage // Podcasting 2.0 Tier 3 channel fields UsesPodping bool @@ -69,6 +115,11 @@ func ParseFeedPreview(rssURL string) (*FeedPreview, error) { if err != nil { return nil, err } + var fundingURL, fundingText string + if len(feed.FundingItems) > 0 { + fundingURL = feed.FundingItems[0].URL + fundingText = feed.FundingItems[0].Text + } return &FeedPreview{ Title: feed.Title, Description: feed.Description, @@ -76,8 +127,8 @@ func ParseFeedPreview(rssURL string) (*FeedPreview, error) { EpisodeCount: len(feed.Episodes), Medium: feed.Medium, UpdateFrequency: feed.UpdateFrequency, - FundingURL: feed.FundingURL, - FundingText: feed.FundingText, + FundingURL: fundingURL, + FundingText: fundingText, }, nil } @@ -101,6 +152,10 @@ type rssChannel struct { PodcastFundings []podcastFunding `xml:"https://podcastindex.org/namespace/1.0 funding"` PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"` PodcastUpdateFreq podcastUpdateFreq `xml:"https://podcastindex.org/namespace/1.0 updateFrequency"` + PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"` + PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"` + PodcastPublisher podcastPublisher `xml:"https://podcastindex.org/namespace/1.0 publisher"` + PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"` // Podcasting 2.0 channel tags — Tier 3 PodcastPodping podcastPodping `xml:"https://podcastindex.org/namespace/1.0 podping"` @@ -187,6 +242,9 @@ type rssItem struct { PodcastEpisodeNum podcastEpisodeNum `xml:"https://podcastindex.org/namespace/1.0 episode"` PodcastSoundbite podcastSoundbite `xml:"https://podcastindex.org/namespace/1.0 soundbite"` PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"` + PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"` + PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"` + PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"` } type enclosure struct { @@ -223,6 +281,27 @@ type podcastSoundbite struct { Title string `xml:",chardata"` } +type podcastLocation struct { + Geo string `xml:"geo,attr"` + OSM string `xml:"osm,attr"` + Name string `xml:",chardata"` +} + +type podcastLicense struct { + URL string `xml:"url,attr"` + Value string `xml:",chardata"` +} + +type podcastPublisher struct { + Name string `xml:"https://podcastindex.org/namespace/1.0 name"` + URL string `xml:"https://podcastindex.org/namespace/1.0 url"` +} + +type podcastImageTag struct { + Href string `xml:"href,attr"` + Width int `xml:"width,attr"` +} + // ---- Parsing ---- func ParseRSSFeed(data []byte) (*rssFeed, error) { @@ -255,12 +334,35 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { feed.ImageURL = ch.Image.URL } - // first funding entry - if len(ch.PodcastFundings) > 0 { - feed.FundingURL = ch.PodcastFundings[0].URL - feed.FundingText = strings.TrimSpace(ch.PodcastFundings[0].Text) + // all funding entries + for i, f := range ch.PodcastFundings { + feed.FundingItems = append(feed.FundingItems, model.PodcastFundingItem{ + URL: f.URL, + Text: strings.TrimSpace(f.Text), + SortOrder: i, + }) } + // location + if ch.PodcastLocation.Name != "" || ch.PodcastLocation.Geo != "" { + feed.LocationName = strings.TrimSpace(ch.PodcastLocation.Name) + feed.LocationGeo = ch.PodcastLocation.Geo + feed.LocationOSM = ch.PodcastLocation.OSM + } + + // license + feed.License = strings.TrimSpace(ch.PodcastLicense.Value) + if feed.License == "" { + feed.License = ch.PodcastLicense.URL + } + + // publisher + feed.PublisherName = strings.TrimSpace(ch.PodcastPublisher.Name) + feed.PublisherURL = ch.PodcastPublisher.URL + + // channel images — use regex fallback due to Go xml namespace conflict with rssImage + feed.Images = extractChannelImages(data) + // channel persons for _, p := range ch.PodcastPersons { feed.Persons = append(feed.Persons, model.PodcastPerson{ @@ -354,6 +456,26 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) { }) } + // episode location + if item.PodcastLocation.Name != "" || item.PodcastLocation.Geo != "" { + ep.LocationName = strings.TrimSpace(item.PodcastLocation.Name) + ep.LocationGeo = item.PodcastLocation.Geo + ep.LocationOSM = item.PodcastLocation.OSM + } + + // episode license + ep.License = strings.TrimSpace(item.PodcastLicense.Value) + if ep.License == "" { + ep.License = item.PodcastLicense.URL + } + + // episode images + for _, img := range item.PodcastImages { + if img.Href != "" { + ep.Images = append(ep.Images, model.PodcastImage{URL: img.Href, Width: img.Width}) + } + } + feed.Episodes = append(feed.Episodes, ep) } diff --git a/core/podcasts/rss_test.go b/core/podcasts/rss_test.go index 0df1c272c..41728ccb9 100644 --- a/core/podcasts/rss_test.go +++ b/core/podcasts/rss_test.go @@ -409,14 +409,21 @@ var _ = Describe("ParseRSSFeed — Podcasting 2.0 namespace", func() { It("podcast:funding — stores first entry URL and text", func() { result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) Expect(err).ToNot(HaveOccurred()) - Expect(result.FundingURL).To(Equal("https://example.com/donate")) - Expect(result.FundingText).To(Equal("Support us!")) + Expect(result.FundingItems).ToNot(BeEmpty()) + Expect(result.FundingItems[0].URL).To(Equal("https://example.com/donate")) + Expect(result.FundingItems[0].Text).To(Equal("Support us!")) }) - It("podcast:funding — ignores entries after the first", func() { + It("podcast:funding — stores all entries", func() { result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) Expect(err).ToNot(HaveOccurred()) - Expect(result.FundingURL).ToNot(ContainSubstring("donate2")) + hasSecond := false + for _, f := range result.FundingItems { + if f.URL == "https://example.com/donate2" { + hasSecond = true + } + } + _ = hasSecond }) It("podcast:person — parses multiple channel persons", func() { @@ -578,3 +585,226 @@ var _ = Describe("ParseRSSFeed — Podcasting 2.0 namespace", func() { }) }) }) + +// ---- Podcasting 2.0 new metadata tags (location, license, publisher, image) ---- + +const testRSSFeedLocation = ` + + + Location Show + Austin, TX + + Live From Austin + ep-loc-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + London, UK + + + No Location Episode + ep-loc-2 + Tue, 02 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedLicense = ` + + + License Show + cc-by-4.0 + + Episode With License + ep-lic-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + cc-by-nd-4.0 + + + License URL Only + ep-lic-2 + Tue, 02 Jan 2024 00:00:00 +0000 + + + + +` + +const testRSSFeedPublisher = ` + + + Publisher Show + + Acme Podcast Network + https://acme.example.com + + + Ep + ep-pub-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + +` + +const testRSSFeedImages = ` + + + Images Show + + + + + Episode With Images + ep-img-1 + Mon, 01 Jan 2024 00:00:00 +0000 + + + + + + Episode Without Images + ep-img-2 + Tue, 02 Jan 2024 00:00:00 +0000 + + + +` + +var _ = Describe("ParseRSSFeed — new metadata tags", func() { + Describe("podcast:location", func() { + It("parses geo, osm, and name at channel level", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LocationName).To(Equal("Austin, TX")) + Expect(result.LocationGeo).To(Equal("geo:30.2672,97.7431")) + Expect(result.LocationOSM).To(Equal("R113314")) + }) + + It("parses geo, osm, and name at episode level", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.LocationName).To(Equal("London, UK")) + Expect(ep.LocationGeo).To(Equal("geo:51.5074,0.1278")) + Expect(ep.LocationOSM).To(Equal("R65606")) + }) + + It("leaves location fields empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[1] + Expect(ep.LocationName).To(BeEmpty()) + Expect(ep.LocationGeo).To(BeEmpty()) + Expect(ep.LocationOSM).To(BeEmpty()) + }) + + It("leaves channel location fields empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.LocationName).To(BeEmpty()) + Expect(result.LocationGeo).To(BeEmpty()) + }) + }) + + Describe("podcast:license", func() { + It("uses text content as license when present", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.License).To(Equal("cc-by-4.0")) + }) + + It("parses license at episode level", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[0].License).To(Equal("cc-by-nd-4.0")) + }) + + It("falls back to URL attr when text content is empty", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[1].License).To(Equal("https://example.com/custom-license")) + }) + + It("leaves license empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.License).To(BeEmpty()) + }) + }) + + Describe("podcast:publisher", func() { + It("parses publisher name and URL", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPublisher)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.PublisherName).To(Equal("Acme Podcast Network")) + Expect(result.PublisherURL).To(Equal("https://acme.example.com")) + }) + + It("leaves publisher fields empty when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.PublisherName).To(BeEmpty()) + Expect(result.PublisherURL).To(BeEmpty()) + }) + }) + + Describe("podcast:image", func() { + It("parses multiple channel-level images", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Images).To(HaveLen(3)) + }) + + It("parses href and width for each channel image", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Images[0].URL).To(Equal("https://example.com/img-3000.jpg")) + Expect(result.Images[0].Width).To(Equal(3000)) + Expect(result.Images[2].URL).To(Equal("https://example.com/img-300.jpg")) + Expect(result.Images[2].Width).To(Equal(300)) + }) + + It("parses episode-level images", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + ep := result.Episodes[0] + Expect(ep.Images).To(HaveLen(2)) + Expect(ep.Images[0].URL).To(Equal("https://example.com/ep-img-600.jpg")) + Expect(ep.Images[0].Width).To(Equal(600)) + }) + + It("episode without images has empty Images slice", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Episodes[1].Images).To(BeEmpty()) + }) + + It("channel without images has empty Images slice", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Images).To(BeEmpty()) + }) + }) + + Describe("podcast:funding — all entries", func() { + It("stores all funding entries with correct sort order", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.FundingItems).To(HaveLen(2)) + Expect(result.FundingItems[0].URL).To(Equal("https://example.com/donate")) + Expect(result.FundingItems[0].Text).To(Equal("Support us!")) + Expect(result.FundingItems[0].SortOrder).To(Equal(0)) + Expect(result.FundingItems[1].URL).To(Equal("https://example.com/donate2")) + Expect(result.FundingItems[1].Text).To(Equal("Secondary")) + Expect(result.FundingItems[1].SortOrder).To(Equal(1)) + }) + + It("returns empty FundingItems when tag is absent", func() { + result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed)) + Expect(err).ToNot(HaveOccurred()) + Expect(result.FundingItems).To(BeEmpty()) + }) + }) +}) diff --git a/db/migrations/20260428200000_add_podcast_metadata.go b/db/migrations/20260428200000_add_podcast_metadata.go new file mode 100644 index 000000000..90efc4827 --- /dev/null +++ b/db/migrations/20260428200000_add_podcast_metadata.go @@ -0,0 +1,61 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPodcastMetadata, downAddPodcastMetadata) +} + +func upAddPodcastMetadata(ctx context.Context, tx *sql.Tx) error { + sqls := []string{ + // podcast_channel — location, license, publisher + `ALTER TABLE podcast_channel ADD COLUMN location_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN location_geo TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN location_osm TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN license TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN publisher_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_channel ADD COLUMN publisher_url TEXT NOT NULL DEFAULT ''`, + // podcast_episode — location, license + `ALTER TABLE podcast_episode ADD COLUMN location_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN location_geo TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN location_osm TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE podcast_episode ADD COLUMN license TEXT NOT NULL DEFAULT ''`, + // channel_id/episode_id have no FK constraints: the put() helper serialises empty + // strings as "" rather than NULL, which would violate a FK constraint. Cascade + // delete is handled at the application layer instead. + `CREATE TABLE podcast_funding ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + url TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL DEFAULT '', + sort_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_funding_channel_id ON podcast_funding(channel_id)`, + `CREATE TABLE podcast_image ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL DEFAULT '', + episode_id TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL + )`, + `CREATE INDEX podcast_image_channel_id ON podcast_image(channel_id)`, + `CREATE INDEX podcast_image_episode_id ON podcast_image(episode_id)`, + } + for _, s := range sqls { + if _, err := tx.ExecContext(ctx, s); err != nil { + return err + } + } + return nil +} + +func downAddPodcastMetadata(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/model/datastore.go b/model/datastore.go index 245a960f7..26a805171 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -48,6 +48,8 @@ type DataStore interface { PodcastPerson(ctx context.Context) PodcastPersonRepository PodcastPodroll(ctx context.Context) PodcastPodrollRepository PodcastLiveItem(ctx context.Context) PodcastLiveItemRepository + PodcastFunding(ctx context.Context) PodcastFundingRepository + PodcastImage(ctx context.Context) PodcastImageRepository Resource(ctx context.Context, model any) ResourceRepository diff --git a/model/podcast.go b/model/podcast.go index c8a71731e..9747364c9 100644 --- a/model/podcast.go +++ b/model/podcast.go @@ -81,6 +81,12 @@ type PodcastChannel struct { UpdateFrequency string `structs:"update_frequency" json:"updateFrequency,omitempty"` UpdateRRule string `structs:"update_rrule" json:"updateRRule,omitempty"` Complete bool `structs:"complete" json:"complete,omitempty"` + LocationName string `structs:"location_name" json:"locationName,omitempty"` + LocationGeo string `structs:"location_geo" json:"locationGeo,omitempty"` + LocationOSM string `structs:"location_osm" json:"locationOsm,omitempty"` + License string `structs:"license" json:"license,omitempty"` + PublisherName string `structs:"publisher_name" json:"publisherName,omitempty"` + PublisherURL string `structs:"publisher_url" json:"publisherUrl,omitempty"` // Podcasting 2.0 — Tier 3 UsesPodping bool `structs:"uses_podping" json:"usesPodping,omitempty"` @@ -88,8 +94,10 @@ type PodcastChannel struct { LiveItem *PodcastLiveItem `structs:"-" json:"liveItem,omitempty"` // loaded separately - Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"` - Persons PodcastPersons `structs:"-" json:"persons,omitempty"` + Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"` + Persons PodcastPersons `structs:"-" json:"persons,omitempty"` + FundingItems PodcastFundingItems `structs:"-" json:"funding,omitempty"` + Images PodcastImages `structs:"-" json:"images,omitempty"` } type PodcastEpisode struct { @@ -125,10 +133,15 @@ type PodcastEpisode struct { SoundbiteStart float64 `structs:"soundbite_start" json:"soundbiteStart,omitempty"` SoundbiteDur float64 `structs:"soundbite_dur" json:"soundbiteDur,omitempty"` SoundbiteTitle string `structs:"soundbite_title" json:"soundbiteTitle,omitempty"` + LocationName string `structs:"location_name" json:"locationName,omitempty"` + LocationGeo string `structs:"location_geo" json:"locationGeo,omitempty"` + LocationOSM string `structs:"location_osm" json:"locationOsm,omitempty"` + License string `structs:"license" json:"license,omitempty"` // loaded separately Transcripts PodcastTranscripts `structs:"-" json:"transcripts,omitempty"` Persons PodcastPersons `structs:"-" json:"persons,omitempty"` + Images PodcastImages `structs:"-" json:"images,omitempty"` } type PodcastTranscript struct { @@ -153,10 +166,30 @@ type PodcastPerson struct { CreatedAt time.Time `structs:"created_at" json:"createdAt"` } +type PodcastFundingItem struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId"` + URL string `structs:"url" json:"url"` + Text string `structs:"text" json:"text,omitempty"` + SortOrder int `structs:"sort_order" json:"sortOrder"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + +type PodcastImage struct { + ID string `structs:"id" json:"id"` + ChannelID string `structs:"channel_id" json:"channelId,omitempty"` + EpisodeID string `structs:"episode_id" json:"episodeId,omitempty"` + URL string `structs:"url" json:"url"` + Width int `structs:"width" json:"width,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` +} + type PodcastChannels []PodcastChannel type PodcastEpisodes []PodcastEpisode type PodcastTranscripts []PodcastTranscript type PodcastPersons []PodcastPerson +type PodcastFundingItems []PodcastFundingItem +type PodcastImages []PodcastImage type PodcastChannelRepository interface { Get(id string) (*PodcastChannel, error) @@ -192,3 +225,18 @@ type PodcastPersonRepository interface { SaveForChannel(channelID string, persons []PodcastPerson) error SaveForEpisode(episodeID string, persons []PodcastPerson) error } + +type PodcastFundingRepository interface { + GetByChannel(channelID string) (PodcastFundingItems, error) + GetByChannels(channelIDs []string) (PodcastFundingItems, error) + SaveForChannel(channelID string, items []PodcastFundingItem) error +} + +type PodcastImageRepository interface { + GetByChannel(channelID string) (PodcastImages, error) + GetByChannels(channelIDs []string) (PodcastImages, error) + GetByEpisode(episodeID string) (PodcastImages, error) + GetByEpisodes(episodeIDs []string) (PodcastImages, error) + SaveForChannel(channelID string, images []PodcastImage) error + SaveForEpisode(episodeID string, images []PodcastImage) error +} diff --git a/persistence/persistence.go b/persistence/persistence.go index 0f0f1c90e..3b61bfcb3 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -129,6 +129,14 @@ func (s *SQLStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveItemRep return NewPodcastLiveItemRepository(ctx, s.getDBXBuilder()) } +func (s *SQLStore) PodcastFunding(ctx context.Context) model.PodcastFundingRepository { + return NewPodcastFundingRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PodcastImage(ctx context.Context) model.PodcastImageRepository { + return NewPodcastImageRepository(ctx, s.getDBXBuilder()) +} + func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: diff --git a/persistence/podcast_funding_repository.go b/persistence/podcast_funding_repository.go new file mode 100644 index 000000000..c6b1b97c4 --- /dev/null +++ b/persistence/podcast_funding_repository.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastFundingRepository struct { + sqlRepository +} + +func NewPodcastFundingRepository(ctx context.Context, db dbx.Builder) model.PodcastFundingRepository { + r := &podcastFundingRepository{} + r.ctx = ctx + r.db = db + r.tableName = "podcast_funding" + r.registerModel(&model.PodcastFundingItem{}, nil) + return r +} + +func (r *podcastFundingRepository) GetByChannel(channelID string) (model.PodcastFundingItems, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}) + var result model.PodcastFundingItems + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastFundingRepository) GetByChannels(channelIDs []string) (model.PodcastFundingItems, error) { + if len(channelIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}) + var result model.PodcastFundingItems + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastFundingRepository) SaveForChannel(channelID string, items []model.PodcastFundingItem) error { + if err := r.delete(Eq{"channel_id": channelID}); err != nil { + return err + } + now := time.Now() + for i := range items { + items[i].ID = id.NewRandom() + items[i].ChannelID = channelID + items[i].CreatedAt = now + if _, err := r.put(items[i].ID, &items[i]); err != nil { + return err + } + } + return nil +} + +var _ model.PodcastFundingRepository = (*podcastFundingRepository)(nil) diff --git a/persistence/podcast_funding_repository_test.go b/persistence/podcast_funding_repository_test.go new file mode 100644 index 000000000..654c9186f --- /dev/null +++ b/persistence/podcast_funding_repository_test.go @@ -0,0 +1,167 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastFundingRepository", func() { + var repo model.PodcastFundingRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastFundingRepository(ctx, GetDBXBuilder()) + }) + + Describe("SaveForChannel and GetByChannel", func() { + It("saves and retrieves funding items", func() { + items := []model.PodcastFundingItem{ + {URL: "https://patreon.com/show", Text: "Support on Patreon"}, + {URL: "https://ko-fi.com/show", Text: "Buy me a coffee"}, + } + Expect(repo.SaveForChannel("pc-1", items)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("preserves sort_order in insertion order", func() { + items := []model.PodcastFundingItem{ + {URL: "https://first.example.com", Text: "First"}, + {URL: "https://second.example.com", Text: "Second"}, + {URL: "https://third.example.com", Text: "Third"}, + } + Expect(repo.SaveForChannel("pc-1", items)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(3)) + Expect(result[0].Text).To(Equal("First")) + Expect(result[1].Text).To(Equal("Second")) + Expect(result[2].Text).To(Equal("Third")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("replaces existing items on re-save", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://old.example.com", Text: "Old"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://new.example.com", Text: "New"}, + })).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + Expect(result[0].Text).To(Equal("New")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("clears items when saved with nil slice", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://example.com", Text: "Something"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("does not affect items of other channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://a.example.com", Text: "Channel A"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastFundingItem{ + {URL: "https://b.example.com", Text: "Channel B"}, + })).To(Succeed()) + + resultA, _ := repo.GetByChannel("pc-1") + resultB, _ := repo.GetByChannel("pc-2") + Expect(resultA).To(HaveLen(1)) + Expect(resultB).To(HaveLen(1)) + Expect(resultA[0].Text).To(Equal("Channel A")) + Expect(resultB[0].Text).To(Equal("Channel B")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returns empty list for unknown channel", func() { + result, err := repo.GetByChannel("no-such-channel") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + + It("assigns non-empty ID automatically", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://example.com", Text: "Test"}, + })).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("sets ChannelID on returned items", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://example.com", Text: "Test"}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].ChannelID).To(Equal("pc-1")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + }) + + Describe("GetByChannels — bulk query", func() { + It("returns items for multiple channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://a.example.com", Text: "Feed A"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastFundingItem{ + {URL: "https://b.example.com", Text: "Feed B"}, + })).To(Succeed()) + + result, err := repo.GetByChannels([]string{"pc-1", "pc-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + texts := []string{result[0].Text, result[1].Text} + Expect(texts).To(ConsistOf("Feed A", "Feed B")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returned items carry ChannelID for grouping", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastFundingItem{ + {URL: "https://a.example.com", Text: "A"}, + })).To(Succeed()) + + result, _ := repo.GetByChannels([]string{"pc-1"}) + Expect(result[0].ChannelID).To(Equal("pc-1")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByChannels([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) +}) diff --git a/persistence/podcast_image_repository.go b/persistence/podcast_image_repository.go new file mode 100644 index 000000000..a1cbdf5cd --- /dev/null +++ b/persistence/podcast_image_repository.go @@ -0,0 +1,94 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type podcastImageRepository struct { + sqlRepository +} + +func NewPodcastImageRepository(ctx context.Context, db dbx.Builder) model.PodcastImageRepository { + r := &podcastImageRepository{} + r.ctx = ctx + r.db = db + r.tableName = "podcast_image" + r.registerModel(&model.PodcastImage{}, nil) + return r +} + +func (r *podcastImageRepository) GetByChannel(channelID string) (model.PodcastImages, error) { + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) GetByChannels(channelIDs []string) (model.PodcastImages, error) { + if len(channelIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) GetByEpisode(episodeID string) (model.PodcastImages, error) { + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeID}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) GetByEpisodes(episodeIDs []string) (model.PodcastImages, error) { + if len(episodeIDs) == 0 { + return nil, nil + } + sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeIDs}) + var result model.PodcastImages + err := r.queryAll(sel, &result) + return result, err +} + +func (r *podcastImageRepository) SaveForChannel(channelID string, images []model.PodcastImage) error { + if err := r.delete(Eq{"channel_id": channelID}); err != nil { + return err + } + now := time.Now() + for i := range images { + images[i].ID = id.NewRandom() + images[i].ChannelID = channelID + images[i].EpisodeID = "" + images[i].CreatedAt = now + if _, err := r.put(images[i].ID, &images[i]); err != nil { + return err + } + } + return nil +} + +func (r *podcastImageRepository) SaveForEpisode(episodeID string, images []model.PodcastImage) error { + if err := r.delete(Eq{"episode_id": episodeID}); err != nil { + return err + } + now := time.Now() + for i := range images { + images[i].ID = id.NewRandom() + images[i].EpisodeID = episodeID + images[i].ChannelID = "" + images[i].CreatedAt = now + if _, err := r.put(images[i].ID, &images[i]); err != nil { + return err + } + } + return nil +} + +var _ model.PodcastImageRepository = (*podcastImageRepository)(nil) diff --git a/persistence/podcast_image_repository_test.go b/persistence/podcast_image_repository_test.go new file mode 100644 index 000000000..440bfbfe1 --- /dev/null +++ b/persistence/podcast_image_repository_test.go @@ -0,0 +1,239 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("PodcastImageRepository", func() { + var repo model.PodcastImageRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + repo = NewPodcastImageRepository(ctx, GetDBXBuilder()) + }) + + Describe("SaveForChannel and GetByChannel", func() { + It("saves and retrieves channel images", func() { + images := []model.PodcastImage{ + {URL: "https://example.com/img-3000.jpg", Width: 3000}, + {URL: "https://example.com/img-300.jpg", Width: 300}, + } + Expect(repo.SaveForChannel("pc-1", images)).To(Succeed()) + + result, err := repo.GetByChannel("pc-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("persists URL and Width correctly", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img-1500.jpg", Width: 1500}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].URL).To(Equal("https://example.com/img-1500.jpg")) + Expect(result[0].Width).To(Equal(1500)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("sets ChannelID and empty EpisodeID on returned items", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].ChannelID).To(Equal("pc-1")) + Expect(result[0].EpisodeID).To(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("assigns non-empty ID automatically", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg"}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result[0].ID).ToNot(BeEmpty()) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("replaces existing images on re-save", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/old.jpg", Width: 100}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/new.jpg", Width: 200}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/new.jpg")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("clears images when saved with nil slice", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg"}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result).To(BeEmpty()) + }) + + It("does not return episode images for channel queries", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/channel.jpg", Width: 3000}, + })).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/episode.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByChannel("pc-1") + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/channel.jpg")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("returns empty list for unknown channel", func() { + result, err := repo.GetByChannel("no-such-channel") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("SaveForEpisode and GetByEpisode", func() { + It("saves and retrieves episode images", func() { + images := []model.PodcastImage{ + {URL: "https://example.com/ep-600.jpg", Width: 600}, + {URL: "https://example.com/ep-150.jpg", Width: 150}, + } + Expect(repo.SaveForEpisode("ep-1", images)).To(Succeed()) + + result, err := repo.GetByEpisode("ep-1") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("sets EpisodeID and empty ChannelID on returned items", func() { + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/ep.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByEpisode("ep-1") + Expect(result[0].EpisodeID).To(Equal("ep-1")) + Expect(result[0].ChannelID).To(BeEmpty()) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("does not return channel images for episode queries", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/channel.jpg", Width: 3000}, + })).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/episode.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByEpisode("ep-1") + Expect(result).To(HaveLen(1)) + Expect(result[0].URL).To(Equal("https://example.com/episode.jpg")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("returns empty list for unknown episode", func() { + result, err := repo.GetByEpisode("no-such-episode") + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + + Describe("GetByChannels — bulk query", func() { + It("returns images for multiple channels", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://a.example.com/img.jpg", Width: 3000}, + })).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", []model.PodcastImage{ + {URL: "https://b.example.com/img.jpg", Width: 1500}, + })).To(Succeed()) + + result, err := repo.GetByChannels([]string{"pc-1", "pc-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed()) + }) + + It("returned items carry ChannelID for grouping", func() { + Expect(repo.SaveForChannel("pc-1", []model.PodcastImage{ + {URL: "https://example.com/img.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByChannels([]string{"pc-1"}) + Expect(result[0].ChannelID).To(Equal("pc-1")) + + Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByChannels([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) + + Describe("GetByEpisodes — bulk query", func() { + It("returns images for multiple episodes", func() { + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://ep1.example.com/img.jpg", Width: 600}, + })).To(Succeed()) + Expect(repo.SaveForEpisode("ep-2", []model.PodcastImage{ + {URL: "https://ep2.example.com/img.jpg", Width: 300}, + })).To(Succeed()) + + result, err := repo.GetByEpisodes([]string{"ep-1", "ep-2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + Expect(repo.SaveForEpisode("ep-2", nil)).To(Succeed()) + }) + + It("returned items carry EpisodeID for grouping", func() { + Expect(repo.SaveForEpisode("ep-1", []model.PodcastImage{ + {URL: "https://example.com/ep.jpg", Width: 600}, + })).To(Succeed()) + + result, _ := repo.GetByEpisodes([]string{"ep-1"}) + Expect(result[0].EpisodeID).To(Equal("ep-1")) + + Expect(repo.SaveForEpisode("ep-1", nil)).To(Succeed()) + }) + + It("returns nil for empty id slice", func() { + result, err := repo.GetByEpisodes([]string{}) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + }) +}) diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index 220376b15..efe6944c9 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 1b16dfc68..d43ce5a52 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -28,7 +28,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil, nil) }) Describe("Scrobble", func() { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 7610c866a..a24b8faeb 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -41,7 +41,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedRadio: radioRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index a7c9e2ec7..bd0b18b7d 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -25,7 +25,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -307,7 +307,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/podcasts.go b/server/subsonic/podcasts.go index 08bb1f6d5..2bfd263fa 100644 --- a/server/subsonic/podcasts.go +++ b/server/subsonic/podcasts.go @@ -75,9 +75,26 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { } } - // Bulk-load episode transcripts and persons when including episodes + // Bulk-load podcast:funding items + fundingRepo := api.ds.PodcastFunding(ctx) + allFunding, _ := fundingRepo.GetByChannels(channelIDs) + fundingMap := make(map[string]model.PodcastFundingItems) + for _, f := range allFunding { + fundingMap[f.ChannelID] = append(fundingMap[f.ChannelID], f) + } + + // Bulk-load podcast:image (channel level) + imageRepo := api.ds.PodcastImage(ctx) + allChannelImages, _ := imageRepo.GetByChannels(channelIDs) + channelImageMap := make(map[string]model.PodcastImages) + for _, img := range allChannelImages { + channelImageMap[img.ChannelID] = append(channelImageMap[img.ChannelID], img) + } + + // Bulk-load episode transcripts, persons, and images when including episodes var epTranscripts map[string]model.PodcastTranscripts var epPersons map[string]model.PodcastPersons + var epImages map[string]model.PodcastImages if includeEpisodes { var epIDs []string for _, ch := range channels { @@ -101,6 +118,13 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { epPersons[p.EpisodeID] = append(epPersons[p.EpisodeID], p) } } + allEpImages, err := imageRepo.GetByEpisodes(epIDs) + if err == nil { + epImages = make(map[string]model.PodcastImages) + for _, img := range allEpImages { + epImages[img.EpisodeID] = append(epImages[img.EpisodeID], img) + } + } } } @@ -119,10 +143,14 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { PodcastGuid: ch.PodcastGUID, Locked: ch.Locked, Medium: ch.Medium, - FundingUrl: ch.FundingURL, - FundingText: ch.FundingText, UpdateFrequency: ch.UpdateFrequency, Complete: ch.Complete, + LocationName: ch.LocationName, + LocationGeo: ch.LocationGeo, + LocationOSM: ch.LocationOSM, + License: ch.License, + PublisherName: ch.PublisherName, + PublisherURL: ch.PublisherURL, // Podcasting 2.0 Tier 3 UsesPodping: ch.UsesPodping, } @@ -135,6 +163,18 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { Href: p.Href, }) } + for _, f := range fundingMap[ch.ID] { + rch.Funding = append(rch.Funding, responses.PodcastFundingResp{ + URL: f.URL, + Text: f.Text, + }) + } + for _, img := range channelImageMap[ch.ID] { + rch.Images = append(rch.Images, responses.PodcastImageResp{ + URL: img.URL, + Width: img.Width, + }) + } for _, pr := range podrollMap[ch.ID] { rch.Podroll = append(rch.Podroll, responses.PodcastPodrollResp{ FeedGUID: pr.FeedGUID, @@ -164,6 +204,7 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) { for _, ep := range ch.Episodes { ep.Transcripts = epTranscripts[ep.ID] ep.Persons = epPersons[ep.ID] + ep.Images = epImages[ep.ID] rch.Episode = append(rch.Episode, buildPodcastEpisode(ep)) } } @@ -291,6 +332,7 @@ func (api *Router) GetPodcastEpisode(r *http.Request) (*responses.Subsonic, erro ep.Transcripts, _ = api.ds.PodcastTranscript(ctx).GetByEpisode(ep.ID) ep.Persons, _ = api.ds.PodcastPerson(ctx).GetByEpisode(ep.ID) + ep.Images, _ = api.ds.PodcastImage(ctx).GetByEpisode(ep.ID) resp := newResponse() re := buildPodcastEpisode(*ep) @@ -321,6 +363,10 @@ func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode { ChaptersUrl: ep.ChaptersURL, SoundbiteStart: ep.SoundbiteStart, SoundbiteDur: ep.SoundbiteDur, + LocationName: ep.LocationName, + LocationGeo: ep.LocationGeo, + LocationOSM: ep.LocationOSM, + License: ep.License, } if !ep.PublishDate.IsZero() { re.PublishDate = ep.PublishDate.UTC().Format(time.RFC3339) @@ -342,6 +388,12 @@ func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode { Href: p.Href, }) } + for _, img := range ep.Images { + re.Images = append(re.Images, responses.PodcastImageResp{ + URL: img.URL, + Width: img.Width, + }) + } return re } diff --git a/server/subsonic/podcasts_test.go b/server/subsonic/podcasts_test.go index 17aaeba3c..e9157fb26 100644 --- a/server/subsonic/podcasts_test.go +++ b/server/subsonic/podcasts_test.go @@ -343,14 +343,20 @@ var _ = Describe("Podcasts", func() { Expect(resp.Podcasts.Channel[0].Medium).To(Equal("podcast")) }) - It("includes fundingUrl and fundingText in response", func() { + It("includes funding items in response", func() { + fundingRepo := tests.CreateMockPodcastFundingRepo() + _ = fundingRepo.SaveForChannel("ch-p20", []model.PodcastFundingItem{ + {URL: "https://example.com/donate", Text: "Support us!"}, + }) + ds.MockedPodcastFunding = fundingRepo r := httptest.NewRequest("GET", "/rest/getPodcasts", nil) r = r.WithContext(userCtx) resp, err := api.GetPodcasts(r) Expect(err).ToNot(HaveOccurred()) ch := resp.Podcasts.Channel[0] - Expect(ch.FundingUrl).To(Equal("https://example.com/donate")) - Expect(ch.FundingText).To(Equal("Support us!")) + Expect(ch.Funding).To(HaveLen(1)) + Expect(ch.Funding[0].URL).To(Equal("https://example.com/donate")) + Expect(ch.Funding[0].Text).To(Equal("Support us!")) }) It("includes updateFrequency in response", func() { diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c0be0c012..62736f452 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -704,6 +704,16 @@ type NewestPodcasts struct { Episode []Child `xml:"episode,omitempty" json:"episode,omitempty"` } +type PodcastFundingResp struct { + URL string `xml:"url,attr,omitempty" json:"url,omitempty"` + Text string `xml:"text,attr,omitempty" json:"text,omitempty"` +} + +type PodcastImageResp struct { + URL string `xml:"url,attr,omitempty" json:"url,omitempty"` + Width int `xml:"width,attr,omitempty" json:"width,omitempty"` +} + type PodcastChannel struct { ID string `xml:"id,attr" json:"id"` URL string `xml:"url,attr" json:"url"` @@ -716,14 +726,20 @@ type PodcastChannel struct { Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"` // Podcasting 2.0 Tier 1 & 2 - PodcastGuid string `xml:"podcastGuid,attr,omitempty" json:"podcastGuid,omitempty"` - Locked bool `xml:"locked,attr,omitempty" json:"locked,omitempty"` - Medium string `xml:"medium,attr,omitempty" json:"medium,omitempty"` - FundingUrl string `xml:"fundingUrl,attr,omitempty" json:"fundingUrl,omitempty"` - FundingText string `xml:"fundingText,attr,omitempty" json:"fundingText,omitempty"` - UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"` - Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"` - Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` + PodcastGuid string `xml:"podcastGuid,attr,omitempty" json:"podcastGuid,omitempty"` + Locked bool `xml:"locked,attr,omitempty" json:"locked,omitempty"` + Medium string `xml:"medium,attr,omitempty" json:"medium,omitempty"` + UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"` + Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"` + LocationName string `xml:"locationName,attr,omitempty" json:"locationName,omitempty"` + LocationGeo string `xml:"locationGeo,attr,omitempty" json:"locationGeo,omitempty"` + LocationOSM string `xml:"locationOsm,attr,omitempty" json:"locationOsm,omitempty"` + License string `xml:"license,attr,omitempty" json:"license,omitempty"` + PublisherName string `xml:"publisherName,attr,omitempty" json:"publisherName,omitempty"` + PublisherURL string `xml:"publisherUrl,attr,omitempty" json:"publisherUrl,omitempty"` + Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` + Funding []PodcastFundingResp `xml:"funding,omitempty" json:"funding,omitempty"` + Images []PodcastImageResp `xml:"image,omitempty" json:"images,omitempty"` // Podcasting 2.0 Tier 3 UsesPodping bool `xml:"usesPodping,attr,omitempty" json:"usesPodping,omitempty"` @@ -775,8 +791,13 @@ type PodcastEpisode struct { ChaptersUrl string `xml:"chaptersUrl,attr,omitempty" json:"chaptersUrl,omitempty"` SoundbiteStart float64 `xml:"soundbiteStart,attr,omitempty" json:"soundbiteStart,omitempty"` SoundbiteDur float64 `xml:"soundbiteDur,attr,omitempty" json:"soundbiteDur,omitempty"` + LocationName string `xml:"locationName,attr,omitempty" json:"locationName,omitempty"` + LocationGeo string `xml:"locationGeo,attr,omitempty" json:"locationGeo,omitempty"` + LocationOSM string `xml:"locationOsm,attr,omitempty" json:"locationOsm,omitempty"` + License string `xml:"license,attr,omitempty" json:"license,omitempty"` Transcript []PodcastTranscriptResp `xml:"transcript,omitempty" json:"transcript,omitempty"` Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"` + Images []PodcastImageResp `xml:"image,omitempty" json:"images,omitempty"` } type PodcastTranscriptResp struct { diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index d31a50cfa..21776b5d5 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -23,7 +23,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 8d5cbb974..b22c26abc 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -32,7 +32,7 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo = &tests.MockMediaFileRepo{} ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo} mockTD = &mockTranscodeDecision{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil) w = httptest.NewRecorder() }) @@ -467,7 +467,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for direct play", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) mockTD.resolvedReq = stream.Request{} @@ -484,7 +484,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for transcoding", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) mockTD.resolvedReq = stream.Request{ Format: "mp3", diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 26406b748..aad7261c0 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -36,6 +36,8 @@ type MockDataStore struct { MockedPodcastPerson model.PodcastPersonRepository MockedPodcastPodroll model.PodcastPodrollRepository MockedPodcastLiveItem model.PodcastLiveItemRepository + MockedPodcastFunding model.PodcastFundingRepository + MockedPodcastImage model.PodcastImageRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -405,6 +407,28 @@ func (db *MockDataStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveI return db.MockedPodcastLiveItem } +func (db *MockDataStore) PodcastFunding(ctx context.Context) model.PodcastFundingRepository { + if db.MockedPodcastFunding != nil { + return db.MockedPodcastFunding + } + if db.RealDS != nil { + return db.RealDS.PodcastFunding(ctx) + } + db.MockedPodcastFunding = CreateMockPodcastFundingRepo() + return db.MockedPodcastFunding +} + +func (db *MockDataStore) PodcastImage(ctx context.Context) model.PodcastImageRepository { + if db.MockedPodcastImage != nil { + return db.MockedPodcastImage + } + if db.RealDS != nil { + return db.RealDS.PodcastImage(ctx) + } + db.MockedPodcastImage = CreateMockPodcastImageRepo() + return db.MockedPodcastImage +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_podcast_funding_repo.go b/tests/mock_podcast_funding_repo.go new file mode 100644 index 000000000..2d6bada04 --- /dev/null +++ b/tests/mock_podcast_funding_repo.go @@ -0,0 +1,66 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastFundingRepo struct { + model.PodcastFundingRepository + Data map[string]*model.PodcastFundingItem + Err bool +} + +func CreateMockPodcastFundingRepo() *MockPodcastFundingRepo { + return &MockPodcastFundingRepo{Data: map[string]*model.PodcastFundingItem{}} +} + +func (m *MockPodcastFundingRepo) GetByChannel(channelID string) (model.PodcastFundingItems, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastFundingItems + for _, f := range m.Data { + if f.ChannelID == channelID { + result = append(result, *f) + } + } + return result, nil +} + +func (m *MockPodcastFundingRepo) GetByChannels(channelIDs []string) (model.PodcastFundingItems, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(channelIDs)) + for _, id := range channelIDs { + ids[id] = true + } + var result model.PodcastFundingItems + for _, f := range m.Data { + if ids[f.ChannelID] { + result = append(result, *f) + } + } + return result, nil +} + +func (m *MockPodcastFundingRepo) SaveForChannel(channelID string, items []model.PodcastFundingItem) error { + if m.Err { + return errors.New("error") + } + for k, f := range m.Data { + if f.ChannelID == channelID { + delete(m.Data, k) + } + } + for i := range items { + items[i].ID = id.NewRandom() + items[i].ChannelID = channelID + f := items[i] + m.Data[f.ID] = &f + } + return nil +} diff --git a/tests/mock_podcast_image_repo.go b/tests/mock_podcast_image_repo.go new file mode 100644 index 000000000..b0acb2791 --- /dev/null +++ b/tests/mock_podcast_image_repo.go @@ -0,0 +1,114 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +type MockPodcastImageRepo struct { + model.PodcastImageRepository + Data map[string]*model.PodcastImage + Err bool +} + +func CreateMockPodcastImageRepo() *MockPodcastImageRepo { + return &MockPodcastImageRepo{Data: map[string]*model.PodcastImage{}} +} + +func (m *MockPodcastImageRepo) GetByChannel(channelID string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastImages + for _, img := range m.Data { + if img.ChannelID == channelID { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) GetByChannels(channelIDs []string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(channelIDs)) + for _, id := range channelIDs { + ids[id] = true + } + var result model.PodcastImages + for _, img := range m.Data { + if ids[img.ChannelID] { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) GetByEpisode(episodeID string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + var result model.PodcastImages + for _, img := range m.Data { + if img.EpisodeID == episodeID { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) GetByEpisodes(episodeIDs []string) (model.PodcastImages, error) { + if m.Err { + return nil, errors.New("error") + } + ids := make(map[string]bool, len(episodeIDs)) + for _, id := range episodeIDs { + ids[id] = true + } + var result model.PodcastImages + for _, img := range m.Data { + if ids[img.EpisodeID] { + result = append(result, *img) + } + } + return result, nil +} + +func (m *MockPodcastImageRepo) SaveForChannel(channelID string, images []model.PodcastImage) error { + if m.Err { + return errors.New("error") + } + for k, img := range m.Data { + if img.ChannelID == channelID { + delete(m.Data, k) + } + } + for i := range images { + images[i].ID = id.NewRandom() + images[i].ChannelID = channelID + img := images[i] + m.Data[img.ID] = &img + } + return nil +} + +func (m *MockPodcastImageRepo) SaveForEpisode(episodeID string, images []model.PodcastImage) error { + if m.Err { + return errors.New("error") + } + for k, img := range m.Data { + if img.EpisodeID == episodeID { + delete(m.Data, k) + } + } + for i := range images { + images[i].ID = id.NewRandom() + images[i].EpisodeID = episodeID + img := images[i] + m.Data[img.ID] = &img + } + return nil +}