navidrome/persistence/podcast_transcript_repository_test.go
Jiho Andrew Lee 152a032ae1 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
2026-09-19 21:07:17 +09:00

144 lines
4.7 KiB
Go

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