feat(podcast): implement Podcasting 2.0 namespace support (Tier 1–3)

Adds full support for the Podcasting 2.0 namespace
(https://podcastindex.org/namespace/1.0) across RSS parsing, persistence,
Subsonic API responses, and OpenSubsonic extensions.

- podcast:guid: channel-level UUIDv5 for stable identity across feed URL changes
- podcast:chapters: per-episode chapters URL + MIME type (chaptersUrl in API)
- podcast:transcript: multiple transcripts per episode stored in a dedicated
  podcast_transcript table; attributes: url, type, language, rel
- podcast:season: season number + optional name per episode
- podcast:episode: episode number (decimal string) + optional display label

- podcast:person: host/guest entries at both channel and episode level stored in
  a dedicated podcast_person table; attributes: name, role, group, img, href;
  role defaults to "host" and group defaults to "cast" per spec
- podcast:locked: feed lock flag + optional owner email on channel
- podcast:funding: first funding entry URL + display text on channel
- podcast:medium: content type classification on channel
- podcast:soundbite: startTime (float), duration (float), title per episode
- podcast:updateFrequency: display text + rrule + complete flag on channel

- podcast:podroll: creator-recommended feed list stored in podcast_podroll table
  (feedGuid, feedUrl, title, sort_order); returned as podroll[] in GetPodcasts
- podcast:liveItem: live stream detection stored in podcast_live_item table
  (one row per channel, unique index); stores status, start/end times,
  enclosure URL/type, and contentLink for fallback playback; returned as
  liveItem object in GetPodcasts; Upsert preserves created_at on updates
- podcast:podping: usesPodping boolean on channel; RefreshChannels skips
  channels with usesPodping=true (they receive updates via Podping WebSocket)

- 20260428000000_add_podcast20.go: ALTER TABLE adds 9 columns to
  podcast_channel, 9 columns to podcast_episode; CREATE TABLE
  podcast_transcript (episode_id FK, url, mime_type, language, rel) and
  podcast_person (no FK constraints — put() serialises "" not NULL)
- 20260428120000_add_podcast_tier3.go: ALTER TABLE adds uses_podping to
  podcast_channel; CREATE TABLE podcast_podroll and podcast_live_item
  (UNIQUE INDEX on channel_id)

GetPodcasts response (PodcastChannel) gains:
  podcastGuid, locked, medium, fundingUrl, fundingText, updateFrequency,
  complete, usesPodping, person[], podroll[], liveItem{}

GetPodcastEpisode response (PodcastEpisode) gains:
  season, seasonName, episode, episodeDisplay, chaptersUrl, soundbiteStart,
  soundbiteDur, transcript[], person[]

GetPodcastEpisode now loads transcripts and persons from their repositories
(previously only read the base episode row).

New OpenSubsonic extensions declared:
  podcastChapters, podcastTranscripts, podcastSeason, podcastPerson,
  podcastFunding, podcastMedium, podcastPodroll, podcastLiveItem, podcastPodping

- core/podcasts/rss_test.go: 41 new specs covering all namespace tags,
  default value handling (role→"host", group→"cast"), backward compatibility
- core/podcasts/podcasts_test.go: 26 new service specs covering AddChannel
  field persistence, transcript/person saving, podroll/liveItem saving,
  RefreshChannels podping skip behaviour
- persistence/podcast_transcript_repository_test.go: 10 specs
- persistence/podcast_person_repository_test.go: 12 specs
- persistence/podcast_podroll_repository_test.go: 10 specs
- persistence/podcast_live_item_repository_test.go: 8 specs
- server/subsonic/podcasts_test.go: 15 new handler specs for Tier 2 and
  Tier 3 fields in GetPodcasts and GetPodcastEpisode responses
This commit is contained in:
Jiho Andrew Lee 2026-04-28 12:40:34 +09:00
commit 152a032ae1
27 changed files with 3147 additions and 86 deletions

View file

@ -79,23 +79,74 @@ func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error {
}
ch := &model.PodcastChannel{
URL: rssURL,
Title: feed.Title,
Description: feed.Description,
ImageURL: feed.ImageURL,
Status: model.PodcastStatusNew,
URL: rssURL,
Title: feed.Title,
Description: feed.Description,
ImageURL: feed.ImageURL,
Status: model.PodcastStatusNew,
PodcastGUID: feed.PodcastGUID,
Locked: feed.Locked,
LockedOwner: feed.LockedOwner,
Medium: feed.Medium,
FundingURL: feed.FundingURL,
FundingText: feed.FundingText,
UpdateFrequency: feed.UpdateFrequency,
UpdateRRule: feed.UpdateRRule,
Complete: feed.Complete,
UsesPodping: feed.UsesPodping,
}
if err := s.ds.PodcastChannel(ctx).Create(ch); err != nil {
return err
}
// Save channel-level persons
if len(feed.Persons) > 0 {
if err := s.ds.PodcastPerson(ctx).SaveForChannel(ch.ID, feed.Persons); err != nil {
log.Warn(ctx, "Failed to save podcast channel persons", "channel", ch.ID, err)
}
}
// Save podcast:podroll items
if len(feed.Podroll) > 0 {
if err := s.ds.PodcastPodroll(ctx).SaveForChannel(ch.ID, feed.Podroll); err != nil {
log.Warn(ctx, "Failed to save podcast podroll", "channel", ch.ID, err)
}
}
// Save podcast:liveItem entries
for _, li := range feed.LiveItems {
li.ChannelID = ch.ID
if err := s.ds.PodcastLiveItem(ctx).Upsert(&li); err != nil {
log.Warn(ctx, "Failed to save podcast live item", "channel", ch.ID, err)
}
}
for i := range feed.Episodes {
ep := feed.Episodes[i]
ep.ChannelID = ch.ID
ep.Status = model.PodcastStatusNew
transcripts := ep.Transcripts
persons := ep.Persons
ep.Transcripts = nil
ep.Persons = nil
if err := s.ds.PodcastEpisode(ctx).Create(&ep); err != nil {
return err
}
// Save episode transcripts
if len(transcripts) > 0 {
for j := range transcripts {
transcripts[j].EpisodeID = ep.ID
}
if err := s.ds.PodcastTranscript(ctx).Save(transcripts); err != nil {
log.Warn(ctx, "Failed to save podcast episode transcripts", "episode", ep.ID, err)
}
}
// Save episode persons
if len(persons) > 0 {
if err := s.ds.PodcastPerson(ctx).SaveForEpisode(ep.ID, persons); err != nil {
log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err)
}
}
}
ch.Status = model.PodcastStatusCompleted
@ -109,6 +160,9 @@ func (s *podcastService) RefreshChannels(ctx context.Context) error {
}
for _, ch := range channels {
if ch.UsesPodping {
continue // skip — this channel uses Podping for updates
}
if err := s.refreshChannel(ctx, ch); err != nil {
log.Warn(ctx, "Failed to refresh podcast channel", "channel", ch.Title, err)
}
@ -122,6 +176,19 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha
return err
}
// Refresh podcast:podroll
if err := s.ds.PodcastPodroll(ctx).SaveForChannel(ch.ID, feed.Podroll); err != nil {
log.Warn(ctx, "Failed to refresh podroll", "channel", ch.ID, err)
}
// Refresh podcast:liveItem
for _, li := range feed.LiveItems {
li.ChannelID = ch.ID
if err := s.ds.PodcastLiveItem(ctx).Upsert(&li); err != nil {
log.Warn(ctx, "Failed to upsert live item", "channel", ch.ID, err)
}
}
epRepo := s.ds.PodcastEpisode(ctx)
for i := range feed.Episodes {
ep := feed.Episodes[i]
@ -131,9 +198,28 @@ func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastCha
}
ep.ChannelID = ch.ID
ep.Status = model.PodcastStatusNew
transcripts := ep.Transcripts
persons := ep.Persons
ep.Transcripts = nil
ep.Persons = nil
if err := epRepo.Create(&ep); err != nil {
return err
}
// Save episode transcripts
if len(transcripts) > 0 {
for j := range transcripts {
transcripts[j].EpisodeID = ep.ID
}
if err := s.ds.PodcastTranscript(ctx).Save(transcripts); err != nil {
log.Warn(ctx, "Failed to save podcast episode transcripts", "episode", ep.ID, err)
}
}
// Save episode persons
if len(persons) > 0 {
if err := s.ds.PodcastPerson(ctx).SaveForEpisode(ep.ID, persons); err != nil {
log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err)
}
}
}
return nil
}

View file

@ -115,7 +115,7 @@ var _ = Describe("PodcastService", func() {
}
err := svc.RefreshChannels(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(episodeRepo.Data).To(HaveLen(2)) // 기존 1 + 신규 1
Expect(episodeRepo.Data).To(HaveLen(2)) // 1 existing + 1 new
})
})
@ -245,6 +245,160 @@ var _ = Describe("PodcastService", func() {
})
})
Describe("AddChannel — Podcasting 2.0 field persistence", func() {
var transcriptRepo *tests.MockPodcastTranscriptRepo
var personRepo *tests.MockPodcastPersonRepo
var p20Server *httptest.Server
BeforeEach(func() {
transcriptRepo = tests.CreateMockPodcastTranscriptRepo()
personRepo = tests.CreateMockPodcastPersonRepo()
ds.MockedPodcastTranscript = transcriptRepo
ds.MockedPodcastPerson = personRepo
p20Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
fmt.Fprint(w, testRSSFeedPodcast20)
}))
DeferCleanup(p20Server.Close)
})
It("stores PodcastGUID from feed", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
for _, ch := range channelRepo.Data {
Expect(ch.PodcastGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810"))
}
})
It("stores Locked flag and LockedOwner from feed", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
for _, ch := range channelRepo.Data {
Expect(ch.Locked).To(BeTrue())
Expect(ch.LockedOwner).To(Equal("owner@example.com"))
}
})
It("stores Medium from feed", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
for _, ch := range channelRepo.Data {
Expect(ch.Medium).To(Equal("podcast"))
}
})
It("stores first FundingURL and FundingText from feed", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
for _, ch := range channelRepo.Data {
Expect(ch.FundingURL).To(Equal("https://example.com/donate"))
Expect(ch.FundingText).To(Equal("Support us!"))
}
})
It("stores UpdateFrequency and UpdateRRule from feed", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
for _, ch := range channelRepo.Data {
Expect(ch.UpdateFrequency).To(Equal("Weekly"))
Expect(ch.UpdateRRule).To(Equal("FREQ=WEEKLY"))
}
})
It("saves channel-level podcast:person entries", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
Expect(personRepo.Data).ToNot(BeEmpty())
var channelPersons []string
for _, p := range personRepo.Data {
if p.ChannelID != "" {
channelPersons = append(channelPersons, p.Name)
}
}
Expect(channelPersons).To(ConsistOf("Jane Host", "Bob Producer"))
})
It("saves episode podcast:transcript entries", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
Expect(transcriptRepo.Data).ToNot(BeEmpty())
var mimeTypes []string
for _, t := range transcriptRepo.Data {
mimeTypes = append(mimeTypes, t.MimeType)
}
Expect(mimeTypes).To(ConsistOf("text/vtt", "application/x-subrip"))
})
It("stores transcript language and rel attributes", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
var vttLanguage, vttRel string
for _, t := range transcriptRepo.Data {
if t.MimeType == "text/vtt" {
vttLanguage = t.Language
vttRel = t.Rel
}
}
Expect(vttLanguage).To(Equal("en"))
Expect(vttRel).To(Equal("captions"))
})
It("saves episode-level podcast:person entries", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
var episodePersonNames []string
for _, p := range personRepo.Data {
if p.EpisodeID != "" {
episodePersonNames = append(episodePersonNames, p.Name)
}
}
Expect(episodePersonNames).To(ContainElement("John Guest"))
})
It("stores episode ChaptersURL", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
var chaptersURLs []string
for _, ep := range episodeRepo.Data {
if ep.ChaptersURL != "" {
chaptersURLs = append(chaptersURLs, ep.ChaptersURL)
}
}
Expect(chaptersURLs).To(ContainElement("https://example.com/ep1/chapters.json"))
})
It("stores episode Season number and name", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
var ep1 *model.PodcastEpisode
for _, ep := range episodeRepo.Data {
if ep.GUID == "guid-ep-001" {
ep1 = ep
}
}
Expect(ep1).ToNot(BeNil())
Expect(ep1.Season).To(Equal(1))
Expect(ep1.SeasonName).To(Equal("Season One"))
})
It("stores episode EpisodeNumber and EpisodeDisplay", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
var ep1 *model.PodcastEpisode
for _, ep := range episodeRepo.Data {
if ep.GUID == "guid-ep-001" {
ep1 = ep
}
}
Expect(ep1).ToNot(BeNil())
Expect(ep1.EpisodeNumber).To(Equal("1"))
Expect(ep1.EpisodeDisplay).To(Equal("Ep.1"))
})
It("stores episode Soundbite fields", func() {
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
var ep1 *model.PodcastEpisode
for _, ep := range episodeRepo.Data {
if ep.GUID == "guid-ep-001" {
ep1 = ep
}
}
Expect(ep1).ToNot(BeNil())
Expect(ep1.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001))
Expect(ep1.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001))
Expect(ep1.SoundbiteTitle).To(Equal("Best moment"))
})
})
Describe("DownloadEpisode with timestamp", func() {
BeforeEach(func() {
channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1", Title: "Test Channel"}
@ -264,4 +418,113 @@ var _ = Describe("PodcastService", func() {
}, "3s").Should(BeTrue())
})
})
Describe("AddChannel — Tier 3 field persistence", func() {
var podrollRepo *tests.MockPodcastPodrollRepo
var liveItemRepo *tests.MockPodcastLiveItemRepo
var tier3Server *httptest.Server
BeforeEach(func() {
podrollRepo = tests.CreateMockPodcastPodrollRepo()
liveItemRepo = tests.CreateMockPodcastLiveItemRepo()
ds.MockedPodcastPodroll = podrollRepo
ds.MockedPodcastLiveItem = liveItemRepo
})
Context("when feed has podcast:podping usesPodping=true", func() {
BeforeEach(func() {
tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
fmt.Fprint(w, testRSSFeedPodping)
}))
DeferCleanup(tier3Server.Close)
})
It("stores UsesPodping=true on the channel", func() {
Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed())
for _, ch := range channelRepo.Data {
Expect(ch.UsesPodping).To(BeTrue())
}
})
})
Context("when feed has podcast:podroll", func() {
BeforeEach(func() {
tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
fmt.Fprint(w, testRSSFeedPodroll)
}))
DeferCleanup(tier3Server.Close)
})
It("saves podroll items for the channel", func() {
Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed())
Expect(podrollRepo.Data).ToNot(BeEmpty())
var urls []string
for _, item := range podrollRepo.Data {
urls = append(urls, item.FeedURL)
}
Expect(urls).To(ConsistOf(
"https://example.com/feed.xml",
"https://other.com/feed.xml",
))
})
})
Context("when feed has podcast:liveItem", func() {
BeforeEach(func() {
tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
fmt.Fprint(w, testRSSFeedLiveItem)
}))
DeferCleanup(tier3Server.Close)
})
It("saves the live item for the channel", func() {
Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed())
Expect(liveItemRepo.Data).ToNot(BeEmpty())
for _, li := range liveItemRepo.Data {
Expect(li.Status).To(Equal("live"))
Expect(li.GUID).To(Equal("live-guid-001"))
}
})
})
})
Describe("RefreshChannels — Tier 3 podping skip", func() {
var podrollRepo *tests.MockPodcastPodrollRepo
var liveItemRepo *tests.MockPodcastLiveItemRepo
BeforeEach(func() {
podrollRepo = tests.CreateMockPodcastPodrollRepo()
liveItemRepo = tests.CreateMockPodcastLiveItemRepo()
ds.MockedPodcastPodroll = podrollRepo
ds.MockedPodcastLiveItem = liveItemRepo
})
It("skips channels with UsesPodping=true during refresh", func() {
// UsesPodping channel points to a server that would add episodes.
channelRepo.Data["ch-podping"] = &model.PodcastChannel{
ID: "ch-podping",
URL: mockServer.URL + "/feed.xml",
UsesPodping: true,
}
initialEpisodeCount := len(episodeRepo.Data)
Expect(svc.RefreshChannels(ctx)).To(Succeed())
// No new episodes should be added because the only channel uses podping.
Expect(episodeRepo.Data).To(HaveLen(initialEpisodeCount))
})
It("still refreshes channels with UsesPodping=false", func() {
channelRepo.Data["ch-normal"] = &model.PodcastChannel{
ID: "ch-normal",
URL: mockServer.URL + "/feed.xml",
UsesPodping: false,
}
Expect(svc.RefreshChannels(ctx)).To(Succeed())
// Episodes from the mock feed should have been added.
Expect(episodeRepo.Data).ToNot(BeEmpty())
})
})
})

View file

@ -13,6 +13,8 @@ import (
"github.com/navidrome/navidrome/model"
)
const podcastNS = "https://podcastindex.org/namespace/1.0"
// Go's encoding/xml has a known limitation with inherited namespace prefixes.
// We fall back to a regex scan for itunes:image when struct tag parsing yields nothing.
var itunesImageRe = regexp.MustCompile(`<[^:>]*:image[^>]+href="([^"]*)"`)
@ -29,6 +31,23 @@ type rssFeed struct {
Description string
ImageURL string
Episodes []model.PodcastEpisode
// Podcasting 2.0 channel fields
PodcastGUID string
Locked bool
LockedOwner string
Medium string
FundingURL string
FundingText string
UpdateFrequency string
UpdateRRule string
Complete bool
Persons []model.PodcastPerson
// Podcasting 2.0 Tier 3 channel fields
UsesPodping bool
Podroll []model.PodcastPodrollItem
LiveItems []model.PodcastLiveItem
}
type FeedPreview struct {
@ -37,6 +56,12 @@ type FeedPreview struct {
ImageURL string `json:"imageUrl"`
EpisodeCount int `json:"episodeCount"`
AlreadyExists bool `json:"alreadyExists"`
// Podcasting 2.0
Medium string `json:"medium,omitempty"`
UpdateFrequency string `json:"updateFrequency,omitempty"`
FundingURL string `json:"fundingUrl,omitempty"`
FundingText string `json:"fundingText,omitempty"`
}
func ParseFeedPreview(rssURL string) (*FeedPreview, error) {
@ -45,13 +70,19 @@ func ParseFeedPreview(rssURL string) (*FeedPreview, error) {
return nil, err
}
return &FeedPreview{
Title: feed.Title,
Description: feed.Description,
ImageURL: feed.ImageURL,
EpisodeCount: len(feed.Episodes),
Title: feed.Title,
Description: feed.Description,
ImageURL: feed.ImageURL,
EpisodeCount: len(feed.Episodes),
Medium: feed.Medium,
UpdateFrequency: feed.UpdateFrequency,
FundingURL: feed.FundingURL,
FundingText: feed.FundingText,
}, nil
}
// ---- XML struct definitions ----
type rssRoot struct {
Channel rssChannel `xml:"channel"`
}
@ -62,6 +93,50 @@ type rssChannel struct {
Image rssImage `xml:"image"`
ItunesImage itunesImage `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
Items []rssItem `xml:"item"`
// Podcasting 2.0 channel tags — Tier 1 & 2
PodcastGUID string `xml:"https://podcastindex.org/namespace/1.0 guid"`
PodcastLocked podcastLocked `xml:"https://podcastindex.org/namespace/1.0 locked"`
PodcastMedium string `xml:"https://podcastindex.org/namespace/1.0 medium"`
PodcastFundings []podcastFunding `xml:"https://podcastindex.org/namespace/1.0 funding"`
PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"`
PodcastUpdateFreq podcastUpdateFreq `xml:"https://podcastindex.org/namespace/1.0 updateFrequency"`
// Podcasting 2.0 channel tags — Tier 3
PodcastPodping podcastPodping `xml:"https://podcastindex.org/namespace/1.0 podping"`
PodcastPodroll podcastPodroll `xml:"https://podcastindex.org/namespace/1.0 podroll"`
PodcastLiveItems []podcastLiveItemXML `xml:"https://podcastindex.org/namespace/1.0 liveItem"`
}
// Tier 3 XML parsing structs.
type podcastPodping struct {
UsesPodping string `xml:"usesPodping,attr"`
}
type podcastRemoteItem struct {
FeedGUID string `xml:"feedGuid,attr"`
FeedURL string `xml:"feedUrl,attr"`
Title string `xml:"title,attr"`
}
type podcastPodroll struct {
Items []podcastRemoteItem `xml:"https://podcastindex.org/namespace/1.0 remoteItem"`
}
type podcastContentLink struct {
Href string `xml:"href,attr"`
Text string `xml:",chardata"`
}
type podcastLiveItemXML struct {
Status string `xml:"status,attr"`
Start string `xml:"start,attr"`
End string `xml:"end,attr"`
Title string `xml:"title"`
GUID string `xml:"guid"`
Enclosure enclosure `xml:"enclosure"`
ContentLink podcastContentLink `xml:"https://podcastindex.org/namespace/1.0 contentLink"`
}
type rssImage struct {
@ -72,6 +147,30 @@ type itunesImage struct {
Href string `xml:"href,attr"`
}
type podcastLocked struct {
Owner string `xml:"owner,attr"`
Value string `xml:",chardata"`
}
type podcastFunding struct {
URL string `xml:"url,attr"`
Text string `xml:",chardata"`
}
type podcastPerson struct {
Role string `xml:"role,attr"`
Group string `xml:"group,attr"`
Img string `xml:"img,attr"`
Href string `xml:"href,attr"`
Name string `xml:",chardata"`
}
type podcastUpdateFreq struct {
Complete string `xml:"complete,attr"`
RRule string `xml:"rrule,attr"`
Text string `xml:",chardata"`
}
type rssItem struct {
Title string `xml:"title"`
Description string `xml:"description"`
@ -80,6 +179,14 @@ type rssItem struct {
PubDate string `xml:"pubDate"`
Enclosure enclosure `xml:"enclosure"`
ItunesDur string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
// Podcasting 2.0 episode tags
PodcastChapters podcastChapters `xml:"https://podcastindex.org/namespace/1.0 chapters"`
PodcastTranscripts []podcastTranscript `xml:"https://podcastindex.org/namespace/1.0 transcript"`
PodcastSeason podcastSeason `xml:"https://podcastindex.org/namespace/1.0 season"`
PodcastEpisodeNum podcastEpisodeNum `xml:"https://podcastindex.org/namespace/1.0 episode"`
PodcastSoundbite podcastSoundbite `xml:"https://podcastindex.org/namespace/1.0 soundbite"`
PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"`
}
type enclosure struct {
@ -88,6 +195,36 @@ type enclosure struct {
Type string `xml:"type,attr"`
}
type podcastChapters struct {
URL string `xml:"url,attr"`
Type string `xml:"type,attr"`
}
type podcastTranscript struct {
URL string `xml:"url,attr"`
Type string `xml:"type,attr"`
Language string `xml:"language,attr"`
Rel string `xml:"rel,attr"`
}
type podcastSeason struct {
Name string `xml:"name,attr"`
Value string `xml:",chardata"`
}
type podcastEpisodeNum struct {
Display string `xml:"display,attr"`
Value string `xml:",chardata"`
}
type podcastSoundbite struct {
StartTime string `xml:"startTime,attr"`
Duration string `xml:"duration,attr"`
Title string `xml:",chardata"`
}
// ---- Parsing ----
func ParseRSSFeed(data []byte) (*rssFeed, error) {
var root rssRoot
if err := xml.Unmarshal(data, &root); err != nil {
@ -98,11 +235,18 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) {
feed := &rssFeed{
Title: ch.Title,
Description: ch.Description,
// Podcasting 2.0 channel
PodcastGUID: ch.PodcastGUID,
Locked: strings.TrimSpace(ch.PodcastLocked.Value) == "yes",
LockedOwner: ch.PodcastLocked.Owner,
Medium: ch.PodcastMedium,
UpdateFrequency: strings.TrimSpace(ch.PodcastUpdateFreq.Text),
UpdateRRule: ch.PodcastUpdateFreq.RRule,
Complete: strings.TrimSpace(ch.PodcastUpdateFreq.Complete) == "true",
}
// itunes:image takes precedence over <image><url>.
// Use regex fallback because Go's xml package doesn't always resolve
// namespace prefixes inherited from ancestor elements.
if href := extractItunesImageHref(data); href != "" {
feed.ImageURL = href
} else if ch.ItunesImage.Href != "" {
@ -111,6 +255,53 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) {
feed.ImageURL = ch.Image.URL
}
// first funding entry
if len(ch.PodcastFundings) > 0 {
feed.FundingURL = ch.PodcastFundings[0].URL
feed.FundingText = strings.TrimSpace(ch.PodcastFundings[0].Text)
}
// channel persons
for _, p := range ch.PodcastPersons {
feed.Persons = append(feed.Persons, model.PodcastPerson{
Name: strings.TrimSpace(p.Name),
Role: defaultStr(p.Role, "host"),
Group: defaultStr(p.Group, "cast"),
Img: p.Img,
Href: p.Href,
})
}
// podcast:podping
feed.UsesPodping = strings.TrimSpace(ch.PodcastPodping.UsesPodping) == "true"
// podcast:podroll
for i, item := range ch.PodcastPodroll.Items {
feed.Podroll = append(feed.Podroll, model.PodcastPodrollItem{
FeedGUID: item.FeedGUID,
FeedURL: item.FeedURL,
Title: item.Title,
SortOrder: i,
})
}
// podcast:liveItem
for _, li := range ch.PodcastLiveItems {
startTime, _ := time.Parse(time.RFC3339, li.Start)
endTime, _ := time.Parse(time.RFC3339, li.End)
feed.LiveItems = append(feed.LiveItems, model.PodcastLiveItem{
GUID: li.GUID,
Title: li.Title,
Status: li.Status,
StartTime: startTime,
EndTime: endTime,
EnclosureURL: li.Enclosure.URL,
EnclosureType: li.Enclosure.Type,
ContentLinkURL: li.ContentLink.Href,
ContentLinkText: strings.TrimSpace(li.ContentLink.Text),
})
}
for _, item := range ch.Items {
desc := item.Description
if desc == "" {
@ -131,7 +322,38 @@ func ParseRSSFeed(data []byte) (*rssFeed, error) {
Suffix: suffix,
Duration: parseDuration(item.ItunesDur),
Status: model.PodcastStatusNew,
// Podcasting 2.0 episode
ChaptersURL: item.PodcastChapters.URL,
ChaptersType: item.PodcastChapters.Type,
Season: parseInt(item.PodcastSeason.Value),
SeasonName: item.PodcastSeason.Name,
EpisodeNumber: strings.TrimSpace(item.PodcastEpisodeNum.Value),
EpisodeDisplay: item.PodcastEpisodeNum.Display,
SoundbiteStart: parseFloat(item.PodcastSoundbite.StartTime),
SoundbiteDur: parseFloat(item.PodcastSoundbite.Duration),
SoundbiteTitle: strings.TrimSpace(item.PodcastSoundbite.Title),
}
for _, t := range item.PodcastTranscripts {
ep.Transcripts = append(ep.Transcripts, model.PodcastTranscript{
URL: t.URL,
MimeType: t.Type,
Language: t.Language,
Rel: t.Rel,
})
}
for _, p := range item.PodcastPersons {
ep.Persons = append(ep.Persons, model.PodcastPerson{
Name: strings.TrimSpace(p.Name),
Role: defaultStr(p.Role, "host"),
Group: defaultStr(p.Group, "cast"),
Img: p.Img,
Href: p.Href,
})
}
feed.Episodes = append(feed.Episodes, ep)
}
@ -175,6 +397,23 @@ func parseDuration(s string) int {
}
}
func parseInt(s string) int {
v, _ := strconv.Atoi(strings.TrimSpace(s))
return v
}
func parseFloat(s string) float64 {
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v
}
func defaultStr(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
var mimeToSuffix = map[string]string{
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
@ -192,7 +431,6 @@ func suffixFromMIME(mimeType, enclosureURL string) string {
if s, ok := mimeToSuffix[base]; ok {
return s
}
// fallback: extract from URL path
if u, err := url.Parse(enclosureURL); err == nil {
if ext := path.Ext(u.Path); ext != "" {
return strings.TrimPrefix(ext, ".")

View file

@ -114,3 +114,467 @@ var _ = Describe("ParseRSSFeed", func() {
})
})
})
// Podcasting 2.0 namespace (https://podcastindex.org/namespace/1.0) parsing tests.
const testRSSFeedPodcast20 = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Podcast 2.0 Show</title>
<description>Testing Podcasting 2.0</description>
<itunes:image href="https://example.com/cover.jpg"/>
<podcast:guid>917393e3-1b1e-5cef-ace4-edaa54e1f810</podcast:guid>
<podcast:locked owner="owner@example.com">yes</podcast:locked>
<podcast:medium>podcast</podcast:medium>
<podcast:funding url="https://example.com/donate">Support us!</podcast:funding>
<podcast:funding url="https://example.com/donate2">Secondary</podcast:funding>
<podcast:person role="host" group="cast"
img="https://example.com/host.jpg"
href="https://example.com/host">Jane Host</podcast:person>
<podcast:person role="producer"
img="https://example.com/prod.jpg">Bob Producer</podcast:person>
<podcast:updateFrequency rrule="FREQ=WEEKLY" complete="false">Weekly</podcast:updateFrequency>
<item>
<title>Episode 1</title>
<guid>guid-ep-001</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep1.mp3" length="1048576" type="audio/mpeg"/>
<itunes:duration>01:23:45</itunes:duration>
<podcast:season name="Season One">1</podcast:season>
<podcast:episode display="Ep.1">1</podcast:episode>
<podcast:chapters url="https://example.com/ep1/chapters.json"
type="application/json+chapters"/>
<podcast:transcript url="https://example.com/ep1/transcript.vtt"
type="text/vtt" language="en" rel="captions"/>
<podcast:transcript url="https://example.com/ep1/transcript.srt"
type="application/x-subrip" language="en"/>
<podcast:soundbite startTime="73.5" duration="60.0">Best moment</podcast:soundbite>
<podcast:person role="guest" href="https://example.com/guest">John Guest</podcast:person>
</item>
<item>
<title>Episode 2 — no podcast: tags</title>
<guid>guid-ep-002</guid>
<pubDate>Thu, 01 Feb 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep2.mp3" length="2097152" type="audio/mpeg"/>
</item>
</channel>
</rss>`
const testRSSFeedLocked = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Unlocked Show</title>
<podcast:locked>no</podcast:locked>
<item>
<title>Ep</title><guid>g1</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 testRSSFeedPersonDefaults = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Defaults Show</title>
<podcast:person>No Attrs Person</podcast:person>
<item>
<title>Ep</title><guid>g1</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
<podcast:person>Episode No Attrs</podcast:person>
</item>
</channel>
</rss>`
// --- Tier 3 RSS test fixtures ---
const testRSSFeedPodping = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Podping Show</title>
<podcast:podping usesPodping="true"/>
<item>
<title>Ep</title><guid>g1</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 testRSSFeedPodpingFalse = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>No Podping Show</title>
<podcast:podping usesPodping="false"/>
<item>
<title>Ep</title><guid>g1</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 testRSSFeedPodroll = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Podroll Show</title>
<podcast:podroll>
<podcast:remoteItem feedGuid="917393e3-1b1e-5cef-ace4-edaa54e1f810"
feedUrl="https://example.com/feed.xml"
title="Great Show"/>
<podcast:remoteItem feedGuid="abc123-def456"
feedUrl="https://other.com/feed.xml"/>
</podcast:podroll>
<item>
<title>Ep</title><guid>g1</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 testRSSFeedLiveItem = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Live Show Channel</title>
<podcast:liveItem status="live"
start="2024-04-27T08:00:00Z"
end="2024-04-27T09:00:00Z">
<title>Live Show</title>
<guid>live-guid-001</guid>
<enclosure url="https://stream.example.com/live.m3u8"
type="application/x-mpegURL"
length="0"/>
<podcast:contentLink href="https://youtube.com/live">Watch Live</podcast:contentLink>
</podcast:liveItem>
<item>
<title>Ep</title><guid>g1</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 testRSSFeedLiveItemPending = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Pending Live Channel</title>
<podcast:liveItem status="pending">
<title>Upcoming Show</title>
<guid>live-guid-002</guid>
<enclosure url="https://stream.example.com/pending.m3u8"
type="application/x-mpegURL"
length="0"/>
</podcast:liveItem>
<item>
<title>Ep</title><guid>g1</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>`
var _ = Describe("ParseRSSFeed — Tier 3 tags", func() {
Describe("podcast:podping", func() {
It("sets UsesPodping=true when usesPodping attribute is 'true'", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodping))
Expect(err).ToNot(HaveOccurred())
Expect(result.UsesPodping).To(BeTrue())
})
It("sets UsesPodping=false when usesPodping attribute is 'false'", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodpingFalse))
Expect(err).ToNot(HaveOccurred())
Expect(result.UsesPodping).To(BeFalse())
})
It("sets UsesPodping=false when tag is absent", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(result.UsesPodping).To(BeFalse())
})
})
Describe("podcast:podroll", func() {
It("parses multiple remoteItem entries", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll))
Expect(err).ToNot(HaveOccurred())
Expect(result.Podroll).To(HaveLen(2))
})
It("parses feedGuid, feedUrl, and title from each remoteItem", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll))
Expect(err).ToNot(HaveOccurred())
first := result.Podroll[0]
Expect(first.FeedGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810"))
Expect(first.FeedURL).To(Equal("https://example.com/feed.xml"))
Expect(first.Title).To(Equal("Great Show"))
})
It("handles remoteItem without title", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll))
Expect(err).ToNot(HaveOccurred())
second := result.Podroll[1]
Expect(second.FeedGUID).To(Equal("abc123-def456"))
Expect(second.FeedURL).To(Equal("https://other.com/feed.xml"))
Expect(second.Title).To(BeEmpty())
})
It("assigns SortOrder in declaration order", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll))
Expect(err).ToNot(HaveOccurred())
Expect(result.Podroll[0].SortOrder).To(Equal(0))
Expect(result.Podroll[1].SortOrder).To(Equal(1))
})
It("returns empty podroll when tag is absent", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(result.Podroll).To(BeEmpty())
})
})
Describe("podcast:liveItem", func() {
It("parses status, start, and end attributes", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItem))
Expect(err).ToNot(HaveOccurred())
Expect(result.LiveItems).To(HaveLen(1))
li := result.LiveItems[0]
Expect(li.Status).To(Equal("live"))
Expect(li.StartTime.UTC().Format(time.RFC3339)).To(Equal("2024-04-27T08:00:00Z"))
Expect(li.EndTime.UTC().Format(time.RFC3339)).To(Equal("2024-04-27T09:00:00Z"))
})
It("parses title, guid, enclosure, and contentLink", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItem))
Expect(err).ToNot(HaveOccurred())
li := result.LiveItems[0]
Expect(li.Title).To(Equal("Live Show"))
Expect(li.GUID).To(Equal("live-guid-001"))
Expect(li.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8"))
Expect(li.EnclosureType).To(Equal("application/x-mpegURL"))
Expect(li.ContentLinkURL).To(Equal("https://youtube.com/live"))
Expect(li.ContentLinkText).To(Equal("Watch Live"))
})
It("handles pending liveItem without start/end times", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItemPending))
Expect(err).ToNot(HaveOccurred())
Expect(result.LiveItems).To(HaveLen(1))
li := result.LiveItems[0]
Expect(li.Status).To(Equal("pending"))
Expect(li.StartTime.IsZero()).To(BeTrue())
Expect(li.EndTime.IsZero()).To(BeTrue())
})
It("returns empty liveItems when tag is absent", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(result.LiveItems).To(BeEmpty())
})
})
})
var _ = Describe("ParseRSSFeed — Podcasting 2.0 namespace", func() {
Describe("channel-level tags", func() {
It("podcast:guid — parses channel GUID", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.PodcastGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810"))
})
It("podcast:locked yes — sets Locked=true and LockedOwner", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.Locked).To(BeTrue())
Expect(result.LockedOwner).To(Equal("owner@example.com"))
})
It("podcast:locked no — sets Locked=false", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocked))
Expect(err).ToNot(HaveOccurred())
Expect(result.Locked).To(BeFalse())
})
It("podcast:medium — parses medium type", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.Medium).To(Equal("podcast"))
})
It("podcast:funding — stores first entry URL and text", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.FundingURL).To(Equal("https://example.com/donate"))
Expect(result.FundingText).To(Equal("Support us!"))
})
It("podcast:funding — ignores entries after the first", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.FundingURL).ToNot(ContainSubstring("donate2"))
})
It("podcast:person — parses multiple channel persons", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.Persons).To(HaveLen(2))
})
It("podcast:person — parses name, role, group, img, href", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
host := result.Persons[0]
Expect(host.Name).To(Equal("Jane Host"))
Expect(host.Role).To(Equal("host"))
Expect(host.Group).To(Equal("cast"))
Expect(host.Img).To(Equal("https://example.com/host.jpg"))
Expect(host.Href).To(Equal("https://example.com/host"))
})
It("podcast:person — role defaults to 'host' when omitted", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults))
Expect(err).ToNot(HaveOccurred())
Expect(result.Persons[0].Role).To(Equal("host"))
})
It("podcast:person — group defaults to 'cast' when omitted", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults))
Expect(err).ToNot(HaveOccurred())
Expect(result.Persons[0].Group).To(Equal("cast"))
})
It("podcast:updateFrequency — parses display text and rrule", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.UpdateFrequency).To(Equal("Weekly"))
Expect(result.UpdateRRule).To(Equal("FREQ=WEEKLY"))
})
It("podcast:updateFrequency — complete=false sets Complete=false", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.Complete).To(BeFalse())
})
})
Describe("episode-level tags", func() {
It("podcast:season — parses season number and name", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
ep := result.Episodes[0]
Expect(ep.Season).To(Equal(1))
Expect(ep.SeasonName).To(Equal("Season One"))
})
It("podcast:season — episodes without tag have Season=0", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.Episodes[1].Season).To(Equal(0))
})
It("podcast:episode — parses episode number and display label", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
ep := result.Episodes[0]
Expect(ep.EpisodeNumber).To(Equal("1"))
Expect(ep.EpisodeDisplay).To(Equal("Ep.1"))
})
It("podcast:chapters — parses chapters URL and type", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
ep := result.Episodes[0]
Expect(ep.ChaptersURL).To(Equal("https://example.com/ep1/chapters.json"))
Expect(ep.ChaptersType).To(Equal("application/json+chapters"))
})
It("podcast:chapters — episodes without tag have empty ChaptersURL", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.Episodes[1].ChaptersURL).To(BeEmpty())
})
It("podcast:transcript — parses multiple transcripts per episode", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.Episodes[0].Transcripts).To(HaveLen(2))
})
It("podcast:transcript — parses URL, type, language, rel", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
t0 := result.Episodes[0].Transcripts[0]
Expect(t0.URL).To(Equal("https://example.com/ep1/transcript.vtt"))
Expect(t0.MimeType).To(Equal("text/vtt"))
Expect(t0.Language).To(Equal("en"))
Expect(t0.Rel).To(Equal("captions"))
})
It("podcast:transcript — rel is empty when omitted", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
t1 := result.Episodes[0].Transcripts[1]
Expect(t1.Rel).To(BeEmpty())
})
It("podcast:soundbite — parses startTime and duration as float", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
ep := result.Episodes[0]
Expect(ep.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001))
Expect(ep.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001))
Expect(ep.SoundbiteTitle).To(Equal("Best moment"))
})
It("podcast:person — parses episode-level persons", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
ep := result.Episodes[0]
Expect(ep.Persons).To(HaveLen(1))
Expect(ep.Persons[0].Name).To(Equal("John Guest"))
Expect(ep.Persons[0].Role).To(Equal("guest"))
})
It("podcast:person — episode person role defaults to 'host'", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults))
Expect(err).ToNot(HaveOccurred())
Expect(result.Episodes[0].Persons[0].Role).To(Equal("host"))
})
It("podcast:person — episode person group defaults to 'cast'", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults))
Expect(err).ToNot(HaveOccurred())
Expect(result.Episodes[0].Persons[0].Group).To(Equal("cast"))
})
It("episodes without podcast: tags have zero/empty values", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
ep := result.Episodes[1]
Expect(ep.Season).To(Equal(0))
Expect(ep.ChaptersURL).To(BeEmpty())
Expect(ep.Transcripts).To(BeEmpty())
Expect(ep.Persons).To(BeEmpty())
Expect(ep.SoundbiteStart).To(BeZero())
})
})
Describe("backward compatibility", func() {
It("standard RSS feed without podcast: namespace parses correctly", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(result.Title).To(Equal("Test Podcast"))
Expect(result.PodcastGUID).To(BeEmpty())
Expect(result.Locked).To(BeFalse())
Expect(result.Medium).To(BeEmpty())
Expect(result.Persons).To(BeEmpty())
Expect(result.Episodes[0].Transcripts).To(BeEmpty())
Expect(result.Episodes[0].Season).To(Equal(0))
})
})
})

View file

@ -0,0 +1,76 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddPodcast20, downAddPodcast20)
}
func upAddPodcast20(ctx context.Context, tx *sql.Tx) error {
sqls := []string{
// podcast_channel — Tier 1
`ALTER TABLE podcast_channel ADD COLUMN podcast_guid TEXT NOT NULL DEFAULT ''`,
// podcast_channel — Tier 2
`ALTER TABLE podcast_channel ADD COLUMN locked INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE podcast_channel ADD COLUMN locked_owner TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_channel ADD COLUMN medium TEXT NOT NULL DEFAULT 'podcast'`,
`ALTER TABLE podcast_channel ADD COLUMN funding_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_channel ADD COLUMN funding_text TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_channel ADD COLUMN update_frequency TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_channel ADD COLUMN update_rrule TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_channel ADD COLUMN complete INTEGER NOT NULL DEFAULT 0`,
// podcast_episode — Tier 1
`ALTER TABLE podcast_episode ADD COLUMN season INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE podcast_episode ADD COLUMN season_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_episode ADD COLUMN episode_number TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_episode ADD COLUMN episode_display TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_episode ADD COLUMN chapters_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_episode ADD COLUMN chapters_type TEXT NOT NULL DEFAULT ''`,
// podcast_episode — Tier 2
`ALTER TABLE podcast_episode ADD COLUMN soundbite_start REAL NOT NULL DEFAULT 0`,
`ALTER TABLE podcast_episode ADD COLUMN soundbite_dur REAL NOT NULL DEFAULT 0`,
`ALTER TABLE podcast_episode ADD COLUMN soundbite_title TEXT NOT NULL DEFAULT ''`,
// new tables
`CREATE TABLE podcast_transcript (
id TEXT PRIMARY KEY,
episode_id TEXT NOT NULL REFERENCES podcast_episode(id) ON DELETE CASCADE,
url TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT '',
language TEXT NOT NULL DEFAULT '',
rel TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL
)`,
`CREATE INDEX podcast_transcript_episode_id ON podcast_transcript(episode_id)`,
// channel_id/episode_id have no FK constraints: the put() helper serialises empty
// strings as "" rather than NULL, which would violate a FK constraint. Cascade
// delete is handled at the application layer instead.
`CREATE TABLE podcast_person (
id TEXT PRIMARY KEY,
channel_id TEXT,
episode_id TEXT,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'host',
group_name TEXT NOT NULL DEFAULT 'cast',
img TEXT NOT NULL DEFAULT '',
href TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL
)`,
`CREATE INDEX podcast_person_channel_id ON podcast_person(channel_id)`,
`CREATE INDEX podcast_person_episode_id ON podcast_person(episode_id)`,
}
for _, s := range sqls {
if _, err := tx.ExecContext(ctx, s); err != nil {
return err
}
}
return nil
}
func downAddPodcast20(ctx context.Context, tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,59 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddPodcastTier3, downAddPodcastTier3)
}
func upAddPodcastTier3(ctx context.Context, tx *sql.Tx) error {
sqls := []string{
// podcast:podping flag on channel
`ALTER TABLE podcast_channel ADD COLUMN uses_podping INTEGER NOT NULL DEFAULT 0`,
// podcast:podroll — recommended feeds listed by a channel
`CREATE TABLE podcast_podroll (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
feed_guid TEXT NOT NULL DEFAULT '',
feed_url TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL
)`,
`CREATE INDEX podcast_podroll_channel_id ON podcast_podroll(channel_id)`,
// podcast:liveItem — at most one active live item per channel
`CREATE TABLE podcast_live_item (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
guid TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
start_time DATETIME,
end_time DATETIME,
enclosure_url TEXT NOT NULL DEFAULT '',
enclosure_type TEXT NOT NULL DEFAULT '',
content_link_url TEXT NOT NULL DEFAULT '',
content_link_text TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)`,
`CREATE UNIQUE INDEX podcast_live_item_channel_id ON podcast_live_item(channel_id)`,
}
for _, s := range sqls {
if _, err := tx.ExecContext(ctx, s); err != nil {
return err
}
}
return nil
}
func downAddPodcastTier3(ctx context.Context, tx *sql.Tx) error {
return nil
}

View file

@ -44,6 +44,10 @@ type DataStore interface {
ArtworkQueue(ctx context.Context) ArtworkQueueRepository
PodcastChannel(ctx context.Context) PodcastChannelRepository
PodcastEpisode(ctx context.Context) PodcastEpisodeRepository
PodcastTranscript(ctx context.Context) PodcastTranscriptRepository
PodcastPerson(ctx context.Context) PodcastPersonRepository
PodcastPodroll(ctx context.Context) PodcastPodrollRepository
PodcastLiveItem(ctx context.Context) PodcastLiveItemRepository
Resource(ctx context.Context, model any) ResourceRepository

View file

@ -2,6 +2,51 @@ package model
import "time"
// PodcastPodrollItem represents one entry in a podcast:podroll recommendation list.
type PodcastPodrollItem struct {
ID string `structs:"id" json:"id"`
ChannelID string `structs:"channel_id" json:"channelId"`
FeedGUID string `structs:"feed_guid" json:"feedGuid,omitempty"`
FeedURL string `structs:"feed_url" json:"feedUrl,omitempty"`
Title string `structs:"title" json:"title,omitempty"`
SortOrder int `structs:"sort_order" json:"sortOrder"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
}
// PodcastLiveItem represents a podcast:liveItem stream associated with a channel.
type PodcastLiveItem struct {
ID string `structs:"id" json:"id"`
ChannelID string `structs:"channel_id" json:"channelId"`
GUID string `structs:"guid" json:"guid,omitempty"`
Title string `structs:"title" json:"title,omitempty"`
Status string `structs:"status" json:"status"`
StartTime time.Time `structs:"start_time" json:"startTime,omitempty"`
EndTime time.Time `structs:"end_time" json:"endTime,omitempty"`
EnclosureURL string `structs:"enclosure_url" json:"enclosureUrl,omitempty"`
EnclosureType string `structs:"enclosure_type" json:"enclosureType,omitempty"`
ContentLinkURL string `structs:"content_link_url" json:"contentLinkUrl,omitempty"`
ContentLinkText string `structs:"content_link_text" json:"contentLinkText,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
}
// PodcastPodrollItems is a slice of PodcastPodrollItem.
type PodcastPodrollItems []PodcastPodrollItem
// PodcastPodrollRepository manages podcast:podroll entries for channels.
type PodcastPodrollRepository interface {
GetByChannel(channelID string) (PodcastPodrollItems, error)
GetByChannels(channelIDs []string) (PodcastPodrollItems, error)
SaveForChannel(channelID string, items []PodcastPodrollItem) error
}
// PodcastLiveItemRepository manages podcast:liveItem entries (one per channel).
type PodcastLiveItemRepository interface {
GetByChannel(channelID string) (*PodcastLiveItem, error)
Upsert(item *PodcastLiveItem) error
DeleteByChannel(channelID string) error
}
type PodcastStatus string
const (
@ -14,42 +59,104 @@ const (
)
type PodcastChannel struct {
ID string `structs:"id" json:"id"`
URL string `structs:"url" json:"url"`
Title string `structs:"title" json:"title"`
Description string `structs:"description" json:"description"`
ImageURL string `structs:"image_url" json:"imageUrl"`
Status PodcastStatus `structs:"status" json:"status"`
ErrorMessage string `structs:"error_message" json:"errorMessage"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"`
ID string `structs:"id" json:"id"`
URL string `structs:"url" json:"url"`
Title string `structs:"title" json:"title"`
Description string `structs:"description" json:"description"`
ImageURL string `structs:"image_url" json:"imageUrl"`
Status PodcastStatus `structs:"status" json:"status"`
ErrorMessage string `structs:"error_message" json:"errorMessage"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// Podcasting 2.0 — Tier 1
PodcastGUID string `structs:"podcast_guid" json:"podcastGuid,omitempty"`
// Podcasting 2.0 — Tier 2
Locked bool `structs:"locked" json:"locked,omitempty"`
LockedOwner string `structs:"locked_owner" json:"lockedOwner,omitempty"`
Medium string `structs:"medium" json:"medium,omitempty"`
FundingURL string `structs:"funding_url" json:"fundingUrl,omitempty"`
FundingText string `structs:"funding_text" json:"fundingText,omitempty"`
UpdateFrequency string `structs:"update_frequency" json:"updateFrequency,omitempty"`
UpdateRRule string `structs:"update_rrule" json:"updateRRule,omitempty"`
Complete bool `structs:"complete" json:"complete,omitempty"`
// Podcasting 2.0 — Tier 3
UsesPodping bool `structs:"uses_podping" json:"usesPodping,omitempty"`
Podroll PodcastPodrollItems `structs:"-" json:"podroll,omitempty"`
LiveItem *PodcastLiveItem `structs:"-" json:"liveItem,omitempty"`
// loaded separately
Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"`
Persons PodcastPersons `structs:"-" json:"persons,omitempty"`
}
type PodcastEpisode struct {
ID string `structs:"id" json:"id"`
ChannelID string `structs:"channel_id" json:"channelId"`
StreamID string `structs:"stream_id" json:"streamId"`
GUID string `structs:"guid" json:"guid"`
Title string `structs:"title" json:"title"`
Description string `structs:"description" json:"description"`
PublishDate time.Time `structs:"publish_date" json:"publishDate"`
Duration int `structs:"duration" json:"duration"`
Size int64 `structs:"size" json:"size"`
BitRate int `structs:"bit_rate" json:"bitRate"`
Suffix string `structs:"suffix" json:"suffix"`
ContentType string `structs:"content_type" json:"contentType"`
ID string `structs:"id" json:"id"`
ChannelID string `structs:"channel_id" json:"channelId"`
StreamID string `structs:"stream_id" json:"streamId"`
GUID string `structs:"guid" json:"guid"`
Title string `structs:"title" json:"title"`
Description string `structs:"description" json:"description"`
PublishDate time.Time `structs:"publish_date" json:"publishDate"`
Duration int `structs:"duration" json:"duration"`
Size int64 `structs:"size" json:"size"`
BitRate int `structs:"bit_rate" json:"bitRate"`
Suffix string `structs:"suffix" json:"suffix"`
ContentType string `structs:"content_type" json:"contentType"`
Path string `structs:"path" json:"path"`
EnclosureURL string `structs:"enclosure_url" json:"enclosureUrl"`
DownloadedBytes int64 `structs:"downloaded_bytes" json:"downloadedBytes"`
Status PodcastStatus `structs:"status" json:"status"`
ErrorMessage string `structs:"error_message" json:"errorMessage"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
Status PodcastStatus `structs:"status" json:"status"`
ErrorMessage string `structs:"error_message" json:"errorMessage"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
// Podcasting 2.0 — Tier 1
Season int `structs:"season" json:"season,omitempty"`
SeasonName string `structs:"season_name" json:"seasonName,omitempty"`
EpisodeNumber string `structs:"episode_number" json:"episodeNumber,omitempty"`
EpisodeDisplay string `structs:"episode_display" json:"episodeDisplay,omitempty"`
ChaptersURL string `structs:"chapters_url" json:"chaptersUrl,omitempty"`
ChaptersType string `structs:"chapters_type" json:"chaptersType,omitempty"`
// Podcasting 2.0 — Tier 2
SoundbiteStart float64 `structs:"soundbite_start" json:"soundbiteStart,omitempty"`
SoundbiteDur float64 `structs:"soundbite_dur" json:"soundbiteDur,omitempty"`
SoundbiteTitle string `structs:"soundbite_title" json:"soundbiteTitle,omitempty"`
// loaded separately
Transcripts PodcastTranscripts `structs:"-" json:"transcripts,omitempty"`
Persons PodcastPersons `structs:"-" json:"persons,omitempty"`
}
type PodcastChannels []PodcastChannel
type PodcastEpisodes []PodcastEpisode
type PodcastTranscript struct {
ID string `structs:"id" json:"id"`
EpisodeID string `structs:"episode_id" json:"episodeId"`
URL string `structs:"url" json:"url"`
MimeType string `structs:"mime_type" json:"type"`
Language string `structs:"language" json:"language,omitempty"`
Rel string `structs:"rel" json:"rel,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
}
type PodcastPerson struct {
ID string `structs:"id" json:"id"`
ChannelID string `structs:"channel_id" json:"channelId,omitempty"`
EpisodeID string `structs:"episode_id" json:"episodeId,omitempty"`
Name string `structs:"name" json:"name"`
Role string `structs:"role" json:"role,omitempty"`
Group string `structs:"group_name" db:"group_name" json:"group,omitempty"`
Img string `structs:"img" json:"img,omitempty"`
Href string `structs:"href" json:"href,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
}
type PodcastChannels []PodcastChannel
type PodcastEpisodes []PodcastEpisode
type PodcastTranscripts []PodcastTranscript
type PodcastPersons []PodcastPerson
type PodcastChannelRepository interface {
Get(id string) (*PodcastChannel, error)
@ -70,3 +177,18 @@ type PodcastEpisodeRepository interface {
Update(ep *PodcastEpisode) error
Delete(id string) error
}
type PodcastTranscriptRepository interface {
GetByEpisode(episodeID string) (PodcastTranscripts, error)
GetByEpisodes(episodeIDs []string) (PodcastTranscripts, error)
Save(transcripts []PodcastTranscript) error
DeleteByEpisode(episodeID string) error
}
type PodcastPersonRepository interface {
GetByChannel(channelID string) (PodcastPersons, error)
GetByEpisode(episodeID string) (PodcastPersons, error)
GetByEpisodes(episodeIDs []string) (PodcastPersons, error)
SaveForChannel(channelID string, persons []PodcastPerson) error
SaveForEpisode(episodeID string, persons []PodcastPerson) error
}

View file

@ -113,6 +113,22 @@ func (s *SQLStore) PodcastEpisode(ctx context.Context) model.PodcastEpisodeRepos
return NewPodcastEpisodeRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) PodcastTranscript(ctx context.Context) model.PodcastTranscriptRepository {
return NewPodcastTranscriptRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) PodcastPerson(ctx context.Context) model.PodcastPersonRepository {
return NewPodcastPersonRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) PodcastPodroll(ctx context.Context) model.PodcastPodrollRepository {
return NewPodcastPodrollRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveItemRepository {
return NewPodcastLiveItemRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository {
switch m.(type) {
case model.User:

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 podcastLiveItemRepository struct {
sqlRepository
}
func NewPodcastLiveItemRepository(ctx context.Context, db dbx.Builder) model.PodcastLiveItemRepository {
r := &podcastLiveItemRepository{}
r.ctx = ctx
r.db = db
// Must set tableName before registerModel to avoid auto-derived name mismatch.
r.tableName = "podcast_live_item"
r.registerModel(&model.PodcastLiveItem{}, nil)
return r
}
func (r *podcastLiveItemRepository) GetByChannel(channelID string) (*model.PodcastLiveItem, error) {
sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID})
item := model.PodcastLiveItem{}
if err := r.queryOne(sel, &item); err != nil {
return nil, err
}
return &item, nil
}
func (r *podcastLiveItemRepository) Upsert(item *model.PodcastLiveItem) error {
existing, err := r.GetByChannel(item.ChannelID)
if err == model.ErrNotFound {
item.ID = id.NewRandom()
item.CreatedAt = time.Now()
item.UpdatedAt = time.Now()
_, err = r.put(item.ID, item)
return err
}
if err != nil {
return err
}
item.ID = existing.ID
item.CreatedAt = existing.CreatedAt
item.UpdatedAt = time.Now()
_, err = r.put(item.ID, item)
return err
}
func (r *podcastLiveItemRepository) DeleteByChannel(channelID string) error {
return r.delete(Eq{"channel_id": channelID})
}
var _ model.PodcastLiveItemRepository = (*podcastLiveItemRepository)(nil)

View file

@ -0,0 +1,150 @@
package persistence
import (
"context"
"time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PodcastLiveItemRepository", func() {
var repo model.PodcastLiveItemRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastLiveItemRepository(ctx, GetDBXBuilder())
})
Describe("Upsert and GetByChannel", func() {
It("creates a new live item when none exists", func() {
item := &model.PodcastLiveItem{
ChannelID: "pc-1",
GUID: "live-guid-001",
Title: "Live Show",
Status: "live",
StartTime: time.Date(2024, 4, 27, 8, 0, 0, 0, time.UTC),
EndTime: time.Date(2024, 4, 27, 9, 0, 0, 0, time.UTC),
EnclosureURL: "https://stream.example.com/live.m3u8",
EnclosureType: "application/x-mpegURL",
ContentLinkURL: "https://youtube.com/live",
ContentLinkText: "Watch Live",
}
Expect(repo.Upsert(item)).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
Expect(result.ChannelID).To(Equal("pc-1"))
Expect(result.GUID).To(Equal("live-guid-001"))
Expect(result.Title).To(Equal("Live Show"))
Expect(result.Status).To(Equal("live"))
Expect(result.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8"))
Expect(result.ContentLinkURL).To(Equal("https://youtube.com/live"))
Expect(result.ContentLinkText).To(Equal("Watch Live"))
Expect(repo.DeleteByChannel("pc-1")).To(Succeed())
})
It("assigns ID and timestamps automatically on create", func() {
item := &model.PodcastLiveItem{
ChannelID: "pc-1",
Status: "live",
}
Expect(repo.Upsert(item)).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result.ID).ToNot(BeEmpty())
Expect(result.CreatedAt.IsZero()).To(BeFalse())
Expect(result.UpdatedAt.IsZero()).To(BeFalse())
Expect(repo.DeleteByChannel("pc-1")).To(Succeed())
})
It("updates existing live item (latest wins)", func() {
first := &model.PodcastLiveItem{
ChannelID: "pc-1",
GUID: "live-guid-001",
Title: "Original Title",
Status: "pending",
}
Expect(repo.Upsert(first)).To(Succeed())
second := &model.PodcastLiveItem{
ChannelID: "pc-1",
GUID: "live-guid-001",
Title: "Updated Title",
Status: "live",
}
Expect(repo.Upsert(second)).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result.Title).To(Equal("Updated Title"))
Expect(result.Status).To(Equal("live"))
Expect(repo.DeleteByChannel("pc-1")).To(Succeed())
})
It("preserves created_at on update", func() {
item := &model.PodcastLiveItem{
ChannelID: "pc-1",
Status: "pending",
}
Expect(repo.Upsert(item)).To(Succeed())
original, _ := repo.GetByChannel("pc-1")
originalCreatedAt := original.CreatedAt
item2 := &model.PodcastLiveItem{
ChannelID: "pc-1",
Status: "live",
}
Expect(repo.Upsert(item2)).To(Succeed())
updated, _ := repo.GetByChannel("pc-1")
Expect(updated.CreatedAt.UTC().Truncate(time.Second)).
To(Equal(originalCreatedAt.UTC().Truncate(time.Second)))
Expect(repo.DeleteByChannel("pc-1")).To(Succeed())
})
It("returns ErrNotFound for unknown channel", func() {
_, err := repo.GetByChannel("no-such-channel")
Expect(err).To(Equal(model.ErrNotFound))
})
It("handles zero-value start/end times", func() {
item := &model.PodcastLiveItem{
ChannelID: "pc-1",
Status: "live",
}
Expect(repo.Upsert(item)).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
Expect(repo.DeleteByChannel("pc-1")).To(Succeed())
})
})
Describe("DeleteByChannel", func() {
It("removes live item for the given channel", func() {
Expect(repo.Upsert(&model.PodcastLiveItem{ChannelID: "pc-1", Status: "live"})).To(Succeed())
Expect(repo.DeleteByChannel("pc-1")).To(Succeed())
_, err := repo.GetByChannel("pc-1")
Expect(err).To(Equal(model.ErrNotFound))
})
It("does not error when no item exists", func() {
Expect(repo.DeleteByChannel("no-such-channel")).To(Succeed())
})
})
})

View file

@ -0,0 +1,83 @@
package persistence
import (
"context"
"time"
. "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/pocketbase/dbx"
)
type podcastPersonRepository struct {
sqlRepository
}
func NewPodcastPersonRepository(ctx context.Context, db dbx.Builder) model.PodcastPersonRepository {
r := &podcastPersonRepository{}
r.ctx = ctx
r.db = db
r.registerModel(&model.PodcastPerson{}, nil)
return r
}
func (r *podcastPersonRepository) GetByChannel(channelID string) (model.PodcastPersons, error) {
sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID})
var result model.PodcastPersons
err := r.queryAll(sel, &result)
return result, err
}
func (r *podcastPersonRepository) GetByEpisode(episodeID string) (model.PodcastPersons, error) {
sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeID})
var result model.PodcastPersons
err := r.queryAll(sel, &result)
return result, err
}
func (r *podcastPersonRepository) GetByEpisodes(episodeIDs []string) (model.PodcastPersons, error) {
if len(episodeIDs) == 0 {
return nil, nil
}
sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeIDs})
var result model.PodcastPersons
err := r.queryAll(sel, &result)
return result, err
}
func (r *podcastPersonRepository) SaveForChannel(channelID string, persons []model.PodcastPerson) error {
if err := r.delete(Eq{"channel_id": channelID}); err != nil {
return err
}
now := time.Now()
for i := range persons {
persons[i].ID = id.NewRandom()
persons[i].ChannelID = channelID
persons[i].EpisodeID = ""
persons[i].CreatedAt = now
if _, err := r.put(persons[i].ID, &persons[i]); err != nil {
return err
}
}
return nil
}
func (r *podcastPersonRepository) SaveForEpisode(episodeID string, persons []model.PodcastPerson) error {
if err := r.delete(Eq{"episode_id": episodeID}); err != nil {
return err
}
now := time.Now()
for i := range persons {
persons[i].ID = id.NewRandom()
persons[i].EpisodeID = episodeID
persons[i].ChannelID = ""
persons[i].CreatedAt = now
if _, err := r.put(persons[i].ID, &persons[i]); err != nil {
return err
}
}
return nil
}
var _ model.PodcastPersonRepository = (*podcastPersonRepository)(nil)

View file

@ -0,0 +1,172 @@
package persistence
import (
"context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PodcastPersonRepository", func() {
var repo model.PodcastPersonRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastPersonRepository(ctx, GetDBXBuilder())
})
Describe("SaveForChannel and GetByChannel", func() {
It("saves and retrieves channel-level persons", func() {
persons := []model.PodcastPerson{
{Name: "Jane Host", Role: "host", Group: "cast", Img: "https://example.com/jane.jpg", Href: "https://example.com/jane"},
{Name: "Bob Producer", Role: "producer", Group: "crew"},
}
Expect(repo.SaveForChannel("pc-1", persons)).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
var names []string
for _, p := range result {
names = append(names, p.Name)
}
Expect(names).To(ConsistOf("Jane Host", "Bob Producer"))
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
})
It("persists all person fields", func() {
persons := []model.PodcastPerson{
{Name: "Jane Host", Role: "host", Group: "cast", Img: "https://example.com/jane.jpg", Href: "https://example.com/jane"},
}
Expect(repo.SaveForChannel("pc-1", persons)).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result[0].Name).To(Equal("Jane Host"))
Expect(result[0].Role).To(Equal("host"))
Expect(result[0].Group).To(Equal("cast"))
Expect(result[0].Img).To(Equal("https://example.com/jane.jpg"))
Expect(result[0].Href).To(Equal("https://example.com/jane"))
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
})
It("replaces existing persons on re-save", func() {
Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Old Host", Role: "host", Group: "cast"}})).To(Succeed())
Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "New Host", Role: "host", Group: "cast"}})).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Name).To(Equal("New Host"))
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
})
It("clears persons when saved with nil slice", func() {
Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Jane Host", Role: "host", Group: "cast"}})).To(Succeed())
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
It("does not affect persons of other channels", func() {
Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Host A", Role: "host", Group: "cast"}})).To(Succeed())
Expect(repo.SaveForChannel("pc-2", []model.PodcastPerson{{Name: "Host B", Role: "host", Group: "cast"}})).To(Succeed())
resultA, _ := repo.GetByChannel("pc-1")
resultB, _ := repo.GetByChannel("pc-2")
Expect(resultA).To(HaveLen(1))
Expect(resultB).To(HaveLen(1))
Expect(resultA[0].Name).To(Equal("Host A"))
Expect(resultB[0].Name).To(Equal("Host B"))
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed())
})
It("returns empty list for unknown channel", func() {
result, err := repo.GetByChannel("no-such-channel")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Describe("SaveForEpisode and GetByEpisode", func() {
It("saves and retrieves episode-level persons", func() {
persons := []model.PodcastPerson{
{Name: "John Guest", Role: "guest", Group: "cast"},
}
Expect(repo.SaveForEpisode("pe-1", persons)).To(Succeed())
result, err := repo.GetByEpisode("pe-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Name).To(Equal("John Guest"))
Expect(result[0].Role).To(Equal("guest"))
Expect(repo.SaveForEpisode("pe-1", nil)).To(Succeed())
})
It("replaces existing episode persons on re-save", func() {
Expect(repo.SaveForEpisode("pe-1", []model.PodcastPerson{{Name: "Old Guest", Role: "guest", Group: "cast"}})).To(Succeed())
Expect(repo.SaveForEpisode("pe-1", []model.PodcastPerson{{Name: "New Guest", Role: "guest", Group: "cast"}})).To(Succeed())
result, err := repo.GetByEpisode("pe-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Name).To(Equal("New Guest"))
Expect(repo.SaveForEpisode("pe-1", nil)).To(Succeed())
})
It("returns empty list for unknown episode", func() {
result, err := repo.GetByEpisode("no-such-episode")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Describe("GetByEpisodes — bulk query", func() {
It("returns persons for multiple episodes in one query", func() {
Expect(repo.SaveForEpisode("pe-1", []model.PodcastPerson{{Name: "Guest A", Role: "guest", Group: "cast"}})).To(Succeed())
Expect(repo.SaveForEpisode("pe-2", []model.PodcastPerson{{Name: "Guest B", Role: "guest", Group: "cast"}})).To(Succeed())
result, err := repo.GetByEpisodes([]string{"pe-1", "pe-2"})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
names := []string{result[0].Name, result[1].Name}
Expect(names).To(ConsistOf("Guest A", "Guest B"))
Expect(repo.SaveForEpisode("pe-1", nil)).To(Succeed())
Expect(repo.SaveForEpisode("pe-2", nil)).To(Succeed())
})
It("returns empty list for empty id slice", func() {
result, err := repo.GetByEpisodes([]string{})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Describe("auto ID generation", func() {
It("assigns an ID automatically on SaveForChannel", func() {
Expect(repo.SaveForChannel("pc-1", []model.PodcastPerson{{Name: "Jane", Role: "host", Group: "cast"}})).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result[0].ID).ToNot(BeEmpty())
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
})
})
})

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 podcastPodrollRepository struct {
sqlRepository
}
func NewPodcastPodrollRepository(ctx context.Context, db dbx.Builder) model.PodcastPodrollRepository {
r := &podcastPodrollRepository{}
r.ctx = ctx
r.db = db
// Must set tableName before registerModel to avoid auto-derived name mismatch.
r.tableName = "podcast_podroll"
r.registerModel(&model.PodcastPodrollItem{}, nil)
return r
}
func (r *podcastPodrollRepository) GetByChannel(channelID string) (model.PodcastPodrollItems, error) {
sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelID}).OrderBy("sort_order")
var result model.PodcastPodrollItems
return result, r.queryAll(sel, &result)
}
func (r *podcastPodrollRepository) GetByChannels(channelIDs []string) (model.PodcastPodrollItems, error) {
if len(channelIDs) == 0 {
return nil, nil
}
sel := r.newSelect().Columns("*").Where(Eq{"channel_id": channelIDs}).OrderBy("channel_id, sort_order")
var result model.PodcastPodrollItems
return result, r.queryAll(sel, &result)
}
func (r *podcastPodrollRepository) SaveForChannel(channelID string, items []model.PodcastPodrollItem) error {
if err := r.delete(Eq{"channel_id": channelID}); err != nil {
return err
}
now := time.Now()
for i := range items {
items[i].ID = id.NewRandom()
items[i].ChannelID = channelID
items[i].SortOrder = i
items[i].CreatedAt = now
if _, err := r.put(items[i].ID, &items[i]); err != nil {
return err
}
}
return nil
}
var _ model.PodcastPodrollRepository = (*podcastPodrollRepository)(nil)

View file

@ -0,0 +1,145 @@
package persistence
import (
"context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PodcastPodrollRepository", func() {
var repo model.PodcastPodrollRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastPodrollRepository(ctx, GetDBXBuilder())
})
Describe("SaveForChannel and GetByChannel", func() {
It("saves and retrieves podroll items", func() {
items := []model.PodcastPodrollItem{
{FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"},
{FeedGUID: "guid-b", FeedURL: "https://b.example.com/feed.xml", Title: "Show B"},
}
Expect(repo.SaveForChannel("pc-1", items)).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
})
It("preserves sort_order in insertion order", func() {
items := []model.PodcastPodrollItem{
{FeedURL: "https://first.example.com/feed.xml", Title: "First"},
{FeedURL: "https://second.example.com/feed.xml", Title: "Second"},
{FeedURL: "https://third.example.com/feed.xml", Title: "Third"},
}
Expect(repo.SaveForChannel("pc-1", items)).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(3))
Expect(result[0].Title).To(Equal("First"))
Expect(result[1].Title).To(Equal("Second"))
Expect(result[2].Title).To(Equal("Third"))
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
})
It("replaces existing items on re-save", func() {
Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://old.example.com/feed.xml", Title: "Old Show"},
})).To(Succeed())
Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://new.example.com/feed.xml", Title: "New Show"},
})).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Title).To(Equal("New Show"))
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
})
It("clears items when saved with nil slice", func() {
Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://example.com/feed.xml", Title: "Some Show"},
})).To(Succeed())
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
It("does not affect items of other channels", func() {
Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://a.example.com/feed.xml", Title: "Channel A Feed"},
})).To(Succeed())
Expect(repo.SaveForChannel("pc-2", []model.PodcastPodrollItem{
{FeedURL: "https://b.example.com/feed.xml", Title: "Channel B Feed"},
})).To(Succeed())
resultA, _ := repo.GetByChannel("pc-1")
resultB, _ := repo.GetByChannel("pc-2")
Expect(resultA).To(HaveLen(1))
Expect(resultB).To(HaveLen(1))
Expect(resultA[0].Title).To(Equal("Channel A Feed"))
Expect(resultB[0].Title).To(Equal("Channel B Feed"))
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed())
})
It("returns empty list for unknown channel", func() {
result, err := repo.GetByChannel("no-such-channel")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
It("assigns ID automatically", func() {
Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://example.com/feed.xml"},
})).To(Succeed())
result, err := repo.GetByChannel("pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result[0].ID).ToNot(BeEmpty())
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
})
})
Describe("GetByChannels — bulk query", func() {
It("returns items for multiple channels", func() {
Expect(repo.SaveForChannel("pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://a.example.com/feed.xml", Title: "Feed A"},
})).To(Succeed())
Expect(repo.SaveForChannel("pc-2", []model.PodcastPodrollItem{
{FeedURL: "https://b.example.com/feed.xml", Title: "Feed B"},
})).To(Succeed())
result, err := repo.GetByChannels([]string{"pc-1", "pc-2"})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
titles := []string{result[0].Title, result[1].Title}
Expect(titles).To(ConsistOf("Feed A", "Feed B"))
Expect(repo.SaveForChannel("pc-1", nil)).To(Succeed())
Expect(repo.SaveForChannel("pc-2", nil)).To(Succeed())
})
It("returns nil for empty id slice", func() {
result, err := repo.GetByChannels([]string{})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeNil())
})
})
})

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 podcastTranscriptRepository struct {
sqlRepository
}
func NewPodcastTranscriptRepository(ctx context.Context, db dbx.Builder) model.PodcastTranscriptRepository {
r := &podcastTranscriptRepository{}
r.ctx = ctx
r.db = db
r.registerModel(&model.PodcastTranscript{}, nil)
return r
}
func (r *podcastTranscriptRepository) GetByEpisode(episodeID string) (model.PodcastTranscripts, error) {
sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeID})
var result model.PodcastTranscripts
err := r.queryAll(sel, &result)
return result, err
}
func (r *podcastTranscriptRepository) GetByEpisodes(episodeIDs []string) (model.PodcastTranscripts, error) {
if len(episodeIDs) == 0 {
return nil, nil
}
sel := r.newSelect().Columns("*").Where(Eq{"episode_id": episodeIDs})
var result model.PodcastTranscripts
err := r.queryAll(sel, &result)
return result, err
}
func (r *podcastTranscriptRepository) Save(transcripts []model.PodcastTranscript) error {
for i := range transcripts {
if transcripts[i].ID == "" {
transcripts[i].ID = id.NewRandom()
}
transcripts[i].CreatedAt = time.Now()
if _, err := r.put(transcripts[i].ID, &transcripts[i]); err != nil {
return err
}
}
return nil
}
func (r *podcastTranscriptRepository) DeleteByEpisode(episodeID string) error {
return r.delete(Eq{"episode_id": episodeID})
}
var _ model.PodcastTranscriptRepository = (*podcastTranscriptRepository)(nil)

View file

@ -0,0 +1,144 @@
package persistence
import (
"context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PodcastTranscriptRepository", func() {
var repo model.PodcastTranscriptRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastTranscriptRepository(ctx, GetDBXBuilder())
})
Describe("Save and GetByEpisode", func() {
It("returns saved transcript by episode id", func() {
transcripts := []model.PodcastTranscript{
{EpisodeID: "pe-1", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"},
}
Expect(repo.Save(transcripts)).To(Succeed())
result, err := repo.GetByEpisode("pe-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].URL).To(Equal("https://example.com/t.vtt"))
Expect(result[0].MimeType).To(Equal("text/vtt"))
Expect(result[0].Language).To(Equal("en"))
Expect(result[0].Rel).To(Equal("captions"))
Expect(repo.DeleteByEpisode("pe-1")).To(Succeed())
})
It("saves multiple transcripts for one episode", func() {
transcripts := []model.PodcastTranscript{
{EpisodeID: "pe-1", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"},
{EpisodeID: "pe-1", URL: "https://example.com/t.srt", MimeType: "application/x-subrip", Language: "en"},
}
Expect(repo.Save(transcripts)).To(Succeed())
result, err := repo.GetByEpisode("pe-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
var mimeTypes []string
for _, t := range result {
mimeTypes = append(mimeTypes, t.MimeType)
}
Expect(mimeTypes).To(ConsistOf("text/vtt", "application/x-subrip"))
Expect(repo.DeleteByEpisode("pe-1")).To(Succeed())
})
It("stores empty rel when rel attribute is omitted", func() {
transcripts := []model.PodcastTranscript{
{EpisodeID: "pe-1", URL: "https://example.com/t.txt", MimeType: "text/plain"},
}
Expect(repo.Save(transcripts)).To(Succeed())
result, err := repo.GetByEpisode("pe-1")
Expect(err).ToNot(HaveOccurred())
Expect(result[0].Rel).To(BeEmpty())
Expect(repo.DeleteByEpisode("pe-1")).To(Succeed())
})
It("returns empty list for unknown episode", func() {
result, err := repo.GetByEpisode("no-such-episode")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Describe("GetByEpisodes — bulk query", func() {
It("returns transcripts for multiple episodes in one query", func() {
transcripts := []model.PodcastTranscript{
{EpisodeID: "pe-1", URL: "https://example.com/t1.vtt", MimeType: "text/vtt"},
{EpisodeID: "pe-2", URL: "https://example.com/t2.srt", MimeType: "application/x-subrip"},
}
Expect(repo.Save(transcripts)).To(Succeed())
result, err := repo.GetByEpisodes([]string{"pe-1", "pe-2"})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
episodeIDs := []string{result[0].EpisodeID, result[1].EpisodeID}
Expect(episodeIDs).To(ConsistOf("pe-1", "pe-2"))
Expect(repo.DeleteByEpisode("pe-1")).To(Succeed())
Expect(repo.DeleteByEpisode("pe-2")).To(Succeed())
})
It("returns empty list for empty id slice", func() {
result, err := repo.GetByEpisodes([]string{})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Describe("DeleteByEpisode", func() {
It("deletes only transcripts for the specified episode", func() {
transcripts := []model.PodcastTranscript{
{EpisodeID: "pe-1", URL: "https://example.com/t1.vtt", MimeType: "text/vtt"},
{EpisodeID: "pe-2", URL: "https://example.com/t2.vtt", MimeType: "text/vtt"},
}
Expect(repo.Save(transcripts)).To(Succeed())
Expect(repo.DeleteByEpisode("pe-1")).To(Succeed())
result1, _ := repo.GetByEpisode("pe-1")
Expect(result1).To(BeEmpty())
result2, _ := repo.GetByEpisode("pe-2")
Expect(result2).To(HaveLen(1))
Expect(repo.DeleteByEpisode("pe-2")).To(Succeed())
})
It("succeeds when deleting transcripts for a non-existent episode", func() {
Expect(repo.DeleteByEpisode("no-such-episode")).To(Succeed())
})
})
Describe("Save — auto ID generation", func() {
It("assigns an ID automatically when none is provided", func() {
transcripts := []model.PodcastTranscript{
{EpisodeID: "pe-1", URL: "https://example.com/auto.vtt", MimeType: "text/vtt"},
}
Expect(repo.Save(transcripts)).To(Succeed())
result, err := repo.GetByEpisode("pe-1")
Expect(err).ToNot(HaveOccurred())
Expect(result[0].ID).ToNot(BeEmpty())
Expect(repo.DeleteByEpisode("pe-1")).To(Succeed())
})
})
})

View file

@ -17,6 +17,15 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "playbackReport", Versions: []int32{1}},
{Name: "topSongsByArtistId", Versions: []int32{1}},
{Name: "getPodcastEpisode", Versions: []int32{1}},
{Name: "podcastChapters", Versions: []int32{1}},
{Name: "podcastTranscripts", Versions: []int32{1}},
{Name: "podcastSeason", Versions: []int32{1}},
{Name: "podcastPerson", Versions: []int32{1}},
{Name: "podcastFunding", Versions: []int32{1}},
{Name: "podcastMedium", Versions: []int32{1}},
{Name: "podcastPodroll", Versions: []int32{1}},
{Name: "podcastLiveItem", Versions: []int32{1}},
{Name: "podcastPodping", Versions: []int32{1}},
}
if api.sonic != nil && api.sonic.HasProvider() {
extensions = append(extensions, responses.OpenSubsonicExtension{

View file

@ -44,7 +44,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("should return the base 8 OpenSubsonicExtensions without sonicSimilarity", func() {
It("should return the base 15 OpenSubsonicExtensions without sonicSimilarity", func() {
router.ServeHTTP(w, r)
// Make sure the endpoint is public, by not passing any authentication
@ -55,7 +55,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(8),
HaveLen(15),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
@ -64,6 +64,15 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastChapters", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastTranscripts", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastSeason", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPerson", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastFunding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastMedium", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodroll", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastLiveItem", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodping", Versions: []int32{1}}),
))
Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
@ -77,7 +86,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
})
It("should return 9 extensions including sonicSimilarity and getPodcastEpisode", func() {
It("should return 16 extensions including all podcast 2.0 and sonicSimilarity", func() {
router.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
@ -87,7 +96,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(9),
HaveLen(16),
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
@ -96,6 +105,15 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastChapters", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastTranscripts", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastSeason", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPerson", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastFunding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastMedium", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodroll", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastLiveItem", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodping", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
))
})

View file

@ -42,6 +42,68 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) {
channels[0].Episodes = eps
}
// Collect channel IDs for bulk queries.
channelIDs := make([]string, len(channels))
for i, ch := range channels {
channelIDs[i] = ch.ID
}
// Load channel persons
personRepo := api.ds.PodcastPerson(ctx)
channelPersons := make(map[string]model.PodcastPersons)
for _, ch := range channels {
persons, err := personRepo.GetByChannel(ch.ID)
if err == nil {
channelPersons[ch.ID] = persons
}
}
// Bulk-load podcast:podroll items
podrollRepo := api.ds.PodcastPodroll(ctx)
allPodrolls, _ := podrollRepo.GetByChannels(channelIDs)
podrollMap := make(map[string]model.PodcastPodrollItems)
for _, pr := range allPodrolls {
podrollMap[pr.ChannelID] = append(podrollMap[pr.ChannelID], pr)
}
// Load podcast:liveItem per channel
liveItemRepo := api.ds.PodcastLiveItem(ctx)
liveItemMap := make(map[string]*model.PodcastLiveItem)
for _, chID := range channelIDs {
if li, err := liveItemRepo.GetByChannel(chID); err == nil {
liveItemMap[chID] = li
}
}
// Bulk-load episode transcripts and persons when including episodes
var epTranscripts map[string]model.PodcastTranscripts
var epPersons map[string]model.PodcastPersons
if includeEpisodes {
var epIDs []string
for _, ch := range channels {
for _, ep := range ch.Episodes {
epIDs = append(epIDs, ep.ID)
}
}
if len(epIDs) > 0 {
transcriptRepo := api.ds.PodcastTranscript(ctx)
allTranscripts, err := transcriptRepo.GetByEpisodes(epIDs)
if err == nil {
epTranscripts = make(map[string]model.PodcastTranscripts)
for _, t := range allTranscripts {
epTranscripts[t.EpisodeID] = append(epTranscripts[t.EpisodeID], t)
}
}
allPersons, err := personRepo.GetByEpisodes(epIDs)
if err == nil {
epPersons = make(map[string]model.PodcastPersons)
for _, p := range allPersons {
epPersons[p.EpisodeID] = append(epPersons[p.EpisodeID], p)
}
}
}
}
resp := newResponse()
resp.Podcasts = &responses.Podcasts{}
for _, ch := range channels {
@ -53,9 +115,55 @@ func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) {
OriginalImageUrl: ch.ImageURL,
Status: string(ch.Status),
ErrorMessage: ch.ErrorMessage,
// Podcasting 2.0 Tier 1 & 2
PodcastGuid: ch.PodcastGUID,
Locked: ch.Locked,
Medium: ch.Medium,
FundingUrl: ch.FundingURL,
FundingText: ch.FundingText,
UpdateFrequency: ch.UpdateFrequency,
Complete: ch.Complete,
// Podcasting 2.0 Tier 3
UsesPodping: ch.UsesPodping,
}
for _, p := range channelPersons[ch.ID] {
rch.Person = append(rch.Person, responses.PodcastPersonResp{
Name: p.Name,
Role: p.Role,
Group: p.Group,
Img: p.Img,
Href: p.Href,
})
}
for _, pr := range podrollMap[ch.ID] {
rch.Podroll = append(rch.Podroll, responses.PodcastPodrollResp{
FeedGUID: pr.FeedGUID,
FeedURL: pr.FeedURL,
Title: pr.Title,
})
}
if li := liveItemMap[ch.ID]; li != nil {
liveResp := &responses.PodcastLiveItemResp{
Status: li.Status,
Title: li.Title,
GUID: li.GUID,
EnclosureURL: li.EnclosureURL,
EnclosureType: li.EnclosureType,
ContentLinkURL: li.ContentLinkURL,
ContentLinkText: li.ContentLinkText,
}
if !li.StartTime.IsZero() {
liveResp.StartTime = li.StartTime.UTC().Format(time.RFC3339)
}
if !li.EndTime.IsZero() {
liveResp.EndTime = li.EndTime.UTC().Format(time.RFC3339)
}
rch.LiveItem = liveResp
}
if includeEpisodes {
for _, ep := range ch.Episodes {
ep.Transcripts = epTranscripts[ep.ID]
ep.Persons = epPersons[ep.ID]
rch.Episode = append(rch.Episode, buildPodcastEpisode(ep))
}
}
@ -175,10 +283,15 @@ func (api *Router) GetPodcastEpisode(r *http.Request) (*responses.Subsonic, erro
if err != nil {
return nil, err
}
ep, err := api.ds.PodcastEpisode(r.Context()).Get(id)
ctx := r.Context()
ep, err := api.ds.PodcastEpisode(ctx).Get(id)
if err != nil {
return nil, err
}
ep.Transcripts, _ = api.ds.PodcastTranscript(ctx).GetByEpisode(ep.ID)
ep.Persons, _ = api.ds.PodcastPerson(ctx).GetByEpisode(ep.ID)
resp := newResponse()
re := buildPodcastEpisode(*ep)
resp.PodcastEpisode = &re
@ -200,10 +313,35 @@ func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode {
ContentType: ep.ContentType,
BitRate: ep.BitRate,
DownloadedBytes: ep.DownloadedBytes,
// Podcasting 2.0
Season: ep.Season,
SeasonName: ep.SeasonName,
EpisodeNumber: ep.EpisodeNumber,
EpisodeDisplay: ep.EpisodeDisplay,
ChaptersUrl: ep.ChaptersURL,
SoundbiteStart: ep.SoundbiteStart,
SoundbiteDur: ep.SoundbiteDur,
}
if !ep.PublishDate.IsZero() {
re.PublishDate = ep.PublishDate.UTC().Format(time.RFC3339)
}
for _, t := range ep.Transcripts {
re.Transcript = append(re.Transcript, responses.PodcastTranscriptResp{
URL: t.URL,
Type: t.MimeType,
Language: t.Language,
Rel: t.Rel,
})
}
for _, p := range ep.Persons {
re.Person = append(re.Person, responses.PodcastPersonResp{
Name: p.Name,
Role: p.Role,
Group: p.Group,
Img: p.Img,
Href: p.Href,
})
}
return re
}

View file

@ -299,4 +299,290 @@ var _ = Describe("Podcasts", func() {
Expect(err).ToNot(HaveOccurred())
})
})
Describe("GetPodcasts — Podcasting 2.0 channel fields", func() {
BeforeEach(func() {
channelRepo.Data = map[string]*model.PodcastChannel{
"ch-p20": {
ID: "ch-p20",
Title: "P2.0 Podcast",
Status: model.PodcastStatusCompleted,
PodcastGUID: "917393e3-1b1e-5cef-ace4-edaa54e1f810",
Locked: true,
Medium: "podcast",
FundingURL: "https://example.com/donate",
FundingText: "Support us!",
UpdateFrequency: "Weekly",
UpdateRRule: "FREQ=WEEKLY",
Complete: false,
},
}
})
It("includes podcastGuid in response", func() {
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Podcasts.Channel[0].PodcastGuid).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810"))
})
It("includes locked flag in response", func() {
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Podcasts.Channel[0].Locked).To(BeTrue())
})
It("includes medium in response", func() {
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Podcasts.Channel[0].Medium).To(Equal("podcast"))
})
It("includes fundingUrl and fundingText in response", func() {
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
ch := resp.Podcasts.Channel[0]
Expect(ch.FundingUrl).To(Equal("https://example.com/donate"))
Expect(ch.FundingText).To(Equal("Support us!"))
})
It("includes updateFrequency in response", func() {
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Podcasts.Channel[0].UpdateFrequency).To(Equal("Weekly"))
})
It("includes channel person list from PersonRepo", func() {
personRepo := tests.CreateMockPodcastPersonRepo()
_ = personRepo.SaveForChannel("ch-p20", []model.PodcastPerson{
{Name: "Jane Host", Role: "host", Group: "cast"},
})
ds.MockedPodcastPerson = personRepo
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Podcasts.Channel[0].Person).To(HaveLen(1))
Expect(resp.Podcasts.Channel[0].Person[0].Name).To(Equal("Jane Host"))
Expect(resp.Podcasts.Channel[0].Person[0].Role).To(Equal("host"))
})
})
Describe("GetPodcastEpisode — Podcasting 2.0 episode fields", func() {
BeforeEach(func() {
episodeRepo.Data["ep-p20"] = &model.PodcastEpisode{
ID: "ep-p20",
Title: "P2.0 Episode",
Status: model.PodcastStatusCompleted,
Season: 2,
SeasonName: "Season Two",
EpisodeNumber: "5",
EpisodeDisplay: "Ep.5",
ChaptersURL: "https://example.com/chapters.json",
ChaptersType: "application/json+chapters",
SoundbiteStart: 73.5,
SoundbiteDur: 60.0,
SoundbiteTitle: "Best moment",
}
})
It("includes season number and name in response", func() {
r := newGetRequest("id=ep-p20")
r = r.WithContext(userCtx)
resp, err := api.GetPodcastEpisode(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.PodcastEpisode.Season).To(Equal(2))
Expect(resp.PodcastEpisode.SeasonName).To(Equal("Season Two"))
})
It("includes episode number and display label in response", func() {
r := newGetRequest("id=ep-p20")
r = r.WithContext(userCtx)
resp, err := api.GetPodcastEpisode(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.PodcastEpisode.EpisodeNumber).To(Equal("5"))
Expect(resp.PodcastEpisode.EpisodeDisplay).To(Equal("Ep.5"))
})
It("includes chaptersUrl in response", func() {
r := newGetRequest("id=ep-p20")
r = r.WithContext(userCtx)
resp, err := api.GetPodcastEpisode(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.PodcastEpisode.ChaptersUrl).To(Equal("https://example.com/chapters.json"))
})
It("includes soundbite fields in response", func() {
r := newGetRequest("id=ep-p20")
r = r.WithContext(userCtx)
resp, err := api.GetPodcastEpisode(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.PodcastEpisode.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001))
Expect(resp.PodcastEpisode.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001))
})
It("includes transcript array from TranscriptRepo in response", func() {
transcriptRepo := tests.CreateMockPodcastTranscriptRepo()
_ = transcriptRepo.Save([]model.PodcastTranscript{
{EpisodeID: "ep-p20", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"},
{EpisodeID: "ep-p20", URL: "https://example.com/t.srt", MimeType: "application/x-subrip", Language: "en"},
})
ds.MockedPodcastTranscript = transcriptRepo
r := newGetRequest("id=ep-p20")
r = r.WithContext(userCtx)
resp, err := api.GetPodcastEpisode(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.PodcastEpisode.Transcript).To(HaveLen(2))
// map iteration order is non-deterministic; use ConsistOf for order-independent check
types := []string{
resp.PodcastEpisode.Transcript[0].Type,
resp.PodcastEpisode.Transcript[1].Type,
}
Expect(types).To(ConsistOf("text/vtt", "application/x-subrip"))
var vttRel string
for _, t := range resp.PodcastEpisode.Transcript {
if t.Type == "text/vtt" {
vttRel = t.Rel
}
}
Expect(vttRel).To(Equal("captions"))
})
It("includes person array from PersonRepo in response", func() {
personRepo := tests.CreateMockPodcastPersonRepo()
_ = personRepo.SaveForEpisode("ep-p20", []model.PodcastPerson{
{Name: "Jane Host", Role: "host", Group: "cast"},
})
ds.MockedPodcastPerson = personRepo
r := newGetRequest("id=ep-p20")
r = r.WithContext(userCtx)
resp, err := api.GetPodcastEpisode(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.PodcastEpisode.Person).To(HaveLen(1))
Expect(resp.PodcastEpisode.Person[0].Name).To(Equal("Jane Host"))
})
})
Describe("GetPodcasts — Tier 3 fields", func() {
BeforeEach(func() {
channelRepo.Data = map[string]*model.PodcastChannel{
"ch-t3": {
ID: "ch-t3",
Title: "Tier3 Podcast",
Status: model.PodcastStatusCompleted,
UsesPodping: true,
},
}
})
It("includes usesPodping in channel response", func() {
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Podcasts.Channel[0].UsesPodping).To(BeTrue())
})
It("includes podroll items in channel response", func() {
podrollRepo := tests.CreateMockPodcastPodrollRepo()
_ = podrollRepo.SaveForChannel("ch-t3", []model.PodcastPodrollItem{
{FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"},
{FeedGUID: "guid-b", FeedURL: "https://b.example.com/feed.xml"},
})
ds.MockedPodcastPodroll = podrollRepo
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
ch := resp.Podcasts.Channel[0]
Expect(ch.Podroll).To(HaveLen(2))
feedURLs := []string{ch.Podroll[0].FeedURL, ch.Podroll[1].FeedURL}
Expect(feedURLs).To(ConsistOf("https://a.example.com/feed.xml", "https://b.example.com/feed.xml"))
})
It("includes podroll title and feedGuid", func() {
podrollRepo := tests.CreateMockPodcastPodrollRepo()
_ = podrollRepo.SaveForChannel("ch-t3", []model.PodcastPodrollItem{
{FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"},
})
ds.MockedPodcastPodroll = podrollRepo
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
item := resp.Podcasts.Channel[0].Podroll[0]
Expect(item.FeedGUID).To(Equal("guid-a"))
Expect(item.Title).To(Equal("Show A"))
})
It("includes liveItem in channel response", func() {
liveItemRepo := tests.CreateMockPodcastLiveItemRepo()
_ = liveItemRepo.Upsert(&model.PodcastLiveItem{
ChannelID: "ch-t3",
GUID: "live-guid-001",
Title: "Live Show",
Status: "live",
EnclosureURL: "https://stream.example.com/live.m3u8",
EnclosureType: "application/x-mpegURL",
ContentLinkURL: "https://youtube.com/live",
ContentLinkText: "Watch Live",
})
ds.MockedPodcastLiveItem = liveItemRepo
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
ch := resp.Podcasts.Channel[0]
Expect(ch.LiveItem).ToNot(BeNil())
Expect(ch.LiveItem.Status).To(Equal("live"))
Expect(ch.LiveItem.GUID).To(Equal("live-guid-001"))
Expect(ch.LiveItem.Title).To(Equal("Live Show"))
Expect(ch.LiveItem.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8"))
Expect(ch.LiveItem.ContentLinkURL).To(Equal("https://youtube.com/live"))
Expect(ch.LiveItem.ContentLinkText).To(Equal("Watch Live"))
})
It("omits liveItem when none exists", func() {
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Podcasts.Channel[0].LiveItem).To(BeNil())
})
It("formats liveItem startTime and endTime as RFC3339", func() {
liveItemRepo := tests.CreateMockPodcastLiveItemRepo()
_ = liveItemRepo.Upsert(&model.PodcastLiveItem{
ChannelID: "ch-t3",
Status: "live",
StartTime: time.Date(2024, 4, 27, 8, 0, 0, 0, time.UTC),
EndTime: time.Date(2024, 4, 27, 9, 0, 0, 0, time.UTC),
})
ds.MockedPodcastLiveItem = liveItemRepo
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
li := resp.Podcasts.Channel[0].LiveItem
Expect(li).ToNot(BeNil())
Expect(li.StartTime).To(Equal("2024-04-27T08:00:00Z"))
Expect(li.EndTime).To(Equal("2024-04-27T09:00:00Z"))
})
})
})

View file

@ -714,23 +714,84 @@ type PodcastChannel struct {
Status string `xml:"status,attr" json:"status"`
ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"`
Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"`
// Podcasting 2.0 Tier 1 & 2
PodcastGuid string `xml:"podcastGuid,attr,omitempty" json:"podcastGuid,omitempty"`
Locked bool `xml:"locked,attr,omitempty" json:"locked,omitempty"`
Medium string `xml:"medium,attr,omitempty" json:"medium,omitempty"`
FundingUrl string `xml:"fundingUrl,attr,omitempty" json:"fundingUrl,omitempty"`
FundingText string `xml:"fundingText,attr,omitempty" json:"fundingText,omitempty"`
UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"`
Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"`
Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"`
// Podcasting 2.0 Tier 3
UsesPodping bool `xml:"usesPodping,attr,omitempty" json:"usesPodping,omitempty"`
Podroll []PodcastPodrollResp `xml:"podroll,omitempty" json:"podroll,omitempty"`
LiveItem *PodcastLiveItemResp `xml:"liveItem,omitempty" json:"liveItem,omitempty"`
}
// PodcastPodrollResp represents a single recommended feed in a podcast:podroll.
type PodcastPodrollResp struct {
FeedGUID string `xml:"feedGuid,attr,omitempty" json:"feedGuid,omitempty"`
FeedURL string `xml:"feedUrl,attr,omitempty" json:"feedUrl,omitempty"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
}
// PodcastLiveItemResp represents a podcast:liveItem in the API response.
type PodcastLiveItemResp struct {
Status string `xml:"status,attr" json:"status"`
StartTime string `xml:"startTime,attr,omitempty" json:"startTime,omitempty"`
EndTime string `xml:"endTime,attr,omitempty" json:"endTime,omitempty"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
GUID string `xml:"guid,attr,omitempty" json:"guid,omitempty"`
EnclosureURL string `xml:"enclosureUrl,attr,omitempty" json:"enclosureUrl,omitempty"`
EnclosureType string `xml:"enclosureType,attr,omitempty" json:"enclosureType,omitempty"`
ContentLinkURL string `xml:"contentLinkUrl,attr,omitempty" json:"contentLinkUrl,omitempty"`
ContentLinkText string `xml:"contentLinkText,attr,omitempty" json:"contentLinkText,omitempty"`
}
type PodcastEpisode struct {
ID string `xml:"id,attr" json:"id"`
StreamId string `xml:"streamId,attr,omitempty" json:"streamId,omitempty"`
ChannelId string `xml:"channelId,attr,omitempty" json:"channelId,omitempty"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
PublishDate string `xml:"publishDate,attr,omitempty" json:"publishDate,omitempty"`
Status string `xml:"status,attr" json:"status"`
ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"`
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"`
Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"`
ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"`
BitRate int `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"`
DownloadedBytes int64 `xml:"downloadedBytes,attr,omitempty" json:"downloadedBytes,omitempty"`
ID string `xml:"id,attr" json:"id"`
StreamId string `xml:"streamId,attr,omitempty" json:"streamId,omitempty"`
ChannelId string `xml:"channelId,attr,omitempty" json:"channelId,omitempty"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
PublishDate string `xml:"publishDate,attr,omitempty" json:"publishDate,omitempty"`
Status string `xml:"status,attr" json:"status"`
ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"`
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"`
Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"`
ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"`
BitRate int `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"`
DownloadedBytes int64 `xml:"downloadedBytes,attr,omitempty" json:"downloadedBytes,omitempty"`
// Podcasting 2.0
Season int `xml:"season,attr,omitempty" json:"season,omitempty"`
SeasonName string `xml:"seasonName,attr,omitempty" json:"seasonName,omitempty"`
EpisodeNumber string `xml:"episode,attr,omitempty" json:"episode,omitempty"`
EpisodeDisplay string `xml:"episodeDisplay,attr,omitempty" json:"episodeDisplay,omitempty"`
ChaptersUrl string `xml:"chaptersUrl,attr,omitempty" json:"chaptersUrl,omitempty"`
SoundbiteStart float64 `xml:"soundbiteStart,attr,omitempty" json:"soundbiteStart,omitempty"`
SoundbiteDur float64 `xml:"soundbiteDur,attr,omitempty" json:"soundbiteDur,omitempty"`
Transcript []PodcastTranscriptResp `xml:"transcript,omitempty" json:"transcript,omitempty"`
Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"`
}
type PodcastTranscriptResp struct {
URL string `xml:"url,attr" json:"url"`
Type string `xml:"type,attr" json:"type"`
Language string `xml:"language,attr,omitempty" json:"language,omitempty"`
Rel string `xml:"rel,attr,omitempty" json:"rel,omitempty"`
}
type PodcastPersonResp struct {
Name string `xml:"name,attr" json:"name"`
Role string `xml:"role,attr,omitempty" json:"role,omitempty"`
Group string `xml:"group,attr,omitempty" json:"group,omitempty"`
Img string `xml:"img,attr,omitempty" json:"img,omitempty"`
Href string `xml:"href,attr,omitempty" json:"href,omitempty"`
}
// StreamDetails describes audio stream properties for transcoding decisions

