feat(podcast): add Podcasting 2.0 metadata support with persistence layer

- Parse podcast:images, podcast:funding, podcast:transcript, podcast:chapters,
  podcast:soundbite, and podcast:person tags from RSS feeds
- Add DB migration and repositories for podcast images and funding sources
- Expose Podcasting 2.0 fields in Subsonic API responses
- Tag downloaded episodes with genre=Podcast for playlist filtering
- Update mock data store and fix subsonic test compatibility
This commit is contained in:
Ji-ho Lee 2026-04-28 16:34:27 +09:00 committed by Lee Ji-ho
parent 1dc1b3cb52
commit fb9461d4bf
No known key found for this signature in database
GPG Key ID: 399C495F019E884F
24 changed files with 1413 additions and 46 deletions

View File

@ -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,

View File

@ -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() {

View File

@ -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(`<itunes:image\b[^>]+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 <image>.
var podcastImageElemRe = regexp.MustCompile(`<podcast:image\b([^>]*)(?:/>|>)`)
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 <item> 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), "<channel")
if channelStart < 0 {
return nil
}
header := data[channelStart:]
if itemIdx := strings.Index(string(header), "<item"); itemIdx >= 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)
}

View File

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Location Show</title>
<podcast:location geo="geo:30.2672,97.7431" osm="R113314">Austin, TX</podcast:location>
<item>
<title>Live From Austin</title>
<guid>ep-loc-1</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
<podcast:location geo="geo:51.5074,0.1278" osm="R65606">London, UK</podcast:location>
</item>
<item>
<title>No Location Episode</title>
<guid>ep-loc-2</guid>
<pubDate>Tue, 02 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep2.mp3" length="1024" type="audio/mpeg"/>
</item>
</channel>
</rss>`
const testRSSFeedLicense = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>License Show</title>
<podcast:license url="https://creativecommons.org/licenses/by/4.0/">cc-by-4.0</podcast:license>
<item>
<title>Episode With License</title>
<guid>ep-lic-1</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
<podcast:license url="https://creativecommons.org/licenses/by-nd/4.0/">cc-by-nd-4.0</podcast:license>
</item>
<item>
<title>License URL Only</title>
<guid>ep-lic-2</guid>
<pubDate>Tue, 02 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep2.mp3" length="1024" type="audio/mpeg"/>
<podcast:license url="https://example.com/custom-license"/>
</item>
</channel>
</rss>`
const testRSSFeedPublisher = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Publisher Show</title>
<podcast:publisher>
<podcast:name>Acme Podcast Network</podcast:name>
<podcast:url>https://acme.example.com</podcast:url>
</podcast:publisher>
<item>
<title>Ep</title>
<guid>ep-pub-1</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
</item>
</channel>
</rss>`
const testRSSFeedImages = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Images Show</title>
<podcast:image href="https://example.com/img-3000.jpg" width="3000"/>
<podcast:image href="https://example.com/img-1500.jpg" width="1500"/>
<podcast:image href="https://example.com/img-300.jpg" width="300"/>
<item>
<title>Episode With Images</title>
<guid>ep-img-1</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
<podcast:image href="https://example.com/ep-img-600.jpg" width="600"/>
<podcast:image href="https://example.com/ep-img-150.jpg" width="150"/>
</item>
<item>
<title>Episode Without Images</title>
<guid>ep-img-2</guid>
<pubDate>Tue, 02 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep2.mp3" length="1024" type="audio/mpeg"/>
</item>
</channel>
</rss>`
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())
})
})
})

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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:

View File

@ -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)

View File

@ -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())
})
})
})

View File

@ -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)

View File

@ -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())
})
})
})

View File

@ -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()
})

View File

@ -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() {

View File

@ -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"

View File

@ -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() {

View File

@ -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
}

View File

@ -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() {

View File

@ -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 {

View File

@ -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)

View File

@ -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",

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}