mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-12 13:18:40 +01:00
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
144 lines
4.7 KiB
Go
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())
|
|
})
|
|
})
|
|
})
|