View file

@ -8,32 +8,36 @@ import (
)
type MockDataStore struct {
RealDS model.DataStore
MockedLibrary model.LibraryRepository
MockedFolder model.FolderRepository
MockedGenre model.GenreRepository
MockedAlbum model.AlbumRepository
MockedArtist model.ArtistRepository
MockedMediaFile model.MediaFileRepository
MockedTag model.TagRepository
MockedUser model.UserRepository
MockedProperty model.PropertyRepository
MockedPlayer model.PlayerRepository
MockedPlaylist model.PlaylistRepository
MockedPlayQueue model.PlayQueueRepository
MockedShare model.ShareRepository
MockedTranscoding model.TranscodingRepository
MockedUserProps model.UserPropsRepository
MockedScrobbleBuffer model.ScrobbleBufferRepository
MockedScrobble model.ScrobbleRepository
MockedRadio model.RadioRepository
MockedPlugin model.PluginRepository
MockedArtwork model.ArtworkRepository
MockedArtworkQueue model.ArtworkQueueRepository
MockedPodcastChannel model.PodcastChannelRepository
MockedPodcastEpisode model.PodcastEpisodeRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
RealDS model.DataStore
MockedLibrary model.LibraryRepository
MockedFolder model.FolderRepository
MockedGenre model.GenreRepository
MockedAlbum model.AlbumRepository
MockedArtist model.ArtistRepository
MockedMediaFile model.MediaFileRepository
MockedTag model.TagRepository
MockedUser model.UserRepository
MockedProperty model.PropertyRepository
MockedPlayer model.PlayerRepository
MockedPlaylist model.PlaylistRepository
MockedPlayQueue model.PlayQueueRepository
MockedShare model.ShareRepository
MockedTranscoding model.TranscodingRepository
MockedUserProps model.UserPropsRepository
MockedScrobbleBuffer model.ScrobbleBufferRepository
MockedScrobble model.ScrobbleRepository
MockedRadio model.RadioRepository
MockedPlugin model.PluginRepository
MockedArtwork model.ArtworkRepository
MockedArtworkQueue model.ArtworkQueueRepository
MockedPodcastChannel model.PodcastChannelRepository
MockedPodcastEpisode model.PodcastEpisodeRepository
MockedPodcastTranscript model.PodcastTranscriptRepository
MockedPodcastPerson model.PodcastPersonRepository
MockedPodcastPodroll model.PodcastPodrollRepository
MockedPodcastLiveItem model.PodcastLiveItemRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
// GC tracking
GCCalled bool
@ -349,6 +353,58 @@ func (db *MockDataStore) PodcastEpisode(ctx context.Context) model.PodcastEpisod
return db.MockedPodcastEpisode
}
func (db *MockDataStore) PodcastTranscript(ctx context.Context) model.PodcastTranscriptRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastTranscript != nil {
return db.MockedPodcastTranscript
}
if db.RealDS != nil {
return db.RealDS.PodcastTranscript(ctx)
}
db.MockedPodcastTranscript = CreateMockPodcastTranscriptRepo()
return db.MockedPodcastTranscript
}
func (db *MockDataStore) PodcastPerson(ctx context.Context) model.PodcastPersonRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastPerson != nil {
return db.MockedPodcastPerson
}
if db.RealDS != nil {
return db.RealDS.PodcastPerson(ctx)
}
db.MockedPodcastPerson = CreateMockPodcastPersonRepo()
return db.MockedPodcastPerson
}
func (db *MockDataStore) PodcastPodroll(ctx context.Context) model.PodcastPodrollRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastPodroll != nil {
return db.MockedPodcastPodroll
}
if db.RealDS != nil {
return db.RealDS.PodcastPodroll(ctx)
}
db.MockedPodcastPodroll = CreateMockPodcastPodrollRepo()
return db.MockedPodcastPodroll
}
func (db *MockDataStore) PodcastLiveItem(ctx context.Context) model.PodcastLiveItemRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastLiveItem != nil {
return db.MockedPodcastLiveItem
}
if db.RealDS != nil {
return db.RealDS.PodcastLiveItem(ctx)
}
db.MockedPodcastLiveItem = CreateMockPodcastLiveItemRepo()
return db.MockedPodcastLiveItem
}
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
return block(db)
}

View file

@ -0,0 +1,53 @@
package tests
import (
"errors"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
// MockPodcastLiveItemRepo is a manual in-memory mock for model.PodcastLiveItemRepository.
type MockPodcastLiveItemRepo struct {
model.PodcastLiveItemRepository
Data map[string]*model.PodcastLiveItem // keyed by channelID
Err bool
}
// CreateMockPodcastLiveItemRepo returns an initialized MockPodcastLiveItemRepo.
func CreateMockPodcastLiveItemRepo() *MockPodcastLiveItemRepo {
return &MockPodcastLiveItemRepo{Data: map[string]*model.PodcastLiveItem{}}
}
func (m *MockPodcastLiveItemRepo) GetByChannel(channelID string) (*model.PodcastLiveItem, error) {
if m.Err {
return nil, errors.New("error")
}
if item, ok := m.Data[channelID]; ok {
return item, nil
}
return nil, model.ErrNotFound
}
func (m *MockPodcastLiveItemRepo) Upsert(item *model.PodcastLiveItem) error {
if m.Err {
return errors.New("error")
}
if _, ok := m.Data[item.ChannelID]; !ok {
item.ID = id.NewRandom()
item.CreatedAt = time.Now()
}
item.UpdatedAt = time.Now()
cp := *item
m.Data[item.ChannelID] = &cp
return nil
}
func (m *MockPodcastLiveItemRepo) DeleteByChannel(channelID string) error {
if m.Err {
return errors.New("error")
}
delete(m.Data, channelID)
return nil
}

View file

@ -0,0 +1,97 @@
package tests
import (
"errors"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
type MockPodcastPersonRepo struct {
model.PodcastPersonRepository
Data map[string]*model.PodcastPerson
Err bool
}
func CreateMockPodcastPersonRepo() *MockPodcastPersonRepo {
return &MockPodcastPersonRepo{Data: map[string]*model.PodcastPerson{}}
}
func (m *MockPodcastPersonRepo) GetByChannel(channelID string) (model.PodcastPersons, error) {
if m.Err {
return nil, errors.New("error")
}
var result model.PodcastPersons
for _, p := range m.Data {
if p.ChannelID == channelID {
result = append(result, *p)
}
}
return result, nil
}
func (m *MockPodcastPersonRepo) GetByEpisode(episodeID string) (model.PodcastPersons, error) {
if m.Err {
return nil, errors.New("error")
}
var result model.PodcastPersons
for _, p := range m.Data {
if p.EpisodeID == episodeID {
result = append(result, *p)
}
}
return result, nil
}
func (m *MockPodcastPersonRepo) GetByEpisodes(episodeIDs []string) (model.PodcastPersons, error) {
if m.Err {
return nil, errors.New("error")
}
ids := make(map[string]bool, len(episodeIDs))
for _, id := range episodeIDs {
ids[id] = true
}
var result model.PodcastPersons
for _, p := range m.Data {
if ids[p.EpisodeID] {
result = append(result, *p)
}
}
return result, nil
}
func (m *MockPodcastPersonRepo) SaveForChannel(channelID string, persons []model.PodcastPerson) error {
if m.Err {
return errors.New("error")
}
for k, p := range m.Data {
if p.ChannelID == channelID {
delete(m.Data, k)
}
}
for i := range persons {
persons[i].ID = id.NewRandom()
persons[i].ChannelID = channelID
p := persons[i]
m.Data[p.ID] = &p
}
return nil
}
func (m *MockPodcastPersonRepo) SaveForEpisode(episodeID string, persons []model.PodcastPerson) error {
if m.Err {
return errors.New("error")
}
for k, p := range m.Data {
if p.EpisodeID == episodeID {
delete(m.Data, k)
}
}
for i := range persons {
persons[i].ID = id.NewRandom()
persons[i].EpisodeID = episodeID
p := persons[i]
m.Data[p.ID] = &p
}
return nil
}

View file

@ -0,0 +1,70 @@
package tests
import (
"errors"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
// MockPodcastPodrollRepo is a manual in-memory mock for model.PodcastPodrollRepository.
type MockPodcastPodrollRepo struct {
model.PodcastPodrollRepository
Data map[string]*model.PodcastPodrollItem // keyed by item ID
Err bool
}
// CreateMockPodcastPodrollRepo returns an initialized MockPodcastPodrollRepo.
func CreateMockPodcastPodrollRepo() *MockPodcastPodrollRepo {
return &MockPodcastPodrollRepo{Data: map[string]*model.PodcastPodrollItem{}}
}
func (m *MockPodcastPodrollRepo) GetByChannel(channelID string) (model.PodcastPodrollItems, error) {
if m.Err {
return nil, errors.New("error")
}
var result model.PodcastPodrollItems
for _, item := range m.Data {
if item.ChannelID == channelID {
result = append(result, *item)
}
}
return result, nil
}
func (m *MockPodcastPodrollRepo) GetByChannels(channelIDs []string) (model.PodcastPodrollItems, error) {
if m.Err {
return nil, errors.New("error")
}
ids := make(map[string]bool)
for _, cid := range channelIDs {
ids[cid] = true
}
var result model.PodcastPodrollItems
for _, item := range m.Data {
if ids[item.ChannelID] {
result = append(result, *item)
}
}
return result, nil
}
func (m *MockPodcastPodrollRepo) SaveForChannel(channelID string, items []model.PodcastPodrollItem) error {
if m.Err {
return errors.New("error")
}
// Remove old items for this channel.
for k, v := range m.Data {
if v.ChannelID == channelID {
delete(m.Data, k)
}
}
for i := range items {
items[i].ID = id.NewRandom()
items[i].ChannelID = channelID
items[i].SortOrder = i
cp := items[i]
m.Data[cp.ID] = &cp
}
return nil
}

View file

@ -0,0 +1,74 @@
package tests
import (
"errors"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
type MockPodcastTranscriptRepo struct {
model.PodcastTranscriptRepository
Data map[string]*model.PodcastTranscript
Err bool
}
func CreateMockPodcastTranscriptRepo() *MockPodcastTranscriptRepo {
return &MockPodcastTranscriptRepo{Data: map[string]*model.PodcastTranscript{}}
}
func (m *MockPodcastTranscriptRepo) GetByEpisode(episodeID string) (model.PodcastTranscripts, error) {
if m.Err {
return nil, errors.New("error")
}
var result model.PodcastTranscripts
for _, t := range m.Data {
if t.EpisodeID == episodeID {
result = append(result, *t)
}
}
return result, nil
}
func (m *MockPodcastTranscriptRepo) GetByEpisodes(episodeIDs []string) (model.PodcastTranscripts, error) {
if m.Err {
return nil, errors.New("error")
}
ids := make(map[string]bool, len(episodeIDs))
for _, id := range episodeIDs {
ids[id] = true
}
var result model.PodcastTranscripts
for _, t := range m.Data {
if ids[t.EpisodeID] {
result = append(result, *t)
}
}
return result, nil
}
func (m *MockPodcastTranscriptRepo) Save(transcripts []model.PodcastTranscript) error {
if m.Err {
return errors.New("error")
}
for i := range transcripts {
if transcripts[i].ID == "" {
transcripts[i].ID = id.NewRandom()
}
t := transcripts[i]
m.Data[t.ID] = &t
}
return nil
}
func (m *MockPodcastTranscriptRepo) DeleteByEpisode(episodeID string) error {
if m.Err {
return errors.New("error")
}
for k, t := range m.Data {
if t.EpisodeID == episodeID {
delete(m.Data, k)
}
}
return nil
}