navidrome/persistence/podcast_transcript_repository.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

59 lines
1.6 KiB
Go

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)