mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
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:
parent
be3c271c9c
commit
152a032ae1
27 changed files with 3147 additions and 86 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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, ".")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
76
db/migrations/20260428000000_add_podcast20.go
Normal file
76
db/migrations/20260428000000_add_podcast20.go
Normal 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
|
||||
}
|
||||
59
db/migrations/20260428120000_add_podcast_tier3.go
Normal file
59
db/migrations/20260428120000_add_podcast_tier3.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
178
model/podcast.go
178
model/podcast.go
|
|
@ -2,6 +2,51 @@ package model
|
|||
|
||||
import "time"
|
||||
|
||||
// PodcastPodrollItem represents one entry in a podcast:podroll recommendation list.
|
||||
type PodcastPodrollItem struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId"`
|
||||
FeedGUID string `structs:"feed_guid" json:"feedGuid,omitempty"`
|
||||
FeedURL string `structs:"feed_url" json:"feedUrl,omitempty"`
|
||||
Title string `structs:"title" json:"title,omitempty"`
|
||||
SortOrder int `structs:"sort_order" json:"sortOrder"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
// PodcastLiveItem represents a podcast:liveItem stream associated with a channel.
|
||||
type PodcastLiveItem struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId"`
|
||||
GUID string `structs:"guid" json:"guid,omitempty"`
|
||||
Title string `structs:"title" json:"title,omitempty"`
|
||||
Status string `structs:"status" json:"status"`
|
||||
StartTime time.Time `structs:"start_time" json:"startTime,omitempty"`
|
||||
EndTime time.Time `structs:"end_time" json:"endTime,omitempty"`
|
||||
EnclosureURL string `structs:"enclosure_url" json:"enclosureUrl,omitempty"`
|
||||
EnclosureType string `structs:"enclosure_type" json:"enclosureType,omitempty"`
|
||||
ContentLinkURL string `structs:"content_link_url" json:"contentLinkUrl,omitempty"`
|
||||
ContentLinkText string `structs:"content_link_text" json:"contentLinkText,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PodcastPodrollItems is a slice of PodcastPodrollItem.
|
||||
type PodcastPodrollItems []PodcastPodrollItem
|
||||
|
||||
// PodcastPodrollRepository manages podcast:podroll entries for channels.
|
||||
type PodcastPodrollRepository interface {
|
||||
GetByChannel(channelID string) (PodcastPodrollItems, error)
|
||||
GetByChannels(channelIDs []string) (PodcastPodrollItems, error)
|
||||
SaveForChannel(channelID string, items []PodcastPodrollItem) error
|
||||
}
|
||||
|
||||
// PodcastLiveItemRepository manages podcast:liveItem entries (one per channel).
|
||||
type PodcastLiveItemRepository interface {
|
||||
GetByChannel(channelID string) (*PodcastLiveItem, error)
|
||||
Upsert(item *PodcastLiveItem) error
|
||||
DeleteByChannel(channelID string) error
|
||||
}
|
||||
|
||||
type PodcastStatus string
|
||||
|
||||
const (
|
||||
|
|
@ -14,42 +59,104 @@ const (
|
|||
)
|
||||
|
||||
type PodcastChannel struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
URL string `structs:"url" json:"url"`
|
||||
Title string `structs:"title" json:"title"`
|
||||
Description string `structs:"description" json:"description"`
|
||||
ImageURL string `structs:"image_url" json:"imageUrl"`
|
||||
Status PodcastStatus `structs:"status" json:"status"`
|
||||
ErrorMessage string `structs:"error_message" json:"errorMessage"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"`
|
||||
ID string `structs:"id" json:"id"`
|
||||
URL string `structs:"url" json:"url"`
|
||||
Title string `structs:"title" json:"title"`
|
||||
Description string `structs:"description" json:"description"`
|
||||
ImageURL string `structs:"image_url" json:"imageUrl"`
|
||||
Status PodcastStatus `structs:"status" json:"status"`
|
||||
ErrorMessage string `structs:"error_message" json:"errorMessage"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
|
||||
// Podcasting 2.0 — Tier 1
|
||||
PodcastGUID string `structs:"podcast_guid" json:"podcastGuid,omitempty"`
|
||||
|
||||
// Podcasting 2.0 — Tier 2
|
||||
Locked bool `structs:"locked" json:"locked,omitempty"`
|
||||
LockedOwner string `structs:"locked_owner" json:"lockedOwner,omitempty"`
|
||||
Medium string `structs:"medium" json:"medium,omitempty"`
|
||||
FundingURL string `structs:"funding_url" json:"fundingUrl,omitempty"`
|
||||
FundingText string `structs:"funding_text" json:"fundingText,omitempty"`
|
||||
UpdateFrequency string `structs:"update_frequency" json:"updateFrequency,omitempty"`
|
||||
UpdateRRule string `structs:"update_rrule" json:"updateRRule,omitempty"`
|
||||
Complete bool `structs:"complete" json:"complete,omitempty"`
|
||||
|
||||
// Podcasting 2.0 — Tier 3
|
||||
UsesPodping bool `structs:"uses_podping" json:"usesPodping,omitempty"`
|
||||
Podroll PodcastPodrollItems `structs:"-" json:"podroll,omitempty"`
|
||||
LiveItem *PodcastLiveItem `structs:"-" json:"liveItem,omitempty"`
|
||||
|
||||
// loaded separately
|
||||
Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"`
|
||||
Persons PodcastPersons `structs:"-" json:"persons,omitempty"`
|
||||
}
|
||||
|
||||
type PodcastEpisode struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId"`
|
||||
StreamID string `structs:"stream_id" json:"streamId"`
|
||||
GUID string `structs:"guid" json:"guid"`
|
||||
Title string `structs:"title" json:"title"`
|
||||
Description string `structs:"description" json:"description"`
|
||||
PublishDate time.Time `structs:"publish_date" json:"publishDate"`
|
||||
Duration int `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
BitRate int `structs:"bit_rate" json:"bitRate"`
|
||||
Suffix string `structs:"suffix" json:"suffix"`
|
||||
ContentType string `structs:"content_type" json:"contentType"`
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId"`
|
||||
StreamID string `structs:"stream_id" json:"streamId"`
|
||||
GUID string `structs:"guid" json:"guid"`
|
||||
Title string `structs:"title" json:"title"`
|
||||
Description string `structs:"description" json:"description"`
|
||||
PublishDate time.Time `structs:"publish_date" json:"publishDate"`
|
||||
Duration int `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
BitRate int `structs:"bit_rate" json:"bitRate"`
|
||||
Suffix string `structs:"suffix" json:"suffix"`
|
||||
ContentType string `structs:"content_type" json:"contentType"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
EnclosureURL string `structs:"enclosure_url" json:"enclosureUrl"`
|
||||
DownloadedBytes int64 `structs:"downloaded_bytes" json:"downloadedBytes"`
|
||||
Status PodcastStatus `structs:"status" json:"status"`
|
||||
ErrorMessage string `structs:"error_message" json:"errorMessage"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
Status PodcastStatus `structs:"status" json:"status"`
|
||||
ErrorMessage string `structs:"error_message" json:"errorMessage"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
|
||||
// Podcasting 2.0 — Tier 1
|
||||
Season int `structs:"season" json:"season,omitempty"`
|
||||
SeasonName string `structs:"season_name" json:"seasonName,omitempty"`
|
||||
EpisodeNumber string `structs:"episode_number" json:"episodeNumber,omitempty"`
|
||||
EpisodeDisplay string `structs:"episode_display" json:"episodeDisplay,omitempty"`
|
||||
ChaptersURL string `structs:"chapters_url" json:"chaptersUrl,omitempty"`
|
||||
ChaptersType string `structs:"chapters_type" json:"chaptersType,omitempty"`
|
||||
|
||||
// Podcasting 2.0 — Tier 2
|
||||
SoundbiteStart float64 `structs:"soundbite_start" json:"soundbiteStart,omitempty"`
|
||||
SoundbiteDur float64 `structs:"soundbite_dur" json:"soundbiteDur,omitempty"`
|
||||
SoundbiteTitle string `structs:"soundbite_title" json:"soundbiteTitle,omitempty"`
|
||||
|
||||
// loaded separately
|
||||
Transcripts PodcastTranscripts `structs:"-" json:"transcripts,omitempty"`
|
||||
Persons PodcastPersons `structs:"-" json:"persons,omitempty"`
|
||||
}
|
||||
|
||||
type PodcastChannels []PodcastChannel
|
||||
type PodcastEpisodes []PodcastEpisode
|
||||
type PodcastTranscript struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
EpisodeID string `structs:"episode_id" json:"episodeId"`
|
||||
URL string `structs:"url" json:"url"`
|
||||
MimeType string `structs:"mime_type" json:"type"`
|
||||
Language string `structs:"language" json:"language,omitempty"`
|
||||
Rel string `structs:"rel" json:"rel,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
type PodcastPerson struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId,omitempty"`
|
||||
EpisodeID string `structs:"episode_id" json:"episodeId,omitempty"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Role string `structs:"role" json:"role,omitempty"`
|
||||
Group string `structs:"group_name" db:"group_name" json:"group,omitempty"`
|
||||
Img string `structs:"img" json:"img,omitempty"`
|
||||
Href string `structs:"href" json:"href,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
type PodcastChannels []PodcastChannel
|
||||
type PodcastEpisodes []PodcastEpisode
|
||||
type PodcastTranscripts []PodcastTranscript
|
||||
type PodcastPersons []PodcastPerson
|
||||
|
||||
type PodcastChannelRepository interface {
|
||||
Get(id string) (*PodcastChannel, error)
|
||||
|
|
@ -70,3 +177,18 @@ type PodcastEpisodeRepository interface {
|
|||
Update(ep *PodcastEpisode) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type PodcastTranscriptRepository interface {
|
||||
GetByEpisode(episodeID string) (PodcastTranscripts, error)
|
||||
GetByEpisodes(episodeIDs []string) (PodcastTranscripts, error)
|
||||
Save(transcripts []PodcastTranscript) error
|
||||
DeleteByEpisode(episodeID string) error
|
||||
}
|
||||
|
||||
type PodcastPersonRepository interface {
|
||||
GetByChannel(channelID string) (PodcastPersons, error)
|
||||
GetByEpisode(episodeID string) (PodcastPersons, error)
|
||||
GetByEpisodes(episodeIDs []string) (PodcastPersons, error)
|
||||
SaveForChannel(channelID string, persons []PodcastPerson) error
|
||||
SaveForEpisode(episodeID string, persons []PodcastPerson) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
59
persistence/podcast_live_item_repository.go
Normal file
59
persistence/podcast_live_item_repository.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type 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)
|
||||
150
persistence/podcast_live_item_repository_test.go
Normal file
150
persistence/podcast_live_item_repository_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
83
persistence/podcast_person_repository.go
Normal file
83
persistence/podcast_person_repository.go
Normal 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)
|
||||
172
persistence/podcast_person_repository_test.go
Normal file
172
persistence/podcast_person_repository_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
59
persistence/podcast_podroll_repository.go
Normal file
59
persistence/podcast_podroll_repository.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type 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)
|
||||
145
persistence/podcast_podroll_repository_test.go
Normal file
145
persistence/podcast_podroll_repository_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
59
persistence/podcast_transcript_repository.go
Normal file
59
persistence/podcast_transcript_repository.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type 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)
|
||||
144
persistence/podcast_transcript_repository_test.go
Normal file
144
persistence/podcast_transcript_repository_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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}}),
|
||||
))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
53
tests/mock_podcast_live_item_repo.go
Normal file
53
tests/mock_podcast_live_item_repo.go
Normal 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
|
||||
}
|
||||
97
tests/mock_podcast_person_repo.go
Normal file
97
tests/mock_podcast_person_repo.go
Normal 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
|
||||
}
|
||||
70
tests/mock_podcast_podroll_repo.go
Normal file
70
tests/mock_podcast_podroll_repo.go
Normal 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
|
||||
}
|
||||
74
tests/mock_podcast_transcript_repo.go
Normal file
74
tests/mock_podcast_transcript_repo.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue