mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
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:
parent
1dc1b3cb52
commit
fb9461d4bf
@ -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,
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
61
db/migrations/20260428200000_add_podcast_metadata.go
Normal file
61
db/migrations/20260428200000_add_podcast_metadata.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
59
persistence/podcast_funding_repository.go
Normal file
59
persistence/podcast_funding_repository.go
Normal 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)
|
||||
167
persistence/podcast_funding_repository_test.go
Normal file
167
persistence/podcast_funding_repository_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
94
persistence/podcast_image_repository.go
Normal file
94
persistence/podcast_image_repository.go
Normal 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)
|
||||
239
persistence/podcast_image_repository_test.go
Normal file
239
persistence/podcast_image_repository_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
66
tests/mock_podcast_funding_repo.go
Normal file
66
tests/mock_podcast_funding_repo.go
Normal 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
|
||||
}
|
||||
114
tests/mock_podcast_image_repo.go
Normal file
114
tests/mock_podcast_image_repo.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user