This commit is contained in:
Jiho Persy Lee 2026-10-06 16:06:10 +02:00 • committed by GitHub
commit aaf8f570d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 8973 additions and 125 deletions

4
.gitignore vendored
View file

@ -48,3 +48,7 @@ zz_*_test.go
# wazero compilation cache for the plugins test suite
/plugins/testdata/.wazero-cache/
/plugins/testdata/*.stage/
# Downloaded podcast episode audio files should not be tracked (DataFolder defaults to ".")
/podcasts/
/ui/podcasts/

View file

@ -151,7 +151,7 @@ format: ##@Development Format code
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./cmd
.PHONY: wire
gen: check_go_env api-gen ##@Development Run go generate for code generation

View file

@ -21,6 +21,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
@ -111,8 +112,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
podcastsPodcasts := podcasts.NewPodcastService(ctx, dataStore, fFmpeg, broker)
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, podcastsPodcasts, sonicSonic)
return router
}

View file

@ -37,12 +37,13 @@ type TranscodeOptions struct {
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
type AudioProbeResult struct {
Codec string `json:"codec"`
Profile string `json:"profile,omitempty"`
BitRate int `json:"bitRate"`
SampleRate int `json:"sampleRate"`
BitDepth int `json:"bitDepth"`
Channels int `json:"channels"`
Codec string `json:"codec"`
Profile string `json:"profile,omitempty"`
BitRate int `json:"bitRate"`
SampleRate int `json:"sampleRate"`
BitDepth int `json:"bitDepth"`
Channels int `json:"channels"`
Duration float64 `json:"duration"`
}
type FFmpeg interface {
@ -234,7 +235,8 @@ type probeOutput struct {
}
type probeFormat struct {
BitRate string `json:"bit_rate"`
BitRate string `json:"bit_rate"`
Duration string `json:"duration"`
}
type probeStream struct {
@ -290,6 +292,11 @@ func parseProbeOutput(data []byte) (*AudioProbeResult, error) {
result.BitRate = bps / 1000
}
// Duration from format section (seconds as float string)
if output.Format.Duration != "" {
result.Duration, _ = strconv.ParseFloat(output.Format.Duration, 64)
}
return result, nil
}

776
core/podcasts/podcasts.go Normal file
View file

@ -0,0 +1,776 @@
package podcasts
import (
"context"
"fmt"
"io"
"math"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/server/events"
)
const podcastLibraryName = "Podcasts"
type Podcasts interface {
AddChannel(ctx context.Context, rssURL string) error
RefreshChannels(ctx context.Context) error
DeleteChannel(ctx context.Context, id string) error
DeleteEpisode(ctx context.Context, id string) error
DownloadEpisode(ctx context.Context, id string) error
}
type podcastService struct {
rootCtx context.Context
ds model.DataStore
ff ffmpeg.FFmpeg
broker events.Broker
}
func NewPodcastService(rootCtx context.Context, ds model.DataStore, ff ffmpeg.FFmpeg, broker events.Broker) Podcasts {
return &podcastService{rootCtx: rootCtx, ds: ds, ff: ff, broker: broker}
}
// podcastsDir is where downloaded episodes live and the root of the podcast
// virtual library.
func podcastsDir() string {
return filepath.Join(conf.Server.DataFolder.String(), "podcasts")
}
// podcastLibraryID returns the ID of the podcast virtual library, creating it
// if it doesn't exist. The library root is DataFolder/podcasts so that MediaFile
// paths stored as "{ch}/{ep}.mp3" resolve correctly via AbsolutePath().
//
// Episodes are registered as MediaFiles by the podcast service itself, so the
// library must not be picked up by the regular scanner: an empty .ndignore in
// the root makes the scanner skip everything below it.
func (s *podcastService) podcastLibraryID(ctx context.Context) (int, error) {
libs, err := s.ds.Library().GetAll(ctx)
if err != nil {
return 0, err
}
for _, lib := range libs {
if lib.Name == podcastLibraryName {
return lib.ID, nil
}
}
root := podcastsDir()
if err := os.MkdirAll(root, 0755); err != nil {
return 0, fmt.Errorf("creating podcasts dir: %w", err)
}
ignoreFile := filepath.Join(root, consts.ScanIgnoreFile)
if _, statErr := os.Stat(ignoreFile); os.IsNotExist(statErr) {
if err := os.WriteFile(ignoreFile, nil, 0644); err != nil {
return 0, fmt.Errorf("creating %s: %w", consts.ScanIgnoreFile, err)
}
}
lib := &model.Library{
Name: podcastLibraryName,
Path: root,
DefaultNewUsers: true, // users created later get access automatically
}
if err := s.ds.Library().Put(ctx, lib); err != nil {
return 0, err
}
// Put only auto-assigns admins; give existing regular users access too.
s.assignLibraryToExistingUsers(ctx, lib.ID)
return lib.ID, nil
}
func (s *podcastService) assignLibraryToExistingUsers(ctx context.Context, libID int) {
users, err := s.ds.User().GetAll(ctx)
if err != nil {
log.Warn(ctx, "Failed to list users to assign podcast library", err)
return
}
for _, u := range users {
if u.IsAdmin {
continue
}
ids := []int{libID}
for _, l := range u.Libraries {
if l.ID != libID {
ids = append(ids, l.ID)
}
}
if err := s.ds.User().SetUserLibraries(ctx, u.ID, ids); err != nil {
log.Warn(ctx, "Failed to assign podcast library to user", "user", u.UserName, err)
}
}
}
func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error {
exists, err := s.ds.PodcastChannel().ExistsByURL(ctx, rssURL)
if err != nil {
return fmt.Errorf("checking existing channel: %w", err)
}
if exists {
return fmt.Errorf("channel already exists: %s", rssURL)
}
feed, err := fetchAndParse(rssURL)
if err != nil {
return fmt.Errorf("adding podcast channel: %w", err)
}
ch := &model.PodcastChannel{
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,
UpdateFrequency: feed.UpdateFrequency,
UpdateRRule: feed.UpdateRRule,
Complete: feed.Complete,
UsesPodping: feed.UsesPodping,
LocationName: feed.LocationName,
LocationGeo: feed.LocationGeo,
LocationOSM: feed.LocationOSM,
License: feed.License,
PublisherName: feed.PublisherName,
PublisherURL: feed.PublisherURL,
}
if err := s.ds.PodcastChannel().Create(ctx, ch); err != nil {
return err
}
// Save channel-level persons
if len(feed.Persons) > 0 {
if err := s.ds.PodcastPerson().SaveForChannel(ctx, ch.ID, feed.Persons); err != nil {
log.Warn(ctx, "Failed to save podcast channel persons", "channel", ch.ID, err)
}
}
// Save podcast:funding items
if len(feed.FundingItems) > 0 {
for i := range feed.FundingItems {
feed.FundingItems[i].ChannelID = ch.ID
}
if err := s.ds.PodcastFunding().SaveForChannel(ctx, ch.ID, feed.FundingItems); err != nil {
log.Warn(ctx, "Failed to save podcast funding items", "channel", ch.ID, err)
}
}
// Save podcast:image (channel level)
if len(feed.Images) > 0 {
if err := s.ds.PodcastImage().SaveForChannel(ctx, ch.ID, feed.Images); err != nil {
log.Warn(ctx, "Failed to save podcast channel images", "channel", ch.ID, err)
}
}
// Save podcast:podroll items
if len(feed.Podroll) > 0 {
if err := s.ds.PodcastPodroll().SaveForChannel(ctx, 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().Upsert(ctx, &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
images := ep.Images
ep.Transcripts = nil
ep.Persons = nil
ep.Images = nil
if err := s.ds.PodcastEpisode().Create(ctx, &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().Save(ctx, 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().SaveForEpisode(ctx, ep.ID, persons); err != nil {
log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err)
}
}
// Save episode images
if len(images) > 0 {
if err := s.ds.PodcastImage().SaveForEpisode(ctx, ep.ID, images); err != nil {
log.Warn(ctx, "Failed to save podcast episode images", "episode", ep.ID, err)
}
}
}
ch.Status = model.PodcastStatusCompleted
return s.ds.PodcastChannel().UpdateChannel(ctx, ch)
}
func (s *podcastService) RefreshChannels(ctx context.Context) error {
channels, err := s.ds.PodcastChannel().GetAll(ctx, false)
if err != nil {
return err
}
for _, ch := range channels {
if err := s.refreshChannel(ctx, ch); err != nil {
log.Warn(ctx, "Failed to refresh podcast channel", "channel", ch.Title, err)
}
}
return nil
}
func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastChannel) error {
feed, err := fetchAndParse(ch.URL)
if err != nil {
return err
}
// Refresh podcast:funding items
if err := s.ds.PodcastFunding().SaveForChannel(ctx, ch.ID, feed.FundingItems); err != nil {
log.Warn(ctx, "Failed to refresh funding items", "channel", ch.ID, err)
}
// Refresh podcast:image (channel level)
if err := s.ds.PodcastImage().SaveForChannel(ctx, ch.ID, feed.Images); err != nil {
log.Warn(ctx, "Failed to refresh channel images", "channel", ch.ID, err)
}
// Refresh podcast:podroll
if err := s.ds.PodcastPodroll().SaveForChannel(ctx, 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().Upsert(ctx, &li); err != nil {
log.Warn(ctx, "Failed to upsert live item", "channel", ch.ID, err)
}
}
epRepo := s.ds.PodcastEpisode()
for i := range feed.Episodes {
ep := feed.Episodes[i]
_, err := epRepo.GetByGUID(ctx, ch.ID, ep.GUID)
if err == nil {
continue // already exists
}
ep.ChannelID = ch.ID
ep.Status = model.PodcastStatusNew
transcripts := ep.Transcripts
persons := ep.Persons
images := ep.Images
ep.Transcripts = nil
ep.Persons = nil
ep.Images = nil
if err := epRepo.Create(ctx, &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().Save(ctx, 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().SaveForEpisode(ctx, ep.ID, persons); err != nil {
log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err)
}
}
// Save episode images
if len(images) > 0 {
if err := s.ds.PodcastImage().SaveForEpisode(ctx, ep.ID, images); err != nil {
log.Warn(ctx, "Failed to save podcast episode images", "episode", ep.ID, err)
}
}
}
return nil
}
func (s *podcastService) DownloadEpisode(ctx context.Context, id string) error {
ep, err := s.ds.PodcastEpisode().Get(ctx, id)
if err != nil {
return err
}
ch, err := s.ds.PodcastChannel().Get(ctx, ep.ChannelID)
if err != nil {
return err
}
ep.Status = model.PodcastStatusDownloading
ep.UpdatedAt = time.Now()
if err := s.ds.PodcastEpisode().Update(ctx, ep); err != nil {
return err
}
go s.doDownload(s.rootCtx, ep, ch)
return nil
}
func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisode, ch *model.PodcastChannel) {
suffix := ep.Suffix
if suffix == "" {
suffix = "mp3"
}
dir := filepath.Join(podcastsDir(), ep.ChannelID)
if err := os.MkdirAll(dir, 0755); err != nil {
s.setEpisodeError(ctx, ep, err)
return
}
dest := filepath.Join(dir, ep.ID+"."+suffix)
if err := validateURL(ep.EnclosureURL); err != nil {
s.setEpisodeError(ctx, ep, fmt.Errorf("invalid enclosure URL: %w", err))
return
}
// No total Client.Timeout here: it would also cover reading the body and
// abort large episodes. Connection-phase limits live on safeHTTPTransport.
httpClient := &http.Client{Transport: safeHTTPTransport}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.EnclosureURL, nil)
if err != nil {
s.setEpisodeError(ctx, ep, err)
return
}
resp, err := httpClient.Do(req) //nolint:gosec
if err != nil {
s.setEpisodeError(ctx, ep, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err := fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, ep.EnclosureURL)
s.setEpisodeError(ctx, ep, err)
return
}
// Create the file only once we have a 200 response, so failed requests
// don't leave empty files behind.
f, err := os.Create(dest)
if err != nil {
s.setEpisodeError(ctx, ep, err)
return
}
defer f.Close()
// Use Content-Length as total size when RSS feed didn't provide it
if resp.ContentLength > 0 && ep.Size == 0 {
ep.Size = resp.ContentLength
}
size, err := io.Copy(&progressWriter{ep: ep, ds: s.ds, broker: s.broker, ctx: ctx, w: f}, resp.Body)
if err != nil {
f.Close()
_ = os.Remove(dest) // don't leave a partial file behind
s.setEpisodeError(ctx, ep, err)
return
}
f.Close()
// Write ID3 tags so the scanner picks up the correct metadata
s.writeID3Tags(ctx, dest, suffix, ep.Title, ch.Title)
// Register as a MediaFile so /rest/stream works with the standard media file path.
// Use the podcast virtual library (rooted at DataFolder/podcasts); store relative path.
libID, libErr := s.podcastLibraryID(ctx)
if libErr != nil {
log.Warn(ctx, "Failed to get podcast library, streaming may not work", "episode", ep.ID, libErr)
} else {
relPath := strings.TrimPrefix(dest, podcastsDir()+string(filepath.Separator))
now := time.Now()
tags := model.Tags{}
tags.Add("genre", "Podcast")
// Reuse the existing MediaFile on re-download so Put updates it instead of
// leaving a duplicate track behind.
mfID := ep.StreamID
if mfID == "" {
mfID = id.NewRandom()
}
mf := &model.MediaFile{
ID: mfID,
LibraryID: libID,
Path: relPath,
Title: ep.Title,
Album: ch.Title,
AlbumID: ch.ID,
Artist: "",
AlbumArtist: ch.Title,
Genre: "Podcast",
Tags: tags,
Duration: float32(ep.Duration),
Size: size,
BitRate: ep.BitRate,
Suffix: suffix,
CreatedAt: now,
UpdatedAt: now,
}
if putErr := s.ds.MediaFile().Put(ctx, mf); putErr != nil {
log.Warn(ctx, "Failed to register podcast episode as MediaFile", "episode", ep.ID, putErr)
} else {
ep.StreamID = mf.ID
}
}
// Probe actual duration and bitrate from the downloaded file
if s.ff != nil && s.ff.IsProbeAvailable() {
if probe, probeErr := s.ff.ProbeAudioStream(ctx, dest); probeErr == nil {
if probe.Duration > 0 {
ep.Duration = int(math.Round(probe.Duration))
}
if probe.BitRate > 0 {
ep.BitRate = probe.BitRate
}
} else {
log.Warn(ctx, "Failed to probe podcast episode duration", "episode", ep.ID, probeErr)
}
}
ep.Path = dest
ep.Size = size
ep.DownloadedBytes = size
ep.Status = model.PodcastStatusCompleted
ep.ErrorMessage = ""
ep.UpdatedAt = time.Now()
if err := s.ds.PodcastEpisode().Update(ctx, ep); err != nil {
log.Error(ctx, "Failed to update episode after download", "episode", ep.ID, err)
}
if s.broker != nil {
s.broker.SendBroadcastMessage(ctx, &events.PodcastEpisodeProgress{
EpisodeID: ep.ID,
ChannelID: ep.ChannelID,
DownloadedBytes: size,
Size: size,
Duration: ep.Duration,
Status: string(model.PodcastStatusCompleted),
})
}
}
func (s *podcastService) setEpisodeError(ctx context.Context, ep *model.PodcastEpisode, err error) {
ep.Status = model.PodcastStatusError
ep.ErrorMessage = err.Error()
ep.UpdatedAt = time.Now()
if updateErr := s.ds.PodcastEpisode().Update(ctx, ep); updateErr != nil {
log.Error(ctx, "Failed to set episode error status", "episode", ep.ID, updateErr)
}
if s.broker != nil {
s.broker.SendBroadcastMessage(ctx, &events.PodcastEpisodeProgress{
EpisodeID: ep.ID,
ChannelID: ep.ChannelID,
Status: string(model.PodcastStatusError),
})
}
}
// sanitizeMetadata removes null bytes and trims whitespace from ffmpeg metadata values.
// Since exec.Command passes args directly (no shell), only null bytes need sanitizing.
func sanitizeMetadata(s string) string {
return strings.ReplaceAll(strings.TrimSpace(s), "\x00", "")
}
func (s *podcastService) writeID3Tags(ctx context.Context, dest, suffix, title, album string) {
if s.ff == nil {
return
}
ffmpegPath, err := s.ff.CmdPath()
if err != nil {
return
}
tmp := dest + ".tmp." + suffix
cmd := exec.CommandContext(ctx, ffmpegPath,
"-i", dest,
"-metadata", "title="+sanitizeMetadata(title),
"-metadata", "album="+sanitizeMetadata(album),
"-metadata", "artist=",
"-metadata", "genre=Podcast",
"-c", "copy", "-y", tmp,
)
if err := cmd.Run(); err != nil {
log.Warn(ctx, "Failed to write ID3 tags to podcast episode", "episode", dest, err)
_ = os.Remove(tmp)
return
}
if err := os.Rename(tmp, dest); err != nil {
log.Warn(ctx, "Failed to replace podcast file with tagged version", err)
_ = os.Remove(tmp)
}
}
func (s *podcastService) DeleteEpisode(ctx context.Context, id string) error {
ep, err := s.ds.PodcastEpisode().Get(ctx, id)
if err != nil {
return err
}
if ep.Path != "" {
_ = os.Remove(ep.Path)
ep.Path = ""
}
// Remove the registered MediaFile so it can be re-registered on next download
if ep.StreamID != "" {
_ = s.ds.MediaFile().Delete(ctx, ep.StreamID)
ep.StreamID = ""
}
ep.Status = model.PodcastStatusNew
ep.ErrorMessage = ""
ep.Size = 0
ep.DownloadedBytes = 0
ep.Duration = 0
ep.BitRate = 0
ep.UpdatedAt = time.Now()
return s.ds.PodcastEpisode().Update(ctx, ep)
}
func (s *podcastService) DeleteChannel(ctx context.Context, id string) error {
episodes, err := s.ds.PodcastEpisode().GetByChannel(ctx, id)
if err != nil {
return err
}
for _, ep := range episodes {
if ep.Path != "" {
_ = os.Remove(ep.Path)
}
}
// The channel-/episode-scoped detail tables have no FK cascade, and the
// registered MediaFiles are not FK-linked either, so clean them up here.
return s.ds.WithTx(func(tx model.DataStore) error {
for _, ep := range episodes {
if ep.StreamID != "" {
if err := tx.MediaFile().Delete(ctx, ep.StreamID); err != nil {
return err
}
}
if err := tx.PodcastPerson().SaveForEpisode(ctx, ep.ID, nil); err != nil {
return err
}
if err := tx.PodcastImage().SaveForEpisode(ctx, ep.ID, nil); err != nil {
return err
}
}
if err := tx.PodcastPerson().SaveForChannel(ctx, id, nil); err != nil {
return err
}
if err := tx.PodcastFunding().SaveForChannel(ctx, id, nil); err != nil {
return err
}
if err := tx.PodcastImage().SaveForChannel(ctx, id, nil); err != nil {
return err
}
if err := tx.PodcastPodroll().SaveForChannel(ctx, id, nil); err != nil {
return err
}
if err := tx.PodcastLiveItem().DeleteByChannel(ctx, id); err != nil {
return err
}
return tx.PodcastChannel().Delete(ctx, id)
})
}
// progressWriter wraps an io.Writer and periodically saves download progress to DB.
type progressWriter struct {
ep *model.PodcastEpisode
ds model.DataStore
broker events.Broker
ctx context.Context
w io.Writer
written int64
lastDB int64
}
const progressUpdateInterval = 512 * 1024 // update DB every 512 KB
func (pw *progressWriter) Write(p []byte) (int, error) {
n, err := pw.w.Write(p)
pw.written += int64(n)
if pw.written-pw.lastDB >= progressUpdateInterval {
pw.ep.DownloadedBytes = pw.written
pw.ep.UpdatedAt = time.Now()
_ = pw.ds.PodcastEpisode().Update(pw.ctx, pw.ep)
pw.lastDB = pw.written
if pw.broker != nil {
pw.broker.SendBroadcastMessage(pw.ctx, &events.PodcastEpisodeProgress{
EpisodeID: pw.ep.ID,
ChannelID: pw.ep.ChannelID,
DownloadedBytes: pw.written,
Size: pw.ep.Size,
})
}
}
return n, err
}
// validateURL rejects any URL that is not a plain http/https request to a
// named host. It exists to prevent SSRF: without it, an authenticated user
// could point the preview/download endpoints at internal services, cloud
// metadata endpoints (e.g. 169.254.169.254), or any other host only
// reachable from the server itself. This is a cheap, fast-failing check on
// the URL's shape - the actual IP-level check happens per-connection in
// safeHTTPTransport below, since the host a URL names and the IP it
// resolves to at request time aren't guaranteed to be the same thing.
func validateURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("parsing URL: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("unsupported URL scheme %q, only http/https are allowed", u.Scheme)
}
if u.Hostname() == "" {
return fmt.Errorf("URL has no host")
}
return nil
}
// isReservedIP reports whether ip is a loopback, private, link-local,
// multicast, or otherwise non-routable/internal address. Cloud metadata
// endpoints (e.g. AWS/GCP/Azure's 169.254.169.254) fall under the
// link-local range, so they're covered without a special case.
//
// This is a var, not a plain func, only so AllowLoopbackHTTPForTests (below)
// can narrow it for test binaries - production code never reassigns it.
var isReservedIP = func(ip net.IP) bool {
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified() ||
inReservedNets(ip)
}
// reservedNets are special-purpose ranges that net.IP's helpers don't cover.
var reservedNets = func() []*net.IPNet {
var nets []*net.IPNet
for _, cidr := range []string{
"0.0.0.0/8", // "this" network
"100.64.0.0/10", // carrier-grade NAT
"192.0.0.0/24", // IETF protocol assignments
"198.18.0.0/15", // benchmarking
"64:ff9b::/96", // NAT64
} {
_, n, err := net.ParseCIDR(cidr)
if err != nil {
panic(err)
}
nets = append(nets, n)
}
return nets
}()
func inReservedNets(ip net.IP) bool {
for _, n := range reservedNets {
if n.Contains(ip) {
return true
}
}
return false
}
// AllowLoopbackHTTPForTests relaxes safeHTTPTransport's SSRF guard to permit
// loopback addresses (127.0.0.0/8, ::1) - every other reserved/private/
// link-local range (including cloud metadata endpoints) is still refused.
// It exists because httptest.Server always binds to loopback, so the podcast
// test suite needs a way to point the service at one without disabling the
// guard entirely. Not for production use.
func AllowLoopbackHTTPForTests() {
strict := isReservedIP
isReservedIP = func(ip net.IP) bool {
if ip.IsLoopback() {
return false
}
return strict(ip)
}
}
// safeHTTPTransport is shared by every outbound podcast HTTP request (RSS
// feed fetch and episode download). Its DialContext resolves the host and
// checks isReservedIP at the moment of connection, not just once via
// validateURL up front - so a DNS answer that changes between the URL
// check and the actual TCP connect (DNS rebinding) can't be used to reach
// a reserved address that validateURL alone would have caught.
var safeHTTPTransport = &http.Transport{
TLSHandshakeTimeout: 15 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("parsing address %q: %w", addr, err)
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolving host %q: %w", host, err)
}
if len(ips) == 0 {
return nil, fmt.Errorf("host %q did not resolve to any address", host)
}
dialer := net.Dialer{Timeout: 15 * time.Second}
var lastErr error
for _, ip := range ips {
if isReservedIP(ip.IP) {
lastErr = fmt.Errorf("host %q resolves to a reserved/internal address (%s), refusing to connect", host, ip.IP)
continue
}
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
},
}
const maxFeedSize = 32 << 20 // 32 MiB
func fetchAndParse(rssURL string) (*rssFeed, error) {
if err := validateURL(rssURL); err != nil {
return nil, fmt.Errorf("invalid RSS feed URL: %w", err)
}
httpClient := &http.Client{Timeout: 15 * time.Second, Transport: safeHTTPTransport}
resp, err := httpClient.Get(rssURL) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("fetching RSS feed: %w", err)
}
defer resp.Body.Close()
// Read at most one byte past the limit so an oversized feed is detected
// without buffering it entirely in memory.
data, err := io.ReadAll(io.LimitReader(resp.Body, maxFeedSize+1))
if err != nil {
return nil, fmt.Errorf("reading RSS feed: %w", err)
}
if len(data) > maxFeedSize {
return nil, fmt.Errorf("RSS feed exceeds %d bytes", maxFeedSize)
}
return ParseRSSFeed(data)
}

View file

@ -0,0 +1,24 @@
package podcasts_test
import (
"testing"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestPodcasts(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
// This suite's specs fetch RSS feeds/episodes from an httptest.Server, which
// always binds to loopback - safeHTTPTransport's SSRF guard would otherwise
// refuse every request the suite makes. See AllowLoopbackHTTPForTests's own
// doc comment: every other reserved/private/link-local address is still
// refused, so this doesn't disable the guard, just narrows it for this run.
podcasts.AllowLoopbackHTTPForTests()
RegisterFailHandler(Fail)
RunSpecs(t, "Podcasts Suite")
}

View file

@ -0,0 +1,585 @@
package podcasts_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PodcastService", func() {
var svc podcasts.Podcasts
var ds *tests.MockDataStore
var channelRepo *tests.MockPodcastChannelRepo
var episodeRepo *tests.MockPodcastEpisodeRepo
var mockServer *httptest.Server
var ctx context.Context
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
channelRepo = tests.CreateMockPodcastChannelRepo()
episodeRepo = tests.CreateMockPodcastEpisodeRepo()
ds = &tests.MockDataStore{
MockedPodcastChannel: channelRepo,
MockedPodcastEpisode: episodeRepo,
}
ctx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true})
mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".mp3") {
w.Header().Set("Content-Type", "audio/mpeg")
w.Write([]byte("fake audio data"))
return
}
w.Header().Set("Content-Type", "application/rss+xml")
fmt.Fprint(w, testRSSFeed)
}))
DeferCleanup(mockServer.Close)
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
svc = podcasts.NewPodcastService(ctx, ds, nil, nil)
})
Describe("AddChannel", func() {
It("creates the channel in DB", func() {
err := svc.AddChannel(ctx, mockServer.URL+"/feed.xml")
Expect(err).ToNot(HaveOccurred())
Expect(channelRepo.Data).To(HaveLen(1))
})
It("creates episodes from the feed", func() {
err := svc.AddChannel(ctx, mockServer.URL+"/feed.xml")
Expect(err).ToNot(HaveOccurred())
Expect(episodeRepo.Data).To(HaveLen(2))
})
It("sets channel title from RSS", func() {
_ = svc.AddChannel(ctx, mockServer.URL+"/feed.xml")
for _, ch := range channelRepo.Data {
Expect(ch.Title).To(Equal("Test Podcast"))
}
})
It("sets channel status to completed", func() {
_ = svc.AddChannel(ctx, mockServer.URL+"/feed.xml")
for _, ch := range channelRepo.Data {
Expect(ch.Status).To(Equal(model.PodcastStatusCompleted))
}
})
It("sets episode status to new", func() {
_ = svc.AddChannel(ctx, mockServer.URL+"/feed.xml")
for _, ep := range episodeRepo.Data {
Expect(ep.Status).To(Equal(model.PodcastStatusNew))
}
})
It("returns error for unreachable URL", func() {
err := svc.AddChannel(ctx, "http://localhost:0/invalid")
Expect(err).To(HaveOccurred())
})
})
Describe("RefreshChannels", func() {
BeforeEach(func() {
channelRepo.Data["ch-1"] = &model.PodcastChannel{
ID: "ch-1",
URL: mockServer.URL + "/feed.xml",
}
})
It("adds new episodes from feed", func() {
err := svc.RefreshChannels(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(episodeRepo.Data).To(HaveLen(2))
})
It("does not duplicate existing episodes", func() {
episodeRepo.Data["ep-existing"] = &model.PodcastEpisode{
ID: "ep-existing",
ChannelID: "ch-1",
GUID: "guid-ep-001",
}
err := svc.RefreshChannels(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(episodeRepo.Data).To(HaveLen(2)) // 1 existing + 1 new
})
})
Describe("DownloadEpisode", func() {
var episode *model.PodcastEpisode
BeforeEach(func() {
channelRepo.Data["ch-1"] = &model.PodcastChannel{
ID: "ch-1",
Title: "Test Channel",
URL: "http://example.com/feed.xml",
}
episode = &model.PodcastEpisode{
ID: "ep-1",
ChannelID: "ch-1",
EnclosureURL: mockServer.URL + "/audio.mp3",
Suffix: "mp3",
Status: model.PodcastStatusNew,
}
episodeRepo.Data[episode.ID] = episode
})
It("immediately sets status to downloading", func() {
_ = svc.DownloadEpisode(ctx, "ep-1")
Expect(episodeRepo.Data["ep-1"].Status).To(Equal(model.PodcastStatusDownloading))
})
It("creates the audio file at the expected path", func() {
_ = svc.DownloadEpisode(ctx, "ep-1")
expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3")
Eventually(func() bool {
_, err := os.Stat(expectedPath)
return err == nil
}, "3s").Should(BeTrue())
})
It("sets status to completed after download", func() {
_ = svc.DownloadEpisode(ctx, "ep-1")
Eventually(func() model.PodcastStatus {
return episodeRepo.Data["ep-1"].Status
}, "3s").Should(Equal(model.PodcastStatusCompleted))
})
It("creates a podcast library rooted at DataFolder/podcasts that the scanner ignores", func() {
_ = svc.DownloadEpisode(ctx, "ep-1")
root := filepath.Join(conf.Server.DataFolder.String(), "podcasts")
Eventually(func() bool {
_, err := os.Stat(filepath.Join(root, ".ndignore"))
return err == nil
}, "3s").Should(BeTrue())
libs, err := ds.Library().GetAll(ctx)
Expect(err).ToNot(HaveOccurred())
var found bool
for _, l := range libs {
if l.Name == "Podcasts" {
found = true
Expect(l.Path).To(Equal(root))
Expect(l.DefaultNewUsers).To(BeTrue())
}
}
Expect(found).To(BeTrue())
})
It("reuses the existing StreamID's MediaFile on re-download", func() {
_ = svc.DownloadEpisode(ctx, "ep-1")
Eventually(func() model.PodcastStatus {
return episodeRepo.Data["ep-1"].Status
}, "3s").Should(Equal(model.PodcastStatusCompleted))
first := episodeRepo.Data["ep-1"].StreamID
Expect(first).ToNot(BeEmpty())
_ = svc.DownloadEpisode(ctx, "ep-1")
Eventually(func() model.PodcastStatus {
return episodeRepo.Data["ep-1"].Status
}, "3s").Should(Equal(model.PodcastStatusCompleted))
Expect(episodeRepo.Data["ep-1"].StreamID).To(Equal(first))
})
It("records the file path after download", func() {
_ = svc.DownloadEpisode(ctx, "ep-1")
expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3")
Eventually(func() string {
return episodeRepo.Data["ep-1"].Path
}, "3s").Should(Equal(expectedPath))
})
})
Describe("DeleteEpisode", func() {
It("resets episode to new status", func() {
episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", ChannelID: "ch-1", Status: model.PodcastStatusCompleted}
err := svc.DeleteEpisode(ctx, "ep-1")
Expect(err).ToNot(HaveOccurred())
Expect(episodeRepo.Data["ep-1"].Status).To(Equal(model.PodcastStatusNew))
})
It("deletes the downloaded file when path is set", func() {
tmpFile := filepath.Join(GinkgoT().TempDir(), "ep.mp3")
Expect(os.WriteFile(tmpFile, []byte("audio"), 0600)).To(Succeed())
episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", Path: tmpFile}
_ = svc.DeleteEpisode(ctx, "ep-1")
_, err := os.Stat(tmpFile)
Expect(os.IsNotExist(err)).To(BeTrue())
})
})
Describe("DeleteChannel", func() {
It("removes channel from DB", func() {
channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1"}
err := svc.DeleteChannel(ctx, "ch-1")
Expect(err).ToNot(HaveOccurred())
Expect(channelRepo.Data).To(BeEmpty())
})
It("deletes all episode files for the channel", func() {
tmpDir := GinkgoT().TempDir()
epFile := filepath.Join(tmpDir, "ep.mp3")
Expect(os.WriteFile(epFile, []byte("audio"), 0600)).To(Succeed())
channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1"}
episodeRepo.Data["ep-1"] = &model.PodcastEpisode{
ID: "ep-1", ChannelID: "ch-1",
Path: epFile,
}
_ = svc.DeleteChannel(ctx, "ch-1")
_, err := os.Stat(epFile)
Expect(os.IsNotExist(err)).To(BeTrue())
})
})
Describe("DownloadEpisode error handling", func() {
BeforeEach(func() {
channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1", Title: "Test Channel"}
})
It("does not leave an empty file behind when the request fails", func() {
episodeRepo.Data["ep-bad"] = &model.PodcastEpisode{
ID: "ep-bad",
ChannelID: "ch-1",
EnclosureURL: "http://localhost:0/no-such.mp3",
Suffix: "mp3",
Status: model.PodcastStatusNew,
}
_ = svc.DownloadEpisode(ctx, "ep-bad")
Eventually(func() model.PodcastStatus {
return episodeRepo.Data["ep-bad"].Status
}, "3s").Should(Equal(model.PodcastStatusError))
dest := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-bad.mp3")
_, err := os.Stat(dest)
Expect(os.IsNotExist(err)).To(BeTrue())
})
It("sets status to error when download fails", func() {
episodeRepo.Data["ep-bad"] = &model.PodcastEpisode{
ID: "ep-bad",
ChannelID: "ch-1",
EnclosureURL: "http://localhost:0/no-such.mp3",
Suffix: "mp3",
Status: model.PodcastStatusNew,
}
_ = svc.DownloadEpisode(ctx, "ep-bad")
Eventually(func() model.PodcastStatus {
return episodeRepo.Data["ep-bad"].Status
}, "3s").Should(Equal(model.PodcastStatusError))
})
It("records error message when download fails", func() {
episodeRepo.Data["ep-bad"] = &model.PodcastEpisode{
ID: "ep-bad",
ChannelID: "ch-1",
EnclosureURL: "http://localhost:0/no-such.mp3",
Suffix: "mp3",
Status: model.PodcastStatusNew,
}
_ = svc.DownloadEpisode(ctx, "ep-bad")
Eventually(func() string {
return episodeRepo.Data["ep-bad"].ErrorMessage
}, "3s").ShouldNot(BeEmpty())
})
})
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("saves funding items to funding repo", func() {
fundingRepo := tests.CreateMockPodcastFundingRepo()
ds.MockedPodcastFunding = fundingRepo
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
Expect(fundingRepo.Data).ToNot(BeEmpty())
var urls []string
for _, f := range fundingRepo.Data {
urls = append(urls, f.URL)
}
Expect(urls).To(ContainElement("https://example.com/donate"))
})
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"}
})
It("sets updated_at after status change", func() {
episodeRepo.Data["ep-ts"] = &model.PodcastEpisode{
ID: "ep-ts",
ChannelID: "ch-1",
EnclosureURL: mockServer.URL + "/audio.mp3",
Suffix: "mp3",
Status: model.PodcastStatusNew,
UpdatedAt: time.Time{},
}
_ = svc.DownloadEpisode(ctx, "ep-ts")
Eventually(func() bool {
return !episodeRepo.Data["ep-ts"].UpdatedAt.IsZero()
}, "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", func() {
var podrollRepo *tests.MockPodcastPodrollRepo
var liveItemRepo *tests.MockPodcastLiveItemRepo
BeforeEach(func() {
podrollRepo = tests.CreateMockPodcastPodrollRepo()
liveItemRepo = tests.CreateMockPodcastLiveItemRepo()
ds.MockedPodcastPodroll = podrollRepo
ds.MockedPodcastLiveItem = liveItemRepo
})
It("still refreshes channels with UsesPodping=true (no Podping listener exists)", func() {
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())
// Podping is only a hint; nothing else refreshes these feeds, so
// new episodes must still be picked up.
Expect(len(episodeRepo.Data)).To(BeNumerically(">", 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())
})
})
})

568
core/podcasts/rss.go Normal file
View file

@ -0,0 +1,568 @@
package podcasts
import (
"encoding/xml"
"fmt"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/model"
)
const podcastNS = "https://podcastindex.org/namespace/1.0"
// Go's encoding/xml has a known bug where a namespace-qualified slice field (e.g.
// `xml:"ns image"`) is not populated when the same struct also has a no-namespace
// field with the same local name (e.g. `xml:"image"`). This affects rssChannel
// because it has both Image rssImage `xml:"image"` and PodcastImages `xml:"ns image"`.
// rssItem has no such conflict, so episode-level podcast:image works via struct tags.
// We use regex fallbacks for both itunes:image and channel-level podcast:image.
var itunesImageRe = regexp.MustCompile(`<itunes:image\b[^>]+href="([^"]*)"`)
// podcastImageElemRe matches a podcast:image element and captures its attributes.
// We intentionally match only the "podcast:" prefix (the de-facto standard) rather
// than any arbitrary prefix, to avoid false matches against itunes:image or rss <image>.
var podcastImageElemRe = regexp.MustCompile(`<podcast:image\b([^>]*)(?:/>|>)`)
var hrefAttrRe = regexp.MustCompile(`\bhref="([^"]*)"`)
var widthAttrRe = regexp.MustCompile(`\bwidth="(\d+)"`)
// extractChannelImages extracts podcast:image elements that appear in the channel
// header (before the first <item> block) using regex, working around the Go xml
// namespace conflict bug.
func extractChannelImages(data []byte) []model.PodcastImage {
// Narrow to channel header to avoid matching episode-level podcast:image elements.
channelStart := strings.Index(string(data), "<channel")
if channelStart < 0 {
return nil
}
header := data[channelStart:]
if itemIdx := strings.Index(string(header), "<item"); itemIdx >= 0 {
header = header[:itemIdx]
}
var images []model.PodcastImage
for _, m := range podcastImageElemRe.FindAllSubmatch(header, -1) {
attrs := string(m[1])
hm := hrefAttrRe.FindStringSubmatch(attrs)
if len(hm) < 2 || hm[1] == "" {
continue
}
img := model.PodcastImage{URL: hm[1]}
if wm := widthAttrRe.FindStringSubmatch(attrs); len(wm) >= 2 {
img.Width, _ = strconv.Atoi(wm[1])
}
images = append(images, img)
}
return images
}
func extractItunesImageHref(data []byte) string {
if m := itunesImageRe.FindSubmatch(data); len(m) > 1 {
return string(m[1])
}
return ""
}
type rssFeed struct {
Title string
Description string
ImageURL string
Episodes []model.PodcastEpisode
// Podcasting 2.0 channel fields
PodcastGUID string
Locked bool
LockedOwner string
Medium string
UpdateFrequency string
UpdateRRule string
Complete bool
Persons []model.PodcastPerson
FundingItems []model.PodcastFundingItem
LocationName string
LocationGeo string
LocationOSM string
License string
PublisherName string
PublisherURL string
Images []model.PodcastImage
// Podcasting 2.0 Tier 3 channel fields
UsesPodping bool
Podroll []model.PodcastPodrollItem
LiveItems []model.PodcastLiveItem
}
type FeedPreview struct {
Title string `json:"title"`
Description string `json:"description"`
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) {
feed, err := fetchAndParse(rssURL)
if err != nil {
return nil, err
}
var fundingURL, fundingText string
if len(feed.FundingItems) > 0 {
fundingURL = feed.FundingItems[0].URL
fundingText = feed.FundingItems[0].Text
}
return &FeedPreview{
Title: feed.Title,
Description: feed.Description,
ImageURL: feed.ImageURL,
EpisodeCount: len(feed.Episodes),
Medium: feed.Medium,
UpdateFrequency: feed.UpdateFrequency,
FundingURL: fundingURL,
FundingText: fundingText,
}, nil
}
// ---- XML struct definitions ----
type rssRoot struct {
Channel rssChannel `xml:"channel"`
}
type rssChannel struct {
Title string `xml:"title"`
Description string `xml:"description"`
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"`
PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"`
PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"`
PodcastPublisher podcastPublisher `xml:"https://podcastindex.org/namespace/1.0 publisher"`
PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"`
// 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 {
URL string `xml:"url"`
}
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"`
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
GUID string `xml:"guid"`
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"`
PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"`
PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"`
PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"`
}
type enclosure struct {
URL string `xml:"url,attr"`
Length int64 `xml:"length,attr"`
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"`
}
type podcastLocation struct {
Geo string `xml:"geo,attr"`
OSM string `xml:"osm,attr"`
Name string `xml:",chardata"`
}
type podcastLicense struct {
URL string `xml:"url,attr"`
Value string `xml:",chardata"`
}
type podcastPublisher struct {
Name string `xml:"https://podcastindex.org/namespace/1.0 name"`
URL string `xml:"https://podcastindex.org/namespace/1.0 url"`
}
type podcastImageTag struct {
Href string `xml:"href,attr"`
Width int `xml:"width,attr"`
}
// ---- Parsing ----
func ParseRSSFeed(data []byte) (*rssFeed, error) {
var root rssRoot
if err := xml.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("parsing RSS feed: %w", err)
}
ch := root.Channel
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>.
if href := extractItunesImageHref(data); href != "" {
feed.ImageURL = href
} else if ch.ItunesImage.Href != "" {
feed.ImageURL = ch.ItunesImage.Href
} else {
feed.ImageURL = ch.Image.URL
}
// all funding entries
for i, f := range ch.PodcastFundings {
feed.FundingItems = append(feed.FundingItems, model.PodcastFundingItem{
URL: f.URL,
Text: strings.TrimSpace(f.Text),
SortOrder: i,
})
}
// location
if ch.PodcastLocation.Name != "" || ch.PodcastLocation.Geo != "" {
feed.LocationName = strings.TrimSpace(ch.PodcastLocation.Name)
feed.LocationGeo = ch.PodcastLocation.Geo
feed.LocationOSM = ch.PodcastLocation.OSM
}
// license
feed.License = strings.TrimSpace(ch.PodcastLicense.Value)
if feed.License == "" {
feed.License = ch.PodcastLicense.URL
}
// publisher
feed.PublisherName = strings.TrimSpace(ch.PodcastPublisher.Name)
feed.PublisherURL = ch.PodcastPublisher.URL
// channel images — use regex fallback due to Go xml namespace conflict with rssImage
feed.Images = extractChannelImages(data)
// 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 == "" {
desc = item.ItunesSummary
}
pubDate, _ := parseRSSDate(item.PubDate)
suffix := suffixFromMIME(item.Enclosure.Type, item.Enclosure.URL)
// <guid> is optional in RSS 2.0; fall back to the enclosure URL so
// episodes keep a unique identity for de-duplication on refresh.
guid := strings.TrimSpace(item.GUID)
if guid == "" {
guid = item.Enclosure.URL
}
ep := model.PodcastEpisode{
GUID: guid,
Title: item.Title,
Description: desc,
PublishDate: pubDate,
EnclosureURL: item.Enclosure.URL,
Size: item.Enclosure.Length,
ContentType: item.Enclosure.Type,
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,
})
}
// episode location
if item.PodcastLocation.Name != "" || item.PodcastLocation.Geo != "" {
ep.LocationName = strings.TrimSpace(item.PodcastLocation.Name)
ep.LocationGeo = item.PodcastLocation.Geo
ep.LocationOSM = item.PodcastLocation.OSM
}
// episode license
ep.License = strings.TrimSpace(item.PodcastLicense.Value)
if ep.License == "" {
ep.License = item.PodcastLicense.URL
}
// episode images
for _, img := range item.PodcastImages {
if img.Href != "" {
ep.Images = append(ep.Images, model.PodcastImage{URL: img.Href, Width: img.Width})
}
}
feed.Episodes = append(feed.Episodes, ep)
}
return feed, nil
}
func parseRSSDate(s string) (time.Time, error) {
formats := []string{
time.RFC1123Z,
time.RFC1123,
"Mon, 2 Jan 2006 15:04:05 -0700",
"Mon, 2 Jan 2006 15:04:05 MST",
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t.UTC(), nil
}
}
return time.Time{}, fmt.Errorf("cannot parse date: %q", s)
}
func parseDuration(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
parts := strings.Split(s, ":")
switch len(parts) {
case 3:
h, _ := strconv.Atoi(parts[0])
m, _ := strconv.Atoi(parts[1])
sec, _ := strconv.Atoi(parts[2])
return h*3600 + m*60 + sec
case 2:
m, _ := strconv.Atoi(parts[0])
sec, _ := strconv.Atoi(parts[1])
return m*60 + sec
default:
sec, _ := strconv.Atoi(s)
return sec
}
}
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",
"audio/mp4": "m4a",
"audio/m4a": "m4a",
"audio/ogg": "ogg",
"audio/opus": "opus",
"audio/flac": "flac",
"audio/x-m4a": "m4a",
}
func suffixFromMIME(mimeType, enclosureURL string) string {
base := strings.Split(mimeType, ";")[0]
base = strings.TrimSpace(strings.ToLower(base))
if s, ok := mimeToSuffix[base]; ok {
return s
}
if u, err := url.Parse(enclosureURL); err == nil {
if ext := path.Ext(u.Path); ext != "" {
return strings.TrimPrefix(ext, ".")
}
}
return ""
}

819
core/podcasts/rss_test.go Normal file
View file

@ -0,0 +1,819 @@
package podcasts_test
import (
"time"
"github.com/navidrome/navidrome/core/podcasts"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const testRSSFeed = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>Test Podcast</title>
<description>A test podcast feed</description>
<itunes:image href="https://example.com/cover.jpg"/>
<item>
<title>Episode 1</title>
<description>First episode description</description>
<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>
</item>
<item>
<title>Episode 2</title>
<description>Second episode</description>
<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"/>
<itunes:duration>3600</itunes:duration>
</item>
</channel>
</rss>`
const testRSSFeedWithImageTag = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Podcast With Image Tag</title>
<description>Uses image tag</description>
<image><url>https://example.com/img.jpg</url></image>
<item>
<title>Ep A</title>
<guid>guid-a</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/a.mp3" length="512" type="audio/mpeg"/>
</item>
</channel>
</rss>`
var _ = Describe("ParseRSSFeed", func() {
Describe("channel metadata", func() {
It("parses title and description", func() {
feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(feed.Title).To(Equal("Test Podcast"))
Expect(feed.Description).To(Equal("A test podcast feed"))
})
It("prefers itunes:image over image tag", func() {
feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(feed.ImageURL).To(Equal("https://example.com/cover.jpg"))
})
It("falls back to image/url when no itunes:image", func() {
feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeedWithImageTag))
Expect(err).ToNot(HaveOccurred())
Expect(feed.ImageURL).To(Equal("https://example.com/img.jpg"))
})
})
Describe("episode list", func() {
It("parses all episodes", func() {
feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(feed.Episodes).To(HaveLen(2))
})
It("falls back to the enclosure URL when <guid> is missing", func() {
feed, err := podcasts.ParseRSSFeed([]byte(`<?xml version="1.0"?><rss version="2.0"><channel><title>T</title>
<item><title>No guid</title><enclosure url="https://example.com/noguid.mp3" length="1" type="audio/mpeg"/></item>
</channel></rss>`))
Expect(err).ToNot(HaveOccurred())
Expect(feed.Episodes).To(HaveLen(1))
Expect(feed.Episodes[0].GUID).To(Equal("https://example.com/noguid.mp3"))
})
It("parses episode fields correctly", func() {
feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed))
ep := feed.Episodes[0]
Expect(ep.GUID).To(Equal("guid-ep-001"))
Expect(ep.Title).To(Equal("Episode 1"))
Expect(ep.Description).To(Equal("First episode description"))
Expect(ep.EnclosureURL).To(Equal("https://example.com/ep1.mp3"))
Expect(ep.Size).To(Equal(int64(1048576)))
Expect(ep.ContentType).To(Equal("audio/mpeg"))
Expect(ep.Suffix).To(Equal("mp3"))
})
It("converts itunes:duration HH:MM:SS to seconds", func() {
feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(feed.Episodes[0].Duration).To(Equal(5025)) // 1*3600 + 23*60 + 45
})
It("converts itunes:duration plain integer to seconds", func() {
feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(feed.Episodes[1].Duration).To(Equal(3600))
})
It("parses pubDate as UTC time", func() {
feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(feed.Episodes[0].PublishDate.Year()).To(Equal(2024))
Expect(feed.Episodes[0].PublishDate.Month()).To(Equal(time.January))
Expect(feed.Episodes[0].PublishDate.Day()).To(Equal(1))
})
})
Describe("error handling", func() {
It("returns error for invalid XML", func() {
_, err := podcasts.ParseRSSFeed([]byte("not valid xml"))
Expect(err).To(HaveOccurred())
})
})
})
// 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.FundingItems).ToNot(BeEmpty())
Expect(result.FundingItems[0].URL).To(Equal("https://example.com/donate"))
Expect(result.FundingItems[0].Text).To(Equal("Support us!"))
})
It("podcast:funding — stores all entries", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
hasSecond := false
for _, f := range result.FundingItems {
if f.URL == "https://example.com/donate2" {
hasSecond = true
}
}
_ = hasSecond
})
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))
})
})
})
// ---- Podcasting 2.0 new metadata tags (location, license, publisher, image) ----
const testRSSFeedLocation = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Location Show</title>
<podcast:location geo="geo:30.2672,97.7431" osm="R113314">Austin, TX</podcast:location>
<item>
<title>Live From Austin</title>
<guid>ep-loc-1</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
<podcast:location geo="geo:51.5074,0.1278" osm="R65606">London, UK</podcast:location>
</item>
<item>
<title>No Location Episode</title>
<guid>ep-loc-2</guid>
<pubDate>Tue, 02 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep2.mp3" length="1024" type="audio/mpeg"/>
</item>
</channel>
</rss>`
const testRSSFeedLicense = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>License Show</title>
<podcast:license url="https://creativecommons.org/licenses/by/4.0/">cc-by-4.0</podcast:license>
<item>
<title>Episode With License</title>
<guid>ep-lic-1</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
<podcast:license url="https://creativecommons.org/licenses/by-nd/4.0/">cc-by-nd-4.0</podcast:license>
</item>
<item>
<title>License URL Only</title>
<guid>ep-lic-2</guid>
<pubDate>Tue, 02 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep2.mp3" length="1024" type="audio/mpeg"/>
<podcast:license url="https://example.com/custom-license"/>
</item>
</channel>
</rss>`
const testRSSFeedPublisher = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Publisher Show</title>
<podcast:publisher>
<podcast:name>Acme Podcast Network</podcast:name>
<podcast:url>https://acme.example.com</podcast:url>
</podcast:publisher>
<item>
<title>Ep</title>
<guid>ep-pub-1</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 testRSSFeedImages = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
<channel>
<title>Images Show</title>
<podcast:image href="https://example.com/img-3000.jpg" width="3000"/>
<podcast:image href="https://example.com/img-1500.jpg" width="1500"/>
<podcast:image href="https://example.com/img-300.jpg" width="300"/>
<item>
<title>Episode With Images</title>
<guid>ep-img-1</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
<podcast:image href="https://example.com/ep-img-600.jpg" width="600"/>
<podcast:image href="https://example.com/ep-img-150.jpg" width="150"/>
</item>
<item>
<title>Episode Without Images</title>
<guid>ep-img-2</guid>
<pubDate>Tue, 02 Jan 2024 00:00:00 +0000</pubDate>
<enclosure url="https://example.com/ep2.mp3" length="1024" type="audio/mpeg"/>
</item>
</channel>
</rss>`
var _ = Describe("ParseRSSFeed — new metadata tags", func() {
Describe("podcast:location", func() {
It("parses geo, osm, and name at channel level", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation))
Expect(err).ToNot(HaveOccurred())
Expect(result.LocationName).To(Equal("Austin, TX"))
Expect(result.LocationGeo).To(Equal("geo:30.2672,97.7431"))
Expect(result.LocationOSM).To(Equal("R113314"))
})
It("parses geo, osm, and name at episode level", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation))
Expect(err).ToNot(HaveOccurred())
ep := result.Episodes[0]
Expect(ep.LocationName).To(Equal("London, UK"))
Expect(ep.LocationGeo).To(Equal("geo:51.5074,0.1278"))
Expect(ep.LocationOSM).To(Equal("R65606"))
})
It("leaves location fields empty when tag is absent", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation))
Expect(err).ToNot(HaveOccurred())
ep := result.Episodes[1]
Expect(ep.LocationName).To(BeEmpty())
Expect(ep.LocationGeo).To(BeEmpty())
Expect(ep.LocationOSM).To(BeEmpty())
})
It("leaves channel location fields empty when tag is absent", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(result.LocationName).To(BeEmpty())
Expect(result.LocationGeo).To(BeEmpty())
})
})
Describe("podcast:license", func() {
It("uses text content as license when present", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense))
Expect(err).ToNot(HaveOccurred())
Expect(result.License).To(Equal("cc-by-4.0"))
})
It("parses license at episode level", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense))
Expect(err).ToNot(HaveOccurred())
Expect(result.Episodes[0].License).To(Equal("cc-by-nd-4.0"))
})
It("falls back to URL attr when text content is empty", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense))
Expect(err).ToNot(HaveOccurred())
Expect(result.Episodes[1].License).To(Equal("https://example.com/custom-license"))
})
It("leaves license empty when tag is absent", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(result.License).To(BeEmpty())
})
})
Describe("podcast:publisher", func() {
It("parses publisher name and URL", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPublisher))
Expect(err).ToNot(HaveOccurred())
Expect(result.PublisherName).To(Equal("Acme Podcast Network"))
Expect(result.PublisherURL).To(Equal("https://acme.example.com"))
})
It("leaves publisher fields empty when tag is absent", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(result.PublisherName).To(BeEmpty())
Expect(result.PublisherURL).To(BeEmpty())
})
})
Describe("podcast:image", func() {
It("parses multiple channel-level images", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages))
Expect(err).ToNot(HaveOccurred())
Expect(result.Images).To(HaveLen(3))
})
It("parses href and width for each channel image", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages))
Expect(err).ToNot(HaveOccurred())
Expect(result.Images[0].URL).To(Equal("https://example.com/img-3000.jpg"))
Expect(result.Images[0].Width).To(Equal(3000))
Expect(result.Images[2].URL).To(Equal("https://example.com/img-300.jpg"))
Expect(result.Images[2].Width).To(Equal(300))
})
It("parses episode-level images", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages))
Expect(err).ToNot(HaveOccurred())
ep := result.Episodes[0]
Expect(ep.Images).To(HaveLen(2))
Expect(ep.Images[0].URL).To(Equal("https://example.com/ep-img-600.jpg"))
Expect(ep.Images[0].Width).To(Equal(600))
})
It("episode without images has empty Images slice", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages))
Expect(err).ToNot(HaveOccurred())
Expect(result.Episodes[1].Images).To(BeEmpty())
})
It("channel without images has empty Images slice", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(result.Images).To(BeEmpty())
})
})
Describe("podcast:funding — all entries", func() {
It("stores all funding entries with correct sort order", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
Expect(err).ToNot(HaveOccurred())
Expect(result.FundingItems).To(HaveLen(2))
Expect(result.FundingItems[0].URL).To(Equal("https://example.com/donate"))
Expect(result.FundingItems[0].Text).To(Equal("Support us!"))
Expect(result.FundingItems[0].SortOrder).To(Equal(0))
Expect(result.FundingItems[1].URL).To(Equal("https://example.com/donate2"))
Expect(result.FundingItems[1].Text).To(Equal("Secondary"))
Expect(result.FundingItems[1].SortOrder).To(Equal(1))
})
It("returns empty FundingItems when tag is absent", func() {
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
Expect(err).ToNot(HaveOccurred())
Expect(result.FundingItems).To(BeEmpty())
})
})
})

View file

@ -10,6 +10,7 @@ import (
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/core/quickconnect"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/stream"
@ -36,4 +37,5 @@ var Set = wire.NewSet(
quickconnect.GetInstance,
metrics.GetInstance,
lyrics.NewLyrics,
podcasts.NewPodcastService,
)

View file

@ -92,7 +92,7 @@ var embeddedIDColumns = []struct {
}
func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
if err := buildIDMap(ctx, tx); err != nil {
if err := buildIDMap(ctx, tx, idColumns); err != nil {
return err
}
for _, tc := range idColumns {
@ -129,7 +129,10 @@ func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error {
}
// buildIDMap stages old->new pairs for every id that changes, indexed for the update joins.
func buildIDMap(ctx context.Context, tx *sql.Tx) error {
// columns is a parameter (not always the package-level idColumns) so a later migration can
// reuse this same collect-and-rewrite machinery for a different, disjoint set of columns - see
// podcast_uniform_canonical_ids.go, which does exactly that for tables idColumns predates.
func buildIDMap(ctx context.Context, tx *sql.Tx, columns []struct{ table, col string }) error {
_, err := tx.ExecContext(ctx,
"CREATE TEMP TABLE _id_map (old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL) WITHOUT ROWID")
if err != nil {
@ -140,7 +143,7 @@ func buildIDMap(ctx context.Context, tx *sql.Tx) error {
return err
}
defer ins.Close()
for _, tc := range idColumns {
for _, tc := range columns {
if err := collectColumn(ctx, tx, ins, tc.table, tc.col); err != nil {
return fmt.Errorf("collecting %s.%s: %w", tc.table, tc.col, err)
}

View file

@ -0,0 +1,71 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
// This file (and the 4 that follow it, add_podcast_downloaded_bytes/podcast20/podcast_tier3/
// podcast_metadata) were originally timestamped 2026-04-27/28, matching when the podcast
// feature was actually developed. They were renumbered to 2026-09-30 (after every migration
// already on master as of this PR) before merging: goose.UpContext (as navidrome calls it, with
// no WithAllowMissing) hard-errors and refuses to start if it finds a pending migration whose
// version is lower than the DB's already-applied max version - which every one of these files
// would have been, for any install that had already migrated past April on current master.
// Keep new migrations timestamped at-or-after merge time, not authoring time.
func init() {
goose.AddMigrationContext(upAddPodcast, downAddPodcast)
}
func upAddPodcast(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
CREATE TABLE podcast_channel (
id VARCHAR(255) PRIMARY KEY,
url VARCHAR(4096) NOT NULL,
title VARCHAR(1024) NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
image_url VARCHAR(4096) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'new',
error_message TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE podcast_episode (
id VARCHAR(255) PRIMARY KEY,
channel_id VARCHAR(255) NOT NULL REFERENCES podcast_channel(id) ON DELETE CASCADE,
stream_id VARCHAR(255) NOT NULL DEFAULT '',
guid VARCHAR(4096) NOT NULL DEFAULT '',
title VARCHAR(1024) NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
publish_date DATETIME,
duration INTEGER NOT NULL DEFAULT 0,
size INTEGER NOT NULL DEFAULT 0,
bit_rate INTEGER NOT NULL DEFAULT 0,
suffix VARCHAR(32) NOT NULL DEFAULT '',
content_type VARCHAR(255) NOT NULL DEFAULT '',
path VARCHAR(4096) NOT NULL DEFAULT '',
enclosure_url VARCHAR(4096) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'new',
error_message TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX podcast_episode_channel_id ON podcast_episode(channel_id);
CREATE INDEX podcast_episode_publish_date ON podcast_episode(publish_date);
`)
return err
}
func downAddPodcast(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `
DROP INDEX IF EXISTS podcast_episode_publish_date;
DROP INDEX IF EXISTS podcast_episode_channel_id;
DROP TABLE IF EXISTS podcast_episode;
DROP TABLE IF EXISTS podcast_channel;
`)
return err
}

View file

@ -0,0 +1,21 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddPodcastDownloadedBytes, downAddPodcastDownloadedBytes)
}
func upAddPodcastDownloadedBytes(ctx context.Context, tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, `ALTER TABLE podcast_episode ADD COLUMN downloaded_bytes INTEGER NOT NULL DEFAULT 0`)
return err
}
func downAddPodcastDownloadedBytes(ctx context.Context, tx *sql.Tx) error {
return nil
}

View file

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

View file

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

View file

@ -0,0 +1,61 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upAddPodcastMetadata, downAddPodcastMetadata)
}
func upAddPodcastMetadata(ctx context.Context, tx *sql.Tx) error {
sqls := []string{
// podcast_channel — location, license, publisher
`ALTER TABLE podcast_channel ADD COLUMN location_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_channel ADD COLUMN location_geo TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_channel ADD COLUMN location_osm TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_channel ADD COLUMN license TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_channel ADD COLUMN publisher_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_channel ADD COLUMN publisher_url TEXT NOT NULL DEFAULT ''`,
// podcast_episode — location, license
`ALTER TABLE podcast_episode ADD COLUMN location_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_episode ADD COLUMN location_geo TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_episode ADD COLUMN location_osm TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE podcast_episode ADD COLUMN license TEXT NOT NULL DEFAULT ''`,
// 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_funding (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
url TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL
)`,
`CREATE INDEX podcast_funding_channel_id ON podcast_funding(channel_id)`,
`CREATE TABLE podcast_image (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL DEFAULT '',
episode_id TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL,
width INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL
)`,
`CREATE INDEX podcast_image_channel_id ON podcast_image(channel_id)`,
`CREATE INDEX podcast_image_episode_id ON podcast_image(episode_id)`,
}
for _, s := range sqls {
if _, err := tx.ExecContext(ctx, s); err != nil {
return err
}
}
return nil
}
func downAddPodcastMetadata(ctx context.Context, tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,57 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upPodcastUniformCanonicalIds, downPodcastUniformCanonicalIds)
}
// podcastIDColumns lists every Navidrome-id-bearing podcast_* column, the same inventory
// uniform_canonical_ids (20260720015443) keeps for every other table - see this file's own
// upPodcastUniformCanonicalIds doc comment for why podcast ids need their own, later migration
// instead of just being added to that one's idColumns.
var podcastIDColumns = []struct{ table, col string }{
{"podcast_channel", "id"},
{"podcast_episode", "id"}, {"podcast_episode", "channel_id"}, {"podcast_episode", "stream_id"},
{"podcast_transcript", "id"}, {"podcast_transcript", "episode_id"},
{"podcast_person", "id"}, {"podcast_person", "channel_id"}, {"podcast_person", "episode_id"},
{"podcast_podroll", "id"}, {"podcast_podroll", "channel_id"},
{"podcast_live_item", "id"}, {"podcast_live_item", "channel_id"},
{"podcast_funding", "id"}, {"podcast_funding", "channel_id"},
{"podcast_image", "id"}, {"podcast_image", "channel_id"}, {"podcast_image", "episode_id"},
}
// upPodcastUniformCanonicalIds rewrites podcast_* ids to the same canonical 22-char base62
// encoding uniform_canonical_ids (20260720015443) already applied to every other table.
//
// It has to be a separate, later migration rather than an addition to that one's idColumns,
// for two independent reasons:
// 1. The podcast_* tables don't exist yet when 20260720015443 runs - the add_podcast* migrations
// that create them are timestamped after it (2026-09-02, see add_podcast.go's own comment on
// why) - so a SELECT against them there would fail outright, even on a fresh install.
// 2. Editing an already-applied migration's Go source has no runtime effect on any install that
// already ran it: goose tracks migrations as applied-or-not by version, not by re-diffing
// their source on every startup. An install that ran 20260720015443 before this feature
// existed would never re-run it, no matter what idColumns says today.
func upPodcastUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
if err := buildIDMap(ctx, tx, podcastIDColumns); err != nil {
return err
}
for _, tc := range podcastIDColumns {
if err := applyIDMap(ctx, tx, tc.table, tc.col); err != nil {
return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err)
}
}
_, err := tx.ExecContext(ctx, "DROP TABLE _id_map")
return err
}
func downPodcastUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
return nil // irreversible data migration
}

View file

@ -37,6 +37,14 @@ type DataStore interface {
Plugin() PluginRepository
Artwork() ArtworkRepository
ArtworkQueue() ArtworkQueueRepository
PodcastChannel() PodcastChannelRepository
PodcastEpisode() PodcastEpisodeRepository
PodcastTranscript() PodcastTranscriptRepository
PodcastPerson() PodcastPersonRepository
PodcastPodroll() PodcastPodrollRepository
PodcastLiveItem() PodcastLiveItemRepository
PodcastFunding() PodcastFundingRepository
PodcastImage() PodcastImageRepository
WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error

249
model/podcast.go Normal file
View file

@ -0,0 +1,249 @@
package model
import (
"context"
"time"
"github.com/deluan/rest"
)
// 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(ctx context.Context, channelID string) (PodcastPodrollItems, error)
GetByChannels(ctx context.Context, channelIDs []string) (PodcastPodrollItems, error)
SaveForChannel(ctx context.Context, channelID string, items []PodcastPodrollItem) error
}
// PodcastLiveItemRepository manages podcast:liveItem entries (one per channel).
type PodcastLiveItemRepository interface {
GetByChannel(ctx context.Context, channelID string) (*PodcastLiveItem, error)
Upsert(ctx context.Context, item *PodcastLiveItem) error
DeleteByChannel(ctx context.Context, channelID string) error
}
type PodcastStatus string
const (
PodcastStatusNew PodcastStatus = "new"
PodcastStatusDownloading PodcastStatus = "downloading"
PodcastStatusCompleted PodcastStatus = "completed"
PodcastStatusError PodcastStatus = "error"
PodcastStatusSkipped PodcastStatus = "skipped"
PodcastStatusDeleted PodcastStatus = "deleted"
)
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"`
// 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"`
LocationName string `structs:"location_name" json:"locationName,omitempty"`
LocationGeo string `structs:"location_geo" json:"locationGeo,omitempty"`
LocationOSM string `structs:"location_osm" json:"locationOsm,omitempty"`
License string `structs:"license" json:"license,omitempty"`
PublisherName string `structs:"publisher_name" json:"publisherName,omitempty"`
PublisherURL string `structs:"publisher_url" json:"publisherUrl,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"`
FundingItems PodcastFundingItems `structs:"-" json:"funding,omitempty"`
Images PodcastImages `structs:"-" json:"images,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"`
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"`
// 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"`
LocationName string `structs:"location_name" json:"locationName,omitempty"`
LocationGeo string `structs:"location_geo" json:"locationGeo,omitempty"`
LocationOSM string `structs:"location_osm" json:"locationOsm,omitempty"`
License string `structs:"license" json:"license,omitempty"`
// loaded separately
Transcripts PodcastTranscripts `structs:"-" json:"transcripts,omitempty"`
Persons PodcastPersons `structs:"-" json:"persons,omitempty"`
Images PodcastImages `structs:"-" json:"images,omitempty"`
}
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 PodcastFundingItem struct {
ID string `structs:"id" json:"id"`
ChannelID string `structs:"channel_id" json:"channelId"`
URL string `structs:"url" json:"url"`
Text string `structs:"text" json:"text,omitempty"`
SortOrder int `structs:"sort_order" json:"sortOrder"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
}
type PodcastImage struct {
ID string `structs:"id" json:"id"`
ChannelID string `structs:"channel_id" json:"channelId,omitempty"`
EpisodeID string `structs:"episode_id" json:"episodeId,omitempty"`
URL string `structs:"url" json:"url"`
Width int `structs:"width" json:"width,omitempty"`
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
}
type PodcastChannels []PodcastChannel
type PodcastEpisodes []PodcastEpisode
type PodcastTranscripts []PodcastTranscript
type PodcastPersons []PodcastPerson
type PodcastFundingItems []PodcastFundingItem
type PodcastImages []PodcastImage
type PodcastChannelRepository interface {
rest.Repository[PodcastChannel]
rest.Persistable[PodcastChannel]
Get(ctx context.Context, id string) (*PodcastChannel, error)
GetAll(ctx context.Context, withEpisodes bool) (PodcastChannels, error)
ExistsByURL(ctx context.Context, url string) (bool, error)
Create(ctx context.Context, channel *PodcastChannel) error
UpdateChannel(ctx context.Context, channel *PodcastChannel) error
Delete(ctx context.Context, ids ...string) error
}
type PodcastEpisodeRepository interface {
Get(ctx context.Context, id string) (*PodcastEpisode, error)
GetNewest(ctx context.Context, count int) (PodcastEpisodes, error)
GetByChannel(ctx context.Context, channelID string) (PodcastEpisodes, error)
GetByChannels(ctx context.Context, channelIDs []string) (PodcastEpisodes, error)
GetByGUID(ctx context.Context, channelID, guid string) (*PodcastEpisode, error)
Create(ctx context.Context, ep *PodcastEpisode) error
Update(ctx context.Context, ep *PodcastEpisode) error
Delete(ctx context.Context, id string) error
}
type PodcastTranscriptRepository interface {
GetByEpisode(ctx context.Context, episodeID string) (PodcastTranscripts, error)
GetByEpisodes(ctx context.Context, episodeIDs []string) (PodcastTranscripts, error)
Save(ctx context.Context, transcripts []PodcastTranscript) error
DeleteByEpisode(ctx context.Context, episodeID string) error
}
type PodcastPersonRepository interface {
GetByChannel(ctx context.Context, channelID string) (PodcastPersons, error)
GetByEpisode(ctx context.Context, episodeID string) (PodcastPersons, error)
GetByEpisodes(ctx context.Context, episodeIDs []string) (PodcastPersons, error)
SaveForChannel(ctx context.Context, channelID string, persons []PodcastPerson) error
SaveForEpisode(ctx context.Context, episodeID string, persons []PodcastPerson) error
}
type PodcastFundingRepository interface {
GetByChannel(ctx context.Context, channelID string) (PodcastFundingItems, error)
GetByChannels(ctx context.Context, channelIDs []string) (PodcastFundingItems, error)
SaveForChannel(ctx context.Context, channelID string, items []PodcastFundingItem) error
}
type PodcastImageRepository interface {
GetByChannel(ctx context.Context, channelID string) (PodcastImages, error)
GetByChannels(ctx context.Context, channelIDs []string) (PodcastImages, error)
GetByEpisode(ctx context.Context, episodeID string) (PodcastImages, error)
GetByEpisodes(ctx context.Context, episodeIDs []string) (PodcastImages, error)
SaveForChannel(ctx context.Context, channelID string, images []PodcastImage) error
SaveForEpisode(ctx context.Context, episodeID string, images []PodcastImage) error
}

View file

@ -15,55 +15,71 @@ import (
)
type SQLStore struct {
db dbx.Builder
library func() model.LibraryRepository
folder func() model.FolderRepository
album func() model.AlbumRepository
artist func() model.ArtistRepository
mediaFile func() model.MediaFileRepository
genre func() model.GenreRepository
tag func() model.TagRepository
playlist func() model.PlaylistRepository
playQueue func() model.PlayQueueRepository
transcoding func() model.TranscodingRepository
player func() model.PlayerRepository
radio func() model.RadioRepository
share func() model.ShareRepository
property func() model.PropertyRepository
user func() model.UserRepository
userProps func() model.UserPropsRepository
scrobbleBuf func() model.ScrobbleBufferRepository
scrobble func() model.ScrobbleRepository
plugin func() model.PluginRepository
artwork func() model.ArtworkRepository
artworkQueue func() model.ArtworkQueueRepository
db dbx.Builder
library func() model.LibraryRepository
folder func() model.FolderRepository
album func() model.AlbumRepository
artist func() model.ArtistRepository
mediaFile func() model.MediaFileRepository
genre func() model.GenreRepository
tag func() model.TagRepository
playlist func() model.PlaylistRepository
playQueue func() model.PlayQueueRepository
transcoding func() model.TranscodingRepository
player func() model.PlayerRepository
radio func() model.RadioRepository
share func() model.ShareRepository
property func() model.PropertyRepository
user func() model.UserRepository
userProps func() model.UserPropsRepository
scrobbleBuf func() model.ScrobbleBufferRepository
scrobble func() model.ScrobbleRepository
plugin func() model.PluginRepository
artwork func() model.ArtworkRepository
artworkQueue func() model.ArtworkQueueRepository
podcastChannel func() model.PodcastChannelRepository
podcastEpisode func() model.PodcastEpisodeRepository
podcastTranscript func() model.PodcastTranscriptRepository
podcastPerson func() model.PodcastPersonRepository
podcastPodroll func() model.PodcastPodrollRepository
podcastLiveItem func() model.PodcastLiveItemRepository
podcastFunding func() model.PodcastFundingRepository
podcastImage func() model.PodcastImageRepository
}
// Repositories are built on first use, so a transaction store only pays for the ones its block touches.
func newSQLStore(db dbx.Builder) *SQLStore {
return &SQLStore{
db: db,
library: sync.OnceValue(func() model.LibraryRepository { return NewLibraryRepository(db) }),
folder: sync.OnceValue(func() model.FolderRepository { return newFolderRepository(db) }),
album: sync.OnceValue(func() model.AlbumRepository { return NewAlbumRepository(db) }),
artist: sync.OnceValue(func() model.ArtistRepository { return NewArtistRepository(db) }),
mediaFile: sync.OnceValue(func() model.MediaFileRepository { return NewMediaFileRepository(db) }),
genre: sync.OnceValue(func() model.GenreRepository { return NewGenreRepository(db) }),
tag: sync.OnceValue(func() model.TagRepository { return NewTagRepository(db) }),
playlist: sync.OnceValue(func() model.PlaylistRepository { return NewPlaylistRepository(db) }),
playQueue: sync.OnceValue(func() model.PlayQueueRepository { return NewPlayQueueRepository(db) }),
transcoding: sync.OnceValue(func() model.TranscodingRepository { return NewTranscodingRepository(db) }),
player: sync.OnceValue(func() model.PlayerRepository { return NewPlayerRepository(db) }),
radio: sync.OnceValue(func() model.RadioRepository { return NewRadioRepository(db) }),
share: sync.OnceValue(func() model.ShareRepository { return NewShareRepository(db) }),
property: sync.OnceValue(func() model.PropertyRepository { return NewPropertyRepository(db) }),
user: sync.OnceValue(func() model.UserRepository { return NewUserRepository(db) }),
userProps: sync.OnceValue(func() model.UserPropsRepository { return NewUserPropsRepository(db) }),
scrobbleBuf: sync.OnceValue(func() model.ScrobbleBufferRepository { return NewScrobbleBufferRepository(db) }),
scrobble: sync.OnceValue(func() model.ScrobbleRepository { return NewScrobbleRepository(db) }),
plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }),
artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }),
artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }),
db: db,
library: sync.OnceValue(func() model.LibraryRepository { return NewLibraryRepository(db) }),
folder: sync.OnceValue(func() model.FolderRepository { return newFolderRepository(db) }),
album: sync.OnceValue(func() model.AlbumRepository { return NewAlbumRepository(db) }),
artist: sync.OnceValue(func() model.ArtistRepository { return NewArtistRepository(db) }),
mediaFile: sync.OnceValue(func() model.MediaFileRepository { return NewMediaFileRepository(db) }),
genre: sync.OnceValue(func() model.GenreRepository { return NewGenreRepository(db) }),
tag: sync.OnceValue(func() model.TagRepository { return NewTagRepository(db) }),
playlist: sync.OnceValue(func() model.PlaylistRepository { return NewPlaylistRepository(db) }),
playQueue: sync.OnceValue(func() model.PlayQueueRepository { return NewPlayQueueRepository(db) }),
transcoding: sync.OnceValue(func() model.TranscodingRepository { return NewTranscodingRepository(db) }),
player: sync.OnceValue(func() model.PlayerRepository { return NewPlayerRepository(db) }),
radio: sync.OnceValue(func() model.RadioRepository { return NewRadioRepository(db) }),
share: sync.OnceValue(func() model.ShareRepository { return NewShareRepository(db) }),
property: sync.OnceValue(func() model.PropertyRepository { return NewPropertyRepository(db) }),
user: sync.OnceValue(func() model.UserRepository { return NewUserRepository(db) }),
userProps: sync.OnceValue(func() model.UserPropsRepository { return NewUserPropsRepository(db) }),
scrobbleBuf: sync.OnceValue(func() model.ScrobbleBufferRepository { return NewScrobbleBufferRepository(db) }),
scrobble: sync.OnceValue(func() model.ScrobbleRepository { return NewScrobbleRepository(db) }),
plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }),
artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }),
artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }),
podcastChannel: sync.OnceValue(func() model.PodcastChannelRepository { return NewPodcastChannelRepository(db) }),
podcastEpisode: sync.OnceValue(func() model.PodcastEpisodeRepository { return NewPodcastEpisodeRepository(db) }),
podcastTranscript: sync.OnceValue(func() model.PodcastTranscriptRepository { return NewPodcastTranscriptRepository(db) }),
podcastPerson: sync.OnceValue(func() model.PodcastPersonRepository { return NewPodcastPersonRepository(db) }),
podcastPodroll: sync.OnceValue(func() model.PodcastPodrollRepository { return NewPodcastPodrollRepository(db) }),
podcastLiveItem: sync.OnceValue(func() model.PodcastLiveItemRepository { return NewPodcastLiveItemRepository(db) }),
podcastFunding: sync.OnceValue(func() model.PodcastFundingRepository { return NewPodcastFundingRepository(db) }),
podcastImage: sync.OnceValue(func() model.PodcastImageRepository { return NewPodcastImageRepository(db) }),
}
}
@ -155,6 +171,38 @@ func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository {
return s.artworkQueue()
}
func (s *SQLStore) PodcastChannel() model.PodcastChannelRepository {
return s.podcastChannel()
}
func (s *SQLStore) PodcastEpisode() model.PodcastEpisodeRepository {
return s.podcastEpisode()
}
func (s *SQLStore) PodcastTranscript() model.PodcastTranscriptRepository {
return s.podcastTranscript()
}
func (s *SQLStore) PodcastPerson() model.PodcastPersonRepository {
return s.podcastPerson()
}
func (s *SQLStore) PodcastPodroll() model.PodcastPodrollRepository {
return s.podcastPodroll()
}
func (s *SQLStore) PodcastLiveItem() model.PodcastLiveItemRepository {
return s.podcastLiveItem()
}
func (s *SQLStore) PodcastFunding() model.PodcastFundingRepository {
return s.podcastFunding()
}
func (s *SQLStore) PodcastImage() model.PodcastImageRepository {
return s.podcastImage()
}
func scopeLabel(scope []string) string {
if len(scope) > 0 {
return scope[0]

View file

@ -145,6 +145,41 @@ var (
testRadios = model.Radios{radioWithoutHomePage, radioWithHomePage}
)
var (
podcastChannel1 = model.PodcastChannel{
ID: "pc-1",
URL: "https://example.com/feed1.xml",
Title: "Test Podcast",
Description: "A test podcast",
Status: model.PodcastStatusCompleted,
}
podcastChannel2 = model.PodcastChannel{
ID: "pc-2",
URL: "https://example.com/feed2.xml",
Title: "Another Podcast",
Status: model.PodcastStatusNew,
}
testPodcastChannels = model.PodcastChannels{podcastChannel1, podcastChannel2}
podcastEpisode1 = model.PodcastEpisode{
ID: "pe-1",
ChannelID: "pc-1",
GUID: "guid-001",
Title: "Episode 1",
Status: model.PodcastStatusCompleted,
PublishDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
}
podcastEpisode2 = model.PodcastEpisode{
ID: "pe-2",
ChannelID: "pc-1",
GUID: "guid-002",
Title: "Episode 2",
Status: model.PodcastStatusNew,
PublishDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
}
testPodcastEpisodes = model.PodcastEpisodes{podcastEpisode1, podcastEpisode2}
)
var (
plsBest model.Playlist
plsCool model.Playlist
@ -274,6 +309,22 @@ var _ = BeforeSuite(func() {
}
}
pcr := NewPodcastChannelRepository(conn)
for i := range testPodcastChannels {
c := testPodcastChannels[i]
if err := pcr.Create(ctx, &c); err != nil {
panic(err)
}
}
per := NewPodcastEpisodeRepository(conn)
for i := range testPodcastEpisodes {
e := testPodcastEpisodes[i]
if err := per.Create(ctx, &e); err != nil {
panic(err)
}
}
plsBest = model.Playlist{
Name: "Best",
Comment: "No Comments",

View file

@ -0,0 +1,141 @@
package persistence
import (
"context"
"errors"
"time"
. "github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/pocketbase/dbx"
)
type podcastChannelRepository struct {
sqlRepository
}
func NewPodcastChannelRepository(db dbx.Builder) model.PodcastChannelRepository {
r := &podcastChannelRepository{}
r.db = db
r.registerModel(&model.PodcastChannel{}, nil)
return r
}
func (r *podcastChannelRepository) isPermitted(ctx context.Context) bool {
return loggedUser(ctx).IsAdmin
}
func (r *podcastChannelRepository) Get(ctx context.Context, chanID string) (*model.PodcastChannel, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"id": chanID})
res := model.PodcastChannel{}
if err := r.queryOne(ctx, sel, &res); err != nil {
return nil, err
}
return &res, nil
}
func (r *podcastChannelRepository) GetAll(ctx context.Context, withEpisodes bool) (model.PodcastChannels, error) {
sel := r.newSelect(ctx).Columns("*").OrderBy("title")
var channels model.PodcastChannels
if err := r.queryAll(ctx, sel, &channels); err != nil {
return nil, err
}
if withEpisodes && len(channels) > 0 {
ids := make([]string, len(channels))
for i, ch := range channels {
ids[i] = ch.ID
}
epRepo := NewPodcastEpisodeRepository(r.db)
allEps, err := epRepo.GetByChannels(ctx, ids)
if err != nil {
return nil, err
}
epsByChannel := make(map[string]model.PodcastEpisodes, len(channels))
for _, ep := range allEps {
epsByChannel[ep.ChannelID] = append(epsByChannel[ep.ChannelID], ep)
}
for i := range channels {
channels[i].Episodes = epsByChannel[channels[i].ID]
}
}
return channels, nil
}
func (r *podcastChannelRepository) ExistsByURL(ctx context.Context, url string) (bool, error) {
sel := r.newSelect(ctx).Columns("count(*)").Where(Eq{"url": url})
count, err := r.count(ctx, sel)
return count > 0, err
}
func (r *podcastChannelRepository) Create(ctx context.Context, channel *model.PodcastChannel) error {
if !r.isPermitted(ctx) {
return rest.ErrPermissionDenied
}
now := time.Now()
channel.CreatedAt = now
channel.UpdatedAt = now
if channel.ID == "" {
channel.ID = id.NewRandom()
}
_, err := r.put(ctx, channel.ID, channel)
return err
}
func (r *podcastChannelRepository) UpdateChannel(ctx context.Context, channel *model.PodcastChannel) error {
if !r.isPermitted(ctx) {
return rest.ErrPermissionDenied
}
channel.UpdatedAt = time.Now()
_, err := r.put(ctx, channel.ID, channel)
return err
}
func (r *podcastChannelRepository) Delete(ctx context.Context, ids ...string) error {
if !r.isPermitted(ctx) {
return rest.ErrPermissionDenied
}
if len(ids) == 0 {
return nil
}
return r.delete(ctx, Eq{"id": ids})
}
func (r *podcastChannelRepository) Read(ctx context.Context, chanID string) (*model.PodcastChannel, error) {
return r.Get(ctx, chanID)
}
func (r *podcastChannelRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.PodcastChannel, error) {
sel := r.newSelect(ctx, r.parseRestOptions(ctx, options...)).Columns("*")
var channels model.PodcastChannels
err := r.queryAll(ctx, sel, &channels)
return channels, err
}
func (r *podcastChannelRepository) Save(ctx context.Context, ch *model.PodcastChannel) (string, error) {
if !r.isPermitted(ctx) {
return "", rest.ErrPermissionDenied
}
err := r.Create(ctx, ch)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return ch.ID, err
}
func (r *podcastChannelRepository) Update(ctx context.Context, id string, entity model.PodcastChannel, cols ...string) error {
ch := &entity
ch.ID = id
if !r.isPermitted(ctx) {
return rest.ErrPermissionDenied
}
return r.UpdateChannel(ctx, ch)
}
func (r *podcastChannelRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) {
sql := r.newSelect(ctx, r.parseRestOptions(ctx, options...))
return r.count(ctx, sql)
}
var _ model.PodcastChannelRepository = (*podcastChannelRepository)(nil)

View file

@ -0,0 +1,135 @@
package persistence
import (
"context"
"github.com/deluan/rest"
"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("PodcastChannelRepository", func() {
var repo model.PodcastChannelRepository
var adminCtx, userCtx context.Context
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
adminCtx = request.WithUser(ctx, adminUser)
userCtx = request.WithUser(ctx, regularUser)
repo = NewPodcastChannelRepository(GetDBXBuilder())
})
Describe("Get", func() {
It("returns an existing channel", func() {
ch, err := repo.Get(adminCtx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(ch.ID).To(Equal("pc-1"))
Expect(ch.Title).To(Equal("Test Podcast"))
})
It("returns ErrNotFound for unknown id", func() {
_, err := repo.Get(adminCtx, "no-such-id")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("GetAll", func() {
It("returns all channels without episodes", func() {
channels, err := repo.GetAll(adminCtx, false)
Expect(err).ToNot(HaveOccurred())
Expect(len(channels)).To(BeNumerically(">=", 2))
for _, ch := range channels {
Expect(ch.Episodes).To(BeEmpty())
}
})
It("returns channels with episodes when withEpisodes=true", func() {
channels, err := repo.GetAll(adminCtx, true)
Expect(err).ToNot(HaveOccurred())
var ch1 *model.PodcastChannel
for i := range channels {
if channels[i].ID == "pc-1" {
ch1 = &channels[i]
break
}
}
Expect(ch1).ToNot(BeNil())
Expect(ch1.Episodes).To(HaveLen(2))
})
})
Describe("Create", func() {
It("creates a new channel and assigns an ID", func() {
ch := &model.PodcastChannel{
URL: "https://new.example.com/feed.xml",
Title: "New Podcast",
Status: model.PodcastStatusNew,
}
err := repo.Create(adminCtx, ch)
Expect(err).ToNot(HaveOccurred())
Expect(ch.ID).ToNot(BeEmpty())
saved, err := repo.Get(adminCtx, ch.ID)
Expect(err).ToNot(HaveOccurred())
Expect(saved.Title).To(Equal("New Podcast"))
// cleanup
_ = repo.Delete(adminCtx, ch.ID)
})
It("denies non-admin users", func() {
err := repo.Create(userCtx, &model.PodcastChannel{URL: "https://x.com/feed.xml"})
Expect(err).To(MatchError(rest.ErrPermissionDenied))
})
})
Describe("Update", func() {
It("updates an existing channel", func() {
ch := &model.PodcastChannel{
URL: "https://update.example.com/feed.xml",
Title: "Before Update",
Status: model.PodcastStatusNew,
}
_ = repo.Create(adminCtx, ch)
ch.Title = "After Update"
err := repo.UpdateChannel(adminCtx, ch)
Expect(err).ToNot(HaveOccurred())
saved, _ := repo.Get(adminCtx, ch.ID)
Expect(saved.Title).To(Equal("After Update"))
// cleanup
_ = repo.Delete(adminCtx, ch.ID)
})
})
Describe("Delete", func() {
It("deletes an existing channel", func() {
ch := &model.PodcastChannel{URL: "https://del.example.com/feed.xml", Status: model.PodcastStatusNew}
_ = repo.Create(adminCtx, ch)
err := repo.Delete(adminCtx, ch.ID)
Expect(err).ToNot(HaveOccurred())
_, err = repo.Get(adminCtx, ch.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("denies non-admin users", func() {
err := repo.Delete(userCtx, "pc-1")
Expect(err).To(MatchError(rest.ErrPermissionDenied))
})
})
Describe("Regular user read access", func() {
It("allows regular users to read channels", func() {
channels, err := repo.GetAll(userCtx, false)
Expect(err).ToNot(HaveOccurred())
Expect(channels).ToNot(BeEmpty())
})
})
})

View file

@ -0,0 +1,87 @@
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 podcastEpisodeRepository struct {
sqlRepository
}
func NewPodcastEpisodeRepository(db dbx.Builder) model.PodcastEpisodeRepository {
r := &podcastEpisodeRepository{}
r.db = db
r.registerModel(&model.PodcastEpisode{}, nil)
return r
}
func (r *podcastEpisodeRepository) Get(ctx context.Context, epID string) (*model.PodcastEpisode, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"id": epID})
res := model.PodcastEpisode{}
if err := r.queryOne(ctx, sel, &res); err != nil {
return nil, err
}
return &res, nil
}
func (r *podcastEpisodeRepository) GetNewest(ctx context.Context, count int) (model.PodcastEpisodes, error) {
sel := r.newSelect(ctx).Columns("*").OrderBy("publish_date DESC").Limit(uint64(count))
var eps model.PodcastEpisodes
err := r.queryAll(ctx, sel, &eps)
return eps, err
}
func (r *podcastEpisodeRepository) GetByChannel(ctx context.Context, channelID string) (model.PodcastEpisodes, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID}).OrderBy("publish_date DESC")
var eps model.PodcastEpisodes
err := r.queryAll(ctx, sel, &eps)
return eps, err
}
func (r *podcastEpisodeRepository) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastEpisodes, error) {
if len(channelIDs) == 0 {
return nil, nil
}
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelIDs}).OrderBy("channel_id, publish_date DESC")
var eps model.PodcastEpisodes
err := r.queryAll(ctx, sel, &eps)
return eps, err
}
func (r *podcastEpisodeRepository) GetByGUID(ctx context.Context, channelID, guid string) (*model.PodcastEpisode, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID, "guid": guid})
res := model.PodcastEpisode{}
if err := r.queryOne(ctx, sel, &res); err != nil {
return nil, err
}
return &res, nil
}
func (r *podcastEpisodeRepository) Create(ctx context.Context, ep *model.PodcastEpisode) error {
now := time.Now()
ep.CreatedAt = now
ep.UpdatedAt = now
if ep.ID == "" {
ep.ID = id.NewRandom()
}
_, err := r.put(ctx, ep.ID, ep)
return err
}
func (r *podcastEpisodeRepository) Update(ctx context.Context, ep *model.PodcastEpisode) error {
ep.UpdatedAt = time.Now()
_, err := r.put(ctx, ep.ID, ep)
return err
}
func (r *podcastEpisodeRepository) Delete(ctx context.Context, epID string) error {
return r.delete(ctx, Eq{"id": epID})
}
var _ model.PodcastEpisodeRepository = (*podcastEpisodeRepository)(nil)

View file

@ -0,0 +1,133 @@
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("PodcastEpisodeRepository", func() {
var ctx context.Context
var repo model.PodcastEpisodeRepository
BeforeEach(func() {
ctx = log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastEpisodeRepository(GetDBXBuilder())
})
Describe("Get", func() {
It("returns an existing episode", func() {
ep, err := repo.Get(ctx, "pe-1")
Expect(err).ToNot(HaveOccurred())
Expect(ep.Title).To(Equal("Episode 1"))
Expect(ep.ChannelID).To(Equal("pc-1"))
})
It("returns ErrNotFound for unknown id", func() {
_, err := repo.Get(ctx, "no-such-id")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("GetNewest", func() {
It("returns episodes ordered by publish_date DESC", func() {
eps, err := repo.GetNewest(ctx, 10)
Expect(err).ToNot(HaveOccurred())
Expect(len(eps)).To(BeNumerically(">=", 2))
Expect(eps[0].PublishDate.After(eps[1].PublishDate)).To(BeTrue())
})
It("respects the count limit", func() {
eps, err := repo.GetNewest(ctx, 1)
Expect(err).ToNot(HaveOccurred())
Expect(eps).To(HaveLen(1))
})
})
Describe("GetByChannel", func() {
It("returns only episodes belonging to the channel", func() {
eps, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(eps).To(HaveLen(2))
for _, ep := range eps {
Expect(ep.ChannelID).To(Equal("pc-1"))
}
})
It("returns empty slice for channel with no episodes", func() {
eps, err := repo.GetByChannel(ctx, "pc-2")
Expect(err).ToNot(HaveOccurred())
Expect(eps).To(BeEmpty())
})
})
Describe("GetByGUID", func() {
It("returns the episode matching channel+guid", func() {
ep, err := repo.GetByGUID(ctx, "pc-1", "guid-001")
Expect(err).ToNot(HaveOccurred())
Expect(ep.Title).To(Equal("Episode 1"))
})
It("returns ErrNotFound for unknown guid", func() {
_, err := repo.GetByGUID(ctx, "pc-1", "no-such-guid")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns ErrNotFound when channel does not match", func() {
_, err := repo.GetByGUID(ctx, "pc-2", "guid-001")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Create and Delete", func() {
It("creates an episode and hard deletes it", func() {
ep := &model.PodcastEpisode{
ChannelID: "pc-1",
GUID: "guid-temp",
Title: "Temp Episode",
Status: model.PodcastStatusNew,
PublishDate: time.Now(),
}
err := repo.Create(ctx, ep)
Expect(err).ToNot(HaveOccurred())
Expect(ep.ID).ToNot(BeEmpty())
err = repo.Delete(ctx, ep.ID)
Expect(err).ToNot(HaveOccurred())
_, err = repo.Get(ctx, ep.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("Update", func() {
It("updates episode fields", func() {
ep := &model.PodcastEpisode{
ChannelID: "pc-1",
GUID: "guid-upd",
Title: "Before",
Status: model.PodcastStatusNew,
PublishDate: time.Now(),
}
_ = repo.Create(ctx, ep)
ep.Status = model.PodcastStatusCompleted
ep.Path = "/podcasts/pc-1/ep.mp3"
err := repo.Update(ctx, ep)
Expect(err).ToNot(HaveOccurred())
saved, _ := repo.Get(ctx, ep.ID)
Expect(saved.Status).To(Equal(model.PodcastStatusCompleted))
Expect(saved.Path).To(Equal("/podcasts/pc-1/ep.mp3"))
// cleanup
_ = repo.Delete(ctx, ep.ID)
})
})
})

View file

@ -0,0 +1,58 @@
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 podcastFundingRepository struct {
sqlRepository
}
func NewPodcastFundingRepository(db dbx.Builder) model.PodcastFundingRepository {
r := &podcastFundingRepository{}
r.db = db
r.tableName = "podcast_funding"
r.registerModel(&model.PodcastFundingItem{}, nil)
return r
}
func (r *podcastFundingRepository) GetByChannel(ctx context.Context, channelID string) (model.PodcastFundingItems, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID})
var result model.PodcastFundingItems
err := r.queryAll(ctx, sel, &result)
return result, err
}
func (r *podcastFundingRepository) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastFundingItems, error) {
if len(channelIDs) == 0 {
return nil, nil
}
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelIDs})
var result model.PodcastFundingItems
err := r.queryAll(ctx, sel, &result)
return result, err
}
func (r *podcastFundingRepository) SaveForChannel(ctx context.Context, channelID string, items []model.PodcastFundingItem) error {
if err := r.delete(ctx, 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].CreatedAt = now
if _, err := r.put(ctx, items[i].ID, &items[i]); err != nil {
return err
}
}
return nil
}
var _ model.PodcastFundingRepository = (*podcastFundingRepository)(nil)

View file

@ -0,0 +1,168 @@
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("PodcastFundingRepository", func() {
var ctx context.Context
var repo model.PodcastFundingRepository
BeforeEach(func() {
ctx = log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastFundingRepository(GetDBXBuilder())
})
Describe("SaveForChannel and GetByChannel", func() {
It("saves and retrieves funding items", func() {
items := []model.PodcastFundingItem{
{URL: "https://patreon.com/show", Text: "Support on Patreon"},
{URL: "https://ko-fi.com/show", Text: "Buy me a coffee"},
}
Expect(repo.SaveForChannel(ctx, "pc-1", items)).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("preserves sort_order in insertion order", func() {
items := []model.PodcastFundingItem{
{URL: "https://first.example.com", Text: "First"},
{URL: "https://second.example.com", Text: "Second"},
{URL: "https://third.example.com", Text: "Third"},
}
Expect(repo.SaveForChannel(ctx, "pc-1", items)).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(3))
Expect(result[0].Text).To(Equal("First"))
Expect(result[1].Text).To(Equal("Second"))
Expect(result[2].Text).To(Equal("Third"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("replaces existing items on re-save", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
{URL: "https://old.example.com", Text: "Old"},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
{URL: "https://new.example.com", Text: "New"},
})).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Text).To(Equal("New"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("clears items when saved with nil slice", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
{URL: "https://example.com", Text: "Something"},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
It("does not affect items of other channels", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
{URL: "https://a.example.com", Text: "Channel A"},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastFundingItem{
{URL: "https://b.example.com", Text: "Channel B"},
})).To(Succeed())
resultA, _ := repo.GetByChannel(ctx, "pc-1")
resultB, _ := repo.GetByChannel(ctx, "pc-2")
Expect(resultA).To(HaveLen(1))
Expect(resultB).To(HaveLen(1))
Expect(resultA[0].Text).To(Equal("Channel A"))
Expect(resultB[0].Text).To(Equal("Channel B"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
})
It("returns empty list for unknown channel", func() {
result, err := repo.GetByChannel(ctx, "no-such-channel")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
It("assigns non-empty ID automatically", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
{URL: "https://example.com", Text: "Test"},
})).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result[0].ID).ToNot(BeEmpty())
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("sets ChannelID on returned items", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
{URL: "https://example.com", Text: "Test"},
})).To(Succeed())
result, _ := repo.GetByChannel(ctx, "pc-1")
Expect(result[0].ChannelID).To(Equal("pc-1"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
})
Describe("GetByChannels — bulk query", func() {
It("returns items for multiple channels", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
{URL: "https://a.example.com", Text: "Feed A"},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastFundingItem{
{URL: "https://b.example.com", Text: "Feed B"},
})).To(Succeed())
result, err := repo.GetByChannels(ctx, []string{"pc-1", "pc-2"})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
texts := []string{result[0].Text, result[1].Text}
Expect(texts).To(ConsistOf("Feed A", "Feed B"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
})
It("returned items carry ChannelID for grouping", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
{URL: "https://a.example.com", Text: "A"},
})).To(Succeed())
result, _ := repo.GetByChannels(ctx, []string{"pc-1"})
Expect(result[0].ChannelID).To(Equal("pc-1"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("returns nil for empty id slice", func() {
result, err := repo.GetByChannels(ctx, []string{})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeNil())
})
})
})

View file

@ -0,0 +1,93 @@
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 podcastImageRepository struct {
sqlRepository
}
func NewPodcastImageRepository(db dbx.Builder) model.PodcastImageRepository {
r := &podcastImageRepository{}
r.db = db
r.tableName = "podcast_image"
r.registerModel(&model.PodcastImage{}, nil)
return r
}
func (r *podcastImageRepository) GetByChannel(ctx context.Context, channelID string) (model.PodcastImages, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID})
var result model.PodcastImages
err := r.queryAll(ctx, sel, &result)
return result, err
}
func (r *podcastImageRepository) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastImages, error) {
if len(channelIDs) == 0 {
return nil, nil
}
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelIDs})
var result model.PodcastImages
err := r.queryAll(ctx, sel, &result)
return result, err
}
func (r *podcastImageRepository) GetByEpisode(ctx context.Context, episodeID string) (model.PodcastImages, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"episode_id": episodeID})
var result model.PodcastImages
err := r.queryAll(ctx, sel, &result)
return result, err
}
func (r *podcastImageRepository) GetByEpisodes(ctx context.Context, episodeIDs []string) (model.PodcastImages, error) {
if len(episodeIDs) == 0 {
return nil, nil
}
sel := r.newSelect(ctx).Columns("*").Where(Eq{"episode_id": episodeIDs})
var result model.PodcastImages
err := r.queryAll(ctx, sel, &result)
return result, err
}
func (r *podcastImageRepository) SaveForChannel(ctx context.Context, channelID string, images []model.PodcastImage) error {
if err := r.delete(ctx, Eq{"channel_id": channelID}); err != nil {
return err
}
now := time.Now()
for i := range images {
images[i].ID = id.NewRandom()
images[i].ChannelID = channelID
images[i].EpisodeID = ""
images[i].CreatedAt = now
if _, err := r.put(ctx, images[i].ID, &images[i]); err != nil {
return err
}
}
return nil
}
func (r *podcastImageRepository) SaveForEpisode(ctx context.Context, episodeID string, images []model.PodcastImage) error {
if err := r.delete(ctx, Eq{"episode_id": episodeID}); err != nil {
return err
}
now := time.Now()
for i := range images {
images[i].ID = id.NewRandom()
images[i].EpisodeID = episodeID
images[i].ChannelID = ""
images[i].CreatedAt = now
if _, err := r.put(ctx, images[i].ID, &images[i]); err != nil {
return err
}
}
return nil
}
var _ model.PodcastImageRepository = (*podcastImageRepository)(nil)

View file

@ -0,0 +1,240 @@
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("PodcastImageRepository", func() {
var ctx context.Context
var repo model.PodcastImageRepository
BeforeEach(func() {
ctx = log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastImageRepository(GetDBXBuilder())
})
Describe("SaveForChannel and GetByChannel", func() {
It("saves and retrieves channel images", func() {
images := []model.PodcastImage{
{URL: "https://example.com/img-3000.jpg", Width: 3000},
{URL: "https://example.com/img-300.jpg", Width: 300},
}
Expect(repo.SaveForChannel(ctx, "pc-1", images)).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("persists URL and Width correctly", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
{URL: "https://example.com/img-1500.jpg", Width: 1500},
})).To(Succeed())
result, _ := repo.GetByChannel(ctx, "pc-1")
Expect(result[0].URL).To(Equal("https://example.com/img-1500.jpg"))
Expect(result[0].Width).To(Equal(1500))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("sets ChannelID and empty EpisodeID on returned items", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
{URL: "https://example.com/img.jpg", Width: 600},
})).To(Succeed())
result, _ := repo.GetByChannel(ctx, "pc-1")
Expect(result[0].ChannelID).To(Equal("pc-1"))
Expect(result[0].EpisodeID).To(BeEmpty())
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("assigns non-empty ID automatically", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
{URL: "https://example.com/img.jpg"},
})).To(Succeed())
result, _ := repo.GetByChannel(ctx, "pc-1")
Expect(result[0].ID).ToNot(BeEmpty())
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("replaces existing images on re-save", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
{URL: "https://example.com/old.jpg", Width: 100},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
{URL: "https://example.com/new.jpg", Width: 200},
})).To(Succeed())
result, _ := repo.GetByChannel(ctx, "pc-1")
Expect(result).To(HaveLen(1))
Expect(result[0].URL).To(Equal("https://example.com/new.jpg"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("clears images when saved with nil slice", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
{URL: "https://example.com/img.jpg"},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
result, _ := repo.GetByChannel(ctx, "pc-1")
Expect(result).To(BeEmpty())
})
It("does not return episode images for channel queries", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
{URL: "https://example.com/channel.jpg", Width: 3000},
})).To(Succeed())
Expect(repo.SaveForEpisode(ctx, "ep-1", []model.PodcastImage{
{URL: "https://example.com/episode.jpg", Width: 600},
})).To(Succeed())
result, _ := repo.GetByChannel(ctx, "pc-1")
Expect(result).To(HaveLen(1))
Expect(result[0].URL).To(Equal("https://example.com/channel.jpg"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
})
It("returns empty list for unknown channel", func() {
result, err := repo.GetByChannel(ctx, "no-such-channel")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Describe("SaveForEpisode and GetByEpisode", func() {
It("saves and retrieves episode images", func() {
images := []model.PodcastImage{
{URL: "https://example.com/ep-600.jpg", Width: 600},
{URL: "https://example.com/ep-150.jpg", Width: 150},
}
Expect(repo.SaveForEpisode(ctx, "ep-1", images)).To(Succeed())
result, err := repo.GetByEpisode(ctx, "ep-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
})
It("sets EpisodeID and empty ChannelID on returned items", func() {
Expect(repo.SaveForEpisode(ctx, "ep-1", []model.PodcastImage{
{URL: "https://example.com/ep.jpg", Width: 600},
})).To(Succeed())
result, _ := repo.GetByEpisode(ctx, "ep-1")
Expect(result[0].EpisodeID).To(Equal("ep-1"))
Expect(result[0].ChannelID).To(BeEmpty())
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
})
It("does not return channel images for episode queries", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
{URL: "https://example.com/channel.jpg", Width: 3000},
})).To(Succeed())
Expect(repo.SaveForEpisode(ctx, "ep-1", []model.PodcastImage{
{URL: "https://example.com/episode.jpg", Width: 600},
})).To(Succeed())
result, _ := repo.GetByEpisode(ctx, "ep-1")
Expect(result).To(HaveLen(1))
Expect(result[0].URL).To(Equal("https://example.com/episode.jpg"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
})
It("returns empty list for unknown episode", func() {
result, err := repo.GetByEpisode(ctx, "no-such-episode")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
})
Describe("GetByChannels — bulk query", func() {
It("returns images for multiple channels", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
{URL: "https://a.example.com/img.jpg", Width: 3000},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastImage{
{URL: "https://b.example.com/img.jpg", Width: 1500},
})).To(Succeed())
result, err := repo.GetByChannels(ctx, []string{"pc-1", "pc-2"})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
})
It("returned items carry ChannelID for grouping", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
{URL: "https://example.com/img.jpg", Width: 600},
})).To(Succeed())
result, _ := repo.GetByChannels(ctx, []string{"pc-1"})
Expect(result[0].ChannelID).To(Equal("pc-1"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("returns nil for empty id slice", func() {
result, err := repo.GetByChannels(ctx, []string{})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeNil())
})
})
Describe("GetByEpisodes — bulk query", func() {
It("returns images for multiple episodes", func() {
Expect(repo.SaveForEpisode(ctx, "ep-1", []model.PodcastImage{
{URL: "https://ep1.example.com/img.jpg", Width: 600},
})).To(Succeed())
Expect(repo.SaveForEpisode(ctx, "ep-2", []model.PodcastImage{
{URL: "https://ep2.example.com/img.jpg", Width: 300},
})).To(Succeed())
result, err := repo.GetByEpisodes(ctx, []string{"ep-1", "ep-2"})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
Expect(repo.SaveForEpisode(ctx, "ep-2", nil)).To(Succeed())
})
It("returned items carry EpisodeID for grouping", func() {
Expect(repo.SaveForEpisode(ctx, "ep-1", []model.PodcastImage{
{URL: "https://example.com/ep.jpg", Width: 600},
})).To(Succeed())
result, _ := repo.GetByEpisodes(ctx, []string{"ep-1"})
Expect(result[0].EpisodeID).To(Equal("ep-1"))
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
})
It("returns nil for empty id slice", func() {
result, err := repo.GetByEpisodes(ctx, []string{})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeNil())
})
})
})

View file

@ -0,0 +1,58 @@
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(db dbx.Builder) model.PodcastLiveItemRepository {
r := &podcastLiveItemRepository{}
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(ctx context.Context, channelID string) (*model.PodcastLiveItem, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID})
item := model.PodcastLiveItem{}
if err := r.queryOne(ctx, sel, &item); err != nil {
return nil, err
}
return &item, nil
}
func (r *podcastLiveItemRepository) Upsert(ctx context.Context, item *model.PodcastLiveItem) error {
existing, err := r.GetByChannel(ctx, item.ChannelID)
if err == model.ErrNotFound {
item.ID = id.NewRandom()
item.CreatedAt = time.Now()
item.UpdatedAt = time.Now()
_, err = r.put(ctx, 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(ctx, item.ID, item)
return err
}
func (r *podcastLiveItemRepository) DeleteByChannel(ctx context.Context, channelID string) error {
return r.delete(ctx, Eq{"channel_id": channelID})
}
var _ model.PodcastLiveItemRepository = (*podcastLiveItemRepository)(nil)

View file

@ -0,0 +1,151 @@
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 ctx context.Context
var repo model.PodcastLiveItemRepository
BeforeEach(func() {
ctx = log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastLiveItemRepository(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(ctx, item)).To(Succeed())
result, err := repo.GetByChannel(ctx, "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(ctx, "pc-1")).To(Succeed())
})
It("assigns ID and timestamps automatically on create", func() {
item := &model.PodcastLiveItem{
ChannelID: "pc-1",
Status: "live",
}
Expect(repo.Upsert(ctx, item)).To(Succeed())
result, err := repo.GetByChannel(ctx, "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(ctx, "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(ctx, first)).To(Succeed())
second := &model.PodcastLiveItem{
ChannelID: "pc-1",
GUID: "live-guid-001",
Title: "Updated Title",
Status: "live",
}
Expect(repo.Upsert(ctx, second)).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result.Title).To(Equal("Updated Title"))
Expect(result.Status).To(Equal("live"))
Expect(repo.DeleteByChannel(ctx, "pc-1")).To(Succeed())
})
It("preserves created_at on update", func() {
item := &model.PodcastLiveItem{
ChannelID: "pc-1",
Status: "pending",
}
Expect(repo.Upsert(ctx, item)).To(Succeed())
original, _ := repo.GetByChannel(ctx, "pc-1")
originalCreatedAt := original.CreatedAt
item2 := &model.PodcastLiveItem{
ChannelID: "pc-1",
Status: "live",
}
Expect(repo.Upsert(ctx, item2)).To(Succeed())
updated, _ := repo.GetByChannel(ctx, "pc-1")
Expect(updated.CreatedAt.UTC().Truncate(time.Second)).
To(Equal(originalCreatedAt.UTC().Truncate(time.Second)))
Expect(repo.DeleteByChannel(ctx, "pc-1")).To(Succeed())
})
It("returns ErrNotFound for unknown channel", func() {
_, err := repo.GetByChannel(ctx, "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(ctx, item)).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
Expect(repo.DeleteByChannel(ctx, "pc-1")).To(Succeed())
})
})
Describe("DeleteByChannel", func() {
It("removes live item for the given channel", func() {
Expect(repo.Upsert(ctx, &model.PodcastLiveItem{ChannelID: "pc-1", Status: "live"})).To(Succeed())
Expect(repo.DeleteByChannel(ctx, "pc-1")).To(Succeed())
_, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).To(Equal(model.ErrNotFound))
})
It("does not error when no item exists", func() {
Expect(repo.DeleteByChannel(ctx, "no-such-channel")).To(Succeed())
})
})
})

View file

@ -0,0 +1,82 @@
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(db dbx.Builder) model.PodcastPersonRepository {
r := &podcastPersonRepository{}
r.db = db
r.registerModel(&model.PodcastPerson{}, nil)
return r
}
func (r *podcastPersonRepository) GetByChannel(ctx context.Context, channelID string) (model.PodcastPersons, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID})
var result model.PodcastPersons
err := r.queryAll(ctx, sel, &result)
return result, err
}
func (r *podcastPersonRepository) GetByEpisode(ctx context.Context, episodeID string) (model.PodcastPersons, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"episode_id": episodeID})
var result model.PodcastPersons
err := r.queryAll(ctx, sel, &result)
return result, err
}
func (r *podcastPersonRepository) GetByEpisodes(ctx context.Context, episodeIDs []string) (model.PodcastPersons, error) {
if len(episodeIDs) == 0 {
return nil, nil
}
sel := r.newSelect(ctx).Columns("*").Where(Eq{"episode_id": episodeIDs})
var result model.PodcastPersons
err := r.queryAll(ctx, sel, &result)
return result, err
}
func (r *podcastPersonRepository) SaveForChannel(ctx context.Context, channelID string, persons []model.PodcastPerson) error {
if err := r.delete(ctx, 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(ctx, persons[i].ID, &persons[i]); err != nil {
return err
}
}
return nil
}
func (r *podcastPersonRepository) SaveForEpisode(ctx context.Context, episodeID string, persons []model.PodcastPerson) error {
if err := r.delete(ctx, 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(ctx, persons[i].ID, &persons[i]); err != nil {
return err
}
}
return nil
}
var _ model.PodcastPersonRepository = (*podcastPersonRepository)(nil)

View file

@ -0,0 +1,173 @@
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 ctx context.Context
var repo model.PodcastPersonRepository
BeforeEach(func() {
ctx = log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastPersonRepository(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(ctx, "pc-1", persons)).To(Succeed())
result, err := repo.GetByChannel(ctx, "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(ctx, "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(ctx, "pc-1", persons)).To(Succeed())
result, err := repo.GetByChannel(ctx, "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(ctx, "pc-1", nil)).To(Succeed())
})
It("replaces existing persons on re-save", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPerson{{Name: "Old Host", Role: "host", Group: "cast"}})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPerson{{Name: "New Host", Role: "host", Group: "cast"}})).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Name).To(Equal("New Host"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("clears persons when saved with nil slice", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPerson{{Name: "Jane Host", Role: "host", Group: "cast"}})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
It("does not affect persons of other channels", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPerson{{Name: "Host A", Role: "host", Group: "cast"}})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastPerson{{Name: "Host B", Role: "host", Group: "cast"}})).To(Succeed())
resultA, _ := repo.GetByChannel(ctx, "pc-1")
resultB, _ := repo.GetByChannel(ctx, "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(ctx, "pc-1", nil)).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
})
It("returns empty list for unknown channel", func() {
result, err := repo.GetByChannel(ctx, "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(ctx, "pe-1", persons)).To(Succeed())
result, err := repo.GetByEpisode(ctx, "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(ctx, "pe-1", nil)).To(Succeed())
})
It("replaces existing episode persons on re-save", func() {
Expect(repo.SaveForEpisode(ctx, "pe-1", []model.PodcastPerson{{Name: "Old Guest", Role: "guest", Group: "cast"}})).To(Succeed())
Expect(repo.SaveForEpisode(ctx, "pe-1", []model.PodcastPerson{{Name: "New Guest", Role: "guest", Group: "cast"}})).To(Succeed())
result, err := repo.GetByEpisode(ctx, "pe-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Name).To(Equal("New Guest"))
Expect(repo.SaveForEpisode(ctx, "pe-1", nil)).To(Succeed())
})
It("returns empty list for unknown episode", func() {
result, err := repo.GetByEpisode(ctx, "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(ctx, "pe-1", []model.PodcastPerson{{Name: "Guest A", Role: "guest", Group: "cast"}})).To(Succeed())
Expect(repo.SaveForEpisode(ctx, "pe-2", []model.PodcastPerson{{Name: "Guest B", Role: "guest", Group: "cast"}})).To(Succeed())
result, err := repo.GetByEpisodes(ctx, []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(ctx, "pe-1", nil)).To(Succeed())
Expect(repo.SaveForEpisode(ctx, "pe-2", nil)).To(Succeed())
})
It("returns empty list for empty id slice", func() {
result, err := repo.GetByEpisodes(ctx, []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(ctx, "pc-1", []model.PodcastPerson{{Name: "Jane", Role: "host", Group: "cast"}})).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result[0].ID).ToNot(BeEmpty())
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
})
})

View file

@ -0,0 +1,58 @@
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(db dbx.Builder) model.PodcastPodrollRepository {
r := &podcastPodrollRepository{}
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(ctx context.Context, channelID string) (model.PodcastPodrollItems, error) {
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID}).OrderBy("sort_order")
var result model.PodcastPodrollItems
return result, r.queryAll(ctx, sel, &result)
}
func (r *podcastPodrollRepository) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastPodrollItems, error) {
if len(channelIDs) == 0 {
return nil, nil
}
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelIDs}).OrderBy("channel_id, sort_order")
var result model.PodcastPodrollItems
return result, r.queryAll(ctx, sel, &result)
}
func (r *podcastPodrollRepository) SaveForChannel(ctx context.Context, channelID string, items []model.PodcastPodrollItem) error {
if err := r.delete(ctx, 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(ctx, items[i].ID, &items[i]); err != nil {
return err
}
}
return nil
}
var _ model.PodcastPodrollRepository = (*podcastPodrollRepository)(nil)

View file

@ -0,0 +1,146 @@
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 ctx context.Context
var repo model.PodcastPodrollRepository
BeforeEach(func() {
ctx = log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastPodrollRepository(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(ctx, "pc-1", items)).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(2))
Expect(repo.SaveForChannel(ctx, "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(ctx, "pc-1", items)).To(Succeed())
result, err := repo.GetByChannel(ctx, "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(ctx, "pc-1", nil)).To(Succeed())
})
It("replaces existing items on re-save", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://old.example.com/feed.xml", Title: "Old Show"},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://new.example.com/feed.xml", Title: "New Show"},
})).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result[0].Title).To(Equal("New Show"))
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
It("clears items when saved with nil slice", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://example.com/feed.xml", Title: "Some Show"},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
It("does not affect items of other channels", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://a.example.com/feed.xml", Title: "Channel A Feed"},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastPodrollItem{
{FeedURL: "https://b.example.com/feed.xml", Title: "Channel B Feed"},
})).To(Succeed())
resultA, _ := repo.GetByChannel(ctx, "pc-1")
resultB, _ := repo.GetByChannel(ctx, "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(ctx, "pc-1", nil)).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
})
It("returns empty list for unknown channel", func() {
result, err := repo.GetByChannel(ctx, "no-such-channel")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeEmpty())
})
It("assigns ID automatically", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://example.com/feed.xml"},
})).To(Succeed())
result, err := repo.GetByChannel(ctx, "pc-1")
Expect(err).ToNot(HaveOccurred())
Expect(result[0].ID).ToNot(BeEmpty())
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
})
})
Describe("GetByChannels — bulk query", func() {
It("returns items for multiple channels", func() {
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
{FeedURL: "https://a.example.com/feed.xml", Title: "Feed A"},
})).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastPodrollItem{
{FeedURL: "https://b.example.com/feed.xml", Title: "Feed B"},
})).To(Succeed())
result, err := repo.GetByChannels(ctx, []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(ctx, "pc-1", nil)).To(Succeed())
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
})
It("returns nil for empty id slice", func() {
result, err := repo.GetByChannels(ctx, []string{})
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeNil())
})
})
})

View file

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

View file

@ -0,0 +1,145 @@
package persistence
import (
"context"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("PodcastTranscriptRepository", func() {
var ctx context.Context
var repo model.PodcastTranscriptRepository
BeforeEach(func() {
ctx = log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastTranscriptRepository(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(ctx, transcripts)).To(Succeed())
result, err := repo.GetByEpisode(ctx, "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(ctx, "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(ctx, transcripts)).To(Succeed())
result, err := repo.GetByEpisode(ctx, "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(ctx, "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(ctx, transcripts)).To(Succeed())
result, err := repo.GetByEpisode(ctx, "pe-1")
Expect(err).ToNot(HaveOccurred())
Expect(result[0].Rel).To(BeEmpty())
Expect(repo.DeleteByEpisode(ctx, "pe-1")).To(Succeed())
})
It("returns empty list for unknown episode", func() {
result, err := repo.GetByEpisode(ctx, "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(ctx, transcripts)).To(Succeed())
result, err := repo.GetByEpisodes(ctx, []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(ctx, "pe-1")).To(Succeed())
Expect(repo.DeleteByEpisode(ctx, "pe-2")).To(Succeed())
})
It("returns empty list for empty id slice", func() {
result, err := repo.GetByEpisodes(ctx, []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(ctx, transcripts)).To(Succeed())
Expect(repo.DeleteByEpisode(ctx, "pe-1")).To(Succeed())
result1, _ := repo.GetByEpisode(ctx, "pe-1")
Expect(result1).To(BeEmpty())
result2, _ := repo.GetByEpisode(ctx, "pe-2")
Expect(result2).To(HaveLen(1))
Expect(repo.DeleteByEpisode(ctx, "pe-2")).To(Succeed())
})
It("succeeds when deleting transcripts for a non-existent episode", func() {
Expect(repo.DeleteByEpisode(ctx, "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(ctx, transcripts)).To(Succeed())
result, err := repo.GetByEpisode(ctx, "pe-1")
Expect(err).ToNot(HaveOccurred())
Expect(result[0].ID).ToNot(BeEmpty())
Expect(repo.DeleteByEpisode(ctx, "pe-1")).To(Succeed())
})
})
})

View file

@ -36,7 +36,11 @@
"tags": "추가 태그",
"mappedTags": "매핑된 태그",
"rawTags": "원시 태그",
"missing": "누락"
"missing": "누락",
"composer": "작곡가",
"albumGain": "앨범 게인",
"trackGain": "트랙 게인",
"disc": "디스크 %{discNumber}"
},
"actions": {
"addToQueue": "나중에 재생",
@ -45,7 +49,9 @@
"shuffleAll": "모든 노래 셔플",
"download": "다운로드",
"playNext": "다음 재생",
"info": "정보 얻기"
"info": "정보 얻기",
"showInPlaylist": "재생목록에서 보기",
"instantMix": "인스턴트 믹스"
}
},
"album": {
@ -131,7 +137,8 @@
"actions": {
"topSongs": "인기곡",
"shuffle": "셔플",
"radio": "라디오"
"radio": "라디오",
"podcast": "팟캐스트"
}
},
"user": {
@ -237,6 +244,43 @@
"playNow": "지금 재생"
}
},
"podcast": {
"name": "팟캐스트 |||| 팟캐스트",
"fields": {
"title": "제목",
"url": "피드 URL",
"description": "설명",
"status": "상태",
"publishDate": "게시됨",
"duration": "시간",
"episodeCount": "에피소드"
},
"actions": {
"refresh": "전체 갱신",
"download": "다운로드",
"addChannel": "채널 추가",
"fetchFeed": "피드 정보 가져오기",
"copyUrl": "피드 URL 복사"
},
"notifications": {
"refreshStarted": "팟캐스트 갱신 시작됨",
"downloadStarted": "다운로드 시작됨",
"channelAdded": "채널 추가됨",
"urlCopied": "피드 URL이 클립보드에 복사됨",
"urlCopyFailed": "피드 URL을 복사할 수 없습니다",
"alreadyExists": "이미 등록된 채널입니다.",
"channelDeleted": "채널 삭제됨",
"episodeDeleted": "에피소드 삭제됨"
},
"status": {
"new": "새 항목",
"downloading": "다운로드 중",
"completed": "다운로드됨",
"error": "오류",
"skipped": "건너뜀",
"deleted": "삭제됨"
}
},
"share": {
"name": "공유 |||| 공유되는 것들",
"fields": {
@ -301,15 +345,20 @@
},
"actions": {
"scan": "라이브러리 스캔",
"manageUsers": "자용자 접속 관리",
"quickScan": "빠른 스캔",
"fullScan": "전체 스캔",
"manageUsers": "사용자 접속 관리",
"viewDetails": "상세 보기"
},
"notifications": {
"created": "라이브러리가 성공적으로 생성됨",
"updated": "라이브러리가 성공적으로 업데이트됨",
"deleted": "라이브러리가 성공적으로 삭제됨",
"scanStarted": "라이브러리 스캔 스작됨",
"scanCompleted": "라이브러리 스캔 완료됨"
"scanStarted": "라이브러리 스캔 시작됨",
"scanCompleted": "라이브러리 스캔 완료됨",
"quickScanStarted": "빠른 스캔 시작됨",
"fullScanStarted": "전체 스캔 시작됨",
"scanError": "스캔 시작 오류. 로그를 확인하세요"
},
"validation": {
"nameRequired": "라이브러리 이름이 필요함",
@ -324,6 +373,82 @@
"scanInProgress": "스캔 진행 중...",
"noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음"
}
},
"plugin": {
"name": "플러그인 |||| 플러그인들",
"fields": {
"id": "ID",
"name": "이름",
"description": "설명",
"version": "버전",
"author": "작성자",
"website": "웹사이트",
"permissions": "권한",
"enabled": "활성화",
"status": "상태",
"path": "경로",
"lastError": "오류",
"hasError": "오류",
"updatedAt": "업데이트됨",
"createdAt": "설치됨",
"configKey": "키",
"configValue": "값",
"allUsers": "모든 사용자 허용",
"selectedUsers": "선택된 사용자",
"allLibraries": "모든 라이브러리 허용",
"selectedLibraries": "선택된 라이브러리",
"allowWriteAccess": "쓰기 접근 허용"
},
"sections": {
"status": "상태",
"info": "플러그인 정보",
"configuration": "구성",
"manifest": "매니페스트",
"usersPermission": "사용자 권한",
"libraryPermission": "라이브러리 권한"
},
"status": {
"enabled": "활성화",
"disabled": "비활성화"
},
"actions": {
"enable": "활성화",
"disable": "비활성화",
"disabledDueToError": "활성화하기 전에 오류를 수정하세요",
"disabledUsersRequired": "활성화하기 전에 사용자를 선택하세요",
"disabledLibrariesRequired": "활성화하기 전에 라이브러리를 선택하세요",
"addConfig": "구성 추가",
"rescan": "재스캔"
},
"notifications": {
"enabled": "플러그인 활성화됨",
"disabled": "플러그인 비활성화됨",
"updated": "플러그인 업데이트됨",
"error": "플러그인 업데이트 오류"
},
"validation": {
"invalidJson": "구성은 유효한 JSON이어야 함"
},
"messages": {
"configHelp": "키-값 쌍을 사용하여 플러그인을 구성하세요. 구성이 필요 없는 경우 비워두세요.",
"configValidationError": "구성 유효성 검사 실패:",
"schemaRenderError": "구성 양식을 렌더링할 수 없습니다. 플러그인의 스키마가 유효하지 않을 수 있습니다.",
"clickPermissions": "권한을 클릭하면 자세한 내용을 볼 수 있음",
"noConfig": "설정된 구성 없음",
"allUsersHelp": "활성화하면 플러그인이 미래에 생성될 사용자를 포함한 모든 사용자에 접근할 수 있습니다.",
"noUsers": "선택된 사용자 없음",
"permissionReason": "이유",
"usersRequired": "이 플러그인은 사용자 정보에 접근해야 합니다. 플러그인이 접근할 수 있는 사용자를 선택하거나 '모든 사용자 허용'을 활성화하세요.",
"allLibrariesHelp": "활성화하면 플러그인이 미래에 생성될 라이브러리를 포함한 모든 라이브러리에 접근할 수 있습니다.",
"noLibraries": "선택된 라이브러리 없음",
"librariesRequired": "이 플러그인은 라이브러리 정보에 접근해야 합니다. 플러그인이 접근할 수 있는 라이브러리를 선택하거나 '모든 라이브러리 허용'을 활성화하세요.",
"allowWriteAccessHelp": "활성화하면 플러그인이 라이브러리 디렉터리의 파일을 수정할 수 있습니다. 기본적으로 플러그인은 읽기 전용 접근 권한을 가집니다.",
"requiredHosts": "필수 호스트"
},
"placeholders": {
"configKey": "키",
"configValue": "값"
}
}
},
"ra": {
@ -507,7 +632,14 @@
"shareSuccess": "URL이 클립보드에 복사되었음: %{url}",
"shareFailure": "URL %{url}을 클립보드에 복사하는 중 오류가 발생하였음",
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
"downloadOriginalFormat": "오리지널 형식으로 다운로드"
"downloadOriginalFormat": "오리지널 형식으로 다운로드",
"uploadCover": "커버 업로드",
"removeCover": "커버 제거",
"coverUploaded": "커버 아트 업데이트됨",
"coverRemoved": "커버 아트 제거됨",
"coverUploadError": "커버 아트 업로드 오류",
"coverRemoveError": "커버 아트 제거 오류",
"startingInstantMix": "인스턴트 믹스 로딩 중..."
},
"menu": {
"library": "라이브러리",
@ -590,6 +722,7 @@
"currentValue": "현재 값",
"configurationFile": "구성 파일",
"exportToml": "구성 내보내기 (TOML)",
"downloadToml": "구성 다운로드 (TOML)",
"exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성",
"exportFailed": "구성 복사 실패",
"devFlagsHeader": "개발 플래그 (변경/삭제 가능)",
@ -604,6 +737,7 @@
"serverUptime": "서버 가동 시간",
"serverDown": "오프라인",
"scanType": "유형",
"selectiveScan": "선택적",
"status": "스캔 오류",
"elapsedTime": "경과 시간"
},

View file

@ -68,6 +68,16 @@ type NowPlayingCount struct {
Count int `json:"count"`
}
type PodcastEpisodeProgress struct {
baseEvent
EpisodeID string `json:"episodeId"`
ChannelID string `json:"channelId"`
DownloadedBytes int64 `json:"downloadedBytes"`
Size int64 `json:"size"`
Duration int `json:"duration,omitempty"`
Status string `json:"status,omitempty"`
}
func (rr *RefreshResource) With(resource string, ids ...string) *RefreshResource {
if rr.resources == nil {
rr.resources = make(map[string][]string)

View file

@ -75,6 +75,7 @@ func (api *Router) routes() http.Handler {
rx(r, "/player", api.ds.Player(), true)
rx(r, "/transcoding", api.ds.Transcoding(), conf.Server.EnableTranscodingConfig)
api.addRadioRoute(r)
api.addPodcastRoute(r)
rx(r, "/tag", api.ds.Tag(), false)
rx(r, "/scrobble", api.ds.Scrobble(), false)
if conf.Server.EnableSharing {

View file

@ -0,0 +1,41 @@
package nativeapi
import (
"encoding/json"
"net/http"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/server"
)
func (api *Router) addPodcastRoute(r chi.Router) {
repo := api.ds.PodcastChannel()
r.Route("/podcast", func(r chi.Router) {
r.Get("/", rest.GetAll(repo))
r.With(adminOnlyMiddleware).Get("/preview", api.podcastPreview)
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(repo))
r.Delete("/", rest.Delete(repo))
})
})
}
func (api *Router) podcastPreview(w http.ResponseWriter, r *http.Request) {
feedURL := r.URL.Query().Get("url")
if feedURL == "" {
http.Error(w, "url is required", http.StatusBadRequest)
return
}
feed, err := podcasts.ParseFeedPreview(feedURL)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
exists, _ := api.ds.PodcastChannel().ExistsByURL(r.Context(), feedURL)
feed.AlreadyExists = exists
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(feed)
}

View file

@ -25,7 +25,7 @@ var _ = Describe("Album Lists", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
mockRepo = ds.Album().(*tests.MockAlbumRepo)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
w = httptest.NewRecorder()
})

View file

@ -17,6 +17,7 @@ import (
lyricssvc "github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
podcastsvc "github.com/navidrome/navidrome/core/podcasts"
playlistsvc "github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
sonicsvc "github.com/navidrome/navidrome/core/sonic"
@ -53,6 +54,7 @@ type Router struct {
metrics metrics.Metrics
lyrics lyricssvc.Lyrics
transcodeDecision stream.TranscodeDecider
podcasts podcastsvc.Podcasts
sonic *sonicsvc.Sonic
}
@ -60,6 +62,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider,
podcasts podcastsvc.Podcasts,
sonic *sonicsvc.Sonic,
) *Router {
r := &Router{
@ -78,6 +81,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea
metrics: metrics,
lyrics: lyrics,
transcodeDecision: transcodeDecision,
podcasts: podcasts,
sonic: sonic,
}
r.Handler = r.routes()
@ -226,9 +230,17 @@ func (api *Router) routes() http.Handler {
h501(r, "jukeboxControl")
}
// Not Implemented (yet?)
h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel",
"deletePodcastEpisode", "downloadPodcastEpisode")
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
h(r, "getPodcasts", api.GetPodcasts)
h(r, "getNewestPodcasts", api.GetNewestPodcasts)
h(r, "createPodcastChannel", api.CreatePodcastChannel)
h(r, "refreshPodcasts", api.RefreshPodcasts)
h(r, "deletePodcastChannel", api.DeletePodcastChannel)
h(r, "deletePodcastEpisode", api.DeletePodcastEpisode)
h(r, "downloadPodcastEpisode", api.DownloadPodcastEpisode)
h(r, "getPodcastEpisode", api.GetPodcastEpisode)
})
h501(r, "createUser", "updateUser", "deleteUser", "changePassword")
// Deprecated/Won't implement/Out of scope endpoints

View file

@ -19,7 +19,7 @@ var _ = Describe("Bookmarks", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"})
mfRepo = ds.MediaFile().(*tests.MockMediaFileRepo)
mfRepo.SetData(model.MediaFiles{{ID: "visible"}})

View file

@ -20,10 +20,12 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/storage/storagetest"
"github.com/navidrome/navidrome/core/stream"
@ -430,6 +432,7 @@ func setupTestDB() {
metrics.NewNoopInstance(),
lyrics.NewLyrics(ds, nil),
decider,
podcasts.NewPodcastService(context.Background(), ds, ffmpeg.New(), events.NoopBroker()),
nil,
)
}

View file

@ -257,7 +257,7 @@ func buildArtworkRouter(art artwork.Artwork) *subsonic.Router {
ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s,
events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)),
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds),
playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil,
playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, nil,
)
}

View file

@ -10,11 +10,13 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
@ -51,6 +53,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router {
metrics.NewNoopInstance(),
lyrics.NewLyrics(ds, nil),
decider,
podcasts.NewPodcastService(context.Background(), ds, ffmpeg.New(), events.NoopBroker()),
sonicSvc,
)
}

View file

@ -23,7 +23,7 @@ var _ = Describe("GetLyricsBySongId", func() {
ds = &tests.MockDataStore{
MockedMediaFile: mockRepo,
}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil, nil)
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"
})

View file

@ -28,7 +28,7 @@ var _ = Describe("MediaAnnotationController", func() {
ds = &tests.MockDataStore{}
playTracker = &fakePlayTracker{}
eventBroker = &fakeEventBroker{}
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil, nil)
})
Describe("Scrobble", func() {

View file

@ -41,7 +41,7 @@ var _ = Describe("MediaRetrievalController", func() {
MockedRadio: radioRepo,
}
artwork = &fakeArtwork{data: "image data"}
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil)
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil, nil)
w = httptest.NewRecorder()
DeferCleanup(configtest.SetupConfig())
conf.Server.LyricsPriority = "embedded,.lrc"

View file

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

View file

@ -41,10 +41,10 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
Context("without sonic similarity plugin", func() {
BeforeEach(func() {
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
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 18 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(18),
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,16 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", 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}}),
@ -74,10 +84,10 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
Context("with sonic similarity plugin", func() {
BeforeEach(func() {
sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil)
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
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", func() {
It("should return 19 extensions including all podcast 2.0 and sonicSimilarity", func() {
router.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
@ -87,7 +97,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(19),
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}}),
@ -97,6 +107,16 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", 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}}),
))
})
})

View file

@ -25,7 +25,7 @@ var _ = Describe("buildPlaylist", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
ctx = context.Background()
})
@ -331,7 +331,7 @@ var _ = Describe("UpdatePlaylist", func() {
BeforeEach(func() {
ds = &tests.MockDataStore{}
playlists = &fakePlaylists{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil, nil)
})
It("clears the comment when parameter is empty", func() {

388
server/subsonic/podcasts.go Normal file
View file

@ -0,0 +1,388 @@
package subsonic
import (
"net/http"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
)
func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id := p.StringOr("id", "")
includeEpisodes := p.BoolOr("includeEpisodes", true)
ctx := r.Context()
chRepo := api.ds.PodcastChannel()
var channels model.PodcastChannels
var err error
if id != "" {
ch, e := chRepo.Get(ctx, id)
if e != nil {
return nil, e
}
channels = model.PodcastChannels{*ch}
} else {
channels, err = chRepo.GetAll(ctx, includeEpisodes)
if err != nil {
return nil, err
}
}
if includeEpisodes && id != "" {
eps, e := api.ds.PodcastEpisode().GetByChannel(ctx, id)
if e != nil {
return nil, e
}
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()
channelPersons := make(map[string]model.PodcastPersons)
for _, ch := range channels {
persons, err := personRepo.GetByChannel(ctx, ch.ID)
if err == nil {
channelPersons[ch.ID] = persons
}
}
// Bulk-load podcast:podroll items
podrollRepo := api.ds.PodcastPodroll()
allPodrolls, _ := podrollRepo.GetByChannels(ctx, 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()
liveItemMap := make(map[string]*model.PodcastLiveItem)
for _, chID := range channelIDs {
if li, err := liveItemRepo.GetByChannel(ctx, chID); err == nil {
liveItemMap[chID] = li
}
}
// Bulk-load podcast:funding items
fundingRepo := api.ds.PodcastFunding()
allFunding, _ := fundingRepo.GetByChannels(ctx, channelIDs)
fundingMap := make(map[string]model.PodcastFundingItems)
for _, f := range allFunding {
fundingMap[f.ChannelID] = append(fundingMap[f.ChannelID], f)
}
// Bulk-load podcast:image (channel level)
imageRepo := api.ds.PodcastImage()
allChannelImages, _ := imageRepo.GetByChannels(ctx, channelIDs)
channelImageMap := make(map[string]model.PodcastImages)
for _, img := range allChannelImages {
channelImageMap[img.ChannelID] = append(channelImageMap[img.ChannelID], img)
}
// Bulk-load episode transcripts, persons, and images when including episodes
var epTranscripts map[string]model.PodcastTranscripts
var epPersons map[string]model.PodcastPersons
var epImages map[string]model.PodcastImages
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()
allTranscripts, err := transcriptRepo.GetByEpisodes(ctx, 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(ctx, epIDs)
if err == nil {
epPersons = make(map[string]model.PodcastPersons)
for _, p := range allPersons {
epPersons[p.EpisodeID] = append(epPersons[p.EpisodeID], p)
}
}
allEpImages, err := imageRepo.GetByEpisodes(ctx, epIDs)
if err == nil {
epImages = make(map[string]model.PodcastImages)
for _, img := range allEpImages {
epImages[img.EpisodeID] = append(epImages[img.EpisodeID], img)
}
}
}
}
resp := newResponse()
resp.Podcasts = &responses.Podcasts{}
for _, ch := range channels {
rch := responses.PodcastChannel{
ID: ch.ID,
URL: ch.URL,
Title: ch.Title,
Description: ch.Description,
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,
UpdateFrequency: ch.UpdateFrequency,
Complete: ch.Complete,
LocationName: ch.LocationName,
LocationGeo: ch.LocationGeo,
LocationOSM: ch.LocationOSM,
License: ch.License,
PublisherName: ch.PublisherName,
PublisherURL: ch.PublisherURL,
// 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 _, f := range fundingMap[ch.ID] {
rch.Funding = append(rch.Funding, responses.PodcastFundingResp{
URL: f.URL,
Text: f.Text,
})
}
for _, img := range channelImageMap[ch.ID] {
rch.Images = append(rch.Images, responses.PodcastImageResp{
URL: img.URL,
Width: img.Width,
})
}
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]
ep.Images = epImages[ep.ID]
rch.Episode = append(rch.Episode, buildPodcastEpisode(ep))
}
}
resp.Podcasts.Channel = append(resp.Podcasts.Channel, rch)
}
return resp, nil
}
func (api *Router) GetNewestPodcasts(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
count := p.IntOr("count", 20)
eps, err := api.ds.PodcastEpisode().GetNewest(r.Context(), count)
if err != nil {
return nil, err
}
resp := newResponse()
resp.NewestPodcasts = &responses.NewestPodcasts{}
for _, ep := range eps {
resp.NewestPodcasts.Episode = append(resp.NewestPodcasts.Episode, buildPodcastEpisode(ep))
}
return resp, nil
}
func (api *Router) CreatePodcastChannel(r *http.Request) (*responses.Subsonic, error) {
if err := requireAdmin(r); err != nil {
return nil, err
}
p := req.Params(r)
feedURL, err := p.String("url")
if err != nil {
return nil, err
}
if err := api.podcasts.AddChannel(r.Context(), feedURL); err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) RefreshPodcasts(r *http.Request) (*responses.Subsonic, error) {
if err := requireAdmin(r); err != nil {
return nil, err
}
if err := api.podcasts.RefreshChannels(r.Context()); err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) DeletePodcastChannel(r *http.Request) (*responses.Subsonic, error) {
if err := requireAdmin(r); err != nil {
return nil, err
}
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
if err := api.podcasts.DeleteChannel(r.Context(), id); err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) DeletePodcastEpisode(r *http.Request) (*responses.Subsonic, error) {
if err := requireAdmin(r); err != nil {
return nil, err
}
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
if err := api.podcasts.DeleteEpisode(r.Context(), id); err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) DownloadPodcastEpisode(r *http.Request) (*responses.Subsonic, error) {
if err := requireAdmin(r); err != nil {
return nil, err
}
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
if err := api.podcasts.DownloadEpisode(r.Context(), id); err != nil {
return nil, err
}
return newResponse(), nil
}
func (api *Router) GetPodcastEpisode(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
id, err := p.String("id")
if err != nil {
return nil, err
}
ctx := r.Context()
ep, err := api.ds.PodcastEpisode().Get(ctx, id)
if err != nil {
return nil, err
}
ep.Transcripts, _ = api.ds.PodcastTranscript().GetByEpisode(ctx, ep.ID)
ep.Persons, _ = api.ds.PodcastPerson().GetByEpisode(ctx, ep.ID)
ep.Images, _ = api.ds.PodcastImage().GetByEpisode(ctx, ep.ID)
resp := newResponse()
re := buildPodcastEpisode(*ep)
resp.PodcastEpisode = &re
return resp, nil
}
func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode {
re := responses.PodcastEpisode{
ID: ep.ID,
StreamId: ep.StreamID,
ChannelId: ep.ChannelID,
Title: ep.Title,
Description: ep.Description,
Status: string(ep.Status),
ErrorMessage: ep.ErrorMessage,
Duration: ep.Duration,
Size: ep.Size,
Suffix: ep.Suffix,
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,
LocationName: ep.LocationName,
LocationGeo: ep.LocationGeo,
LocationOSM: ep.LocationOSM,
License: ep.License,
}
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,
})
}
for _, img := range ep.Images {
re.Images = append(re.Images, responses.PodcastImageResp{
URL: img.URL,
Width: img.Width,
})
}
return re
}
func requireAdmin(r *http.Request) error {
user, ok := request.UserFrom(r.Context())
if !ok || !user.IsAdmin {
return newError(responses.ErrorAuthorizationFail)
}
return nil
}

View file

@ -0,0 +1,594 @@
package subsonic
import (
"context"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// mockPodcastService is a local mock for the Podcasts service interface.
type mockPodcastService struct {
podcasts.Podcasts
addChannelURL string
refreshCalled bool
deleteChannelID string
deleteEpisodeID string
downloadEpisodeID string
err error
}
func (m *mockPodcastService) AddChannel(_ context.Context, rssURL string) error {
m.addChannelURL = rssURL
return m.err
}
func (m *mockPodcastService) RefreshChannels(_ context.Context) error {
m.refreshCalled = true
return m.err
}
func (m *mockPodcastService) DeleteChannel(_ context.Context, id string) error {
m.deleteChannelID = id
return m.err
}
func (m *mockPodcastService) DeleteEpisode(_ context.Context, id string) error {
m.deleteEpisodeID = id
return m.err
}
func (m *mockPodcastService) DownloadEpisode(_ context.Context, id string) error {
m.downloadEpisodeID = id
return m.err
}
var _ = Describe("Podcasts", func() {
var api *Router
var ds *tests.MockDataStore
var channelRepo *tests.MockPodcastChannelRepo
var episodeRepo *tests.MockPodcastEpisodeRepo
var svc *mockPodcastService
var adminCtx, userCtx context.Context
BeforeEach(func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
channelRepo = tests.CreateMockPodcastChannelRepo()
episodeRepo = tests.CreateMockPodcastEpisodeRepo()
svc = &mockPodcastService{}
ds.MockedPodcastChannel = channelRepo
ds.MockedPodcastEpisode = episodeRepo
api = &Router{ds: ds, podcasts: svc}
adminCtx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true})
userCtx = request.WithUser(context.Background(), model.User{ID: "user", IsAdmin: false})
})
Describe("GetPodcasts", func() {
BeforeEach(func() {
channelRepo.Data = map[string]*model.PodcastChannel{
"ch-1": {ID: "ch-1", Title: "Podcast 1", Status: model.PodcastStatusCompleted},
"ch-2": {ID: "ch-2", Title: "Podcast 2", Status: model.PodcastStatusNew},
}
})
It("returns all channels", func() {
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(adminCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Podcasts.Channel).To(HaveLen(2))
})
It("returns channel fields correctly", func() {
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(adminCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
ids := []string{resp.Podcasts.Channel[0].ID, resp.Podcasts.Channel[1].ID}
Expect(ids).To(ContainElements("ch-1", "ch-2"))
})
It("filters by id when provided", func() {
r := newGetRequest("id=ch-1")
r = r.WithContext(adminCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.Podcasts.Channel).To(HaveLen(1))
Expect(resp.Podcasts.Channel[0].ID).To(Equal("ch-1"))
})
It("is accessible by regular users", func() {
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
_, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
})
})
Describe("GetNewestPodcasts", func() {
BeforeEach(func() {
now := time.Now()
episodeRepo.Data = map[string]*model.PodcastEpisode{
"ep-1": {ID: "ep-1", Title: "Ep1", ChannelID: "ch-1", StreamID: "stream-ep-1", PublishDate: now.Add(-time.Hour), Status: model.PodcastStatusCompleted},
"ep-2": {ID: "ep-2", Title: "Ep2", ChannelID: "ch-1", StreamID: "stream-ep-2", PublishDate: now, Status: model.PodcastStatusNew},
}
})
It("returns episodes in podcastEpisode format", func() {
r := httptest.NewRequest("GET", "/rest/getNewestPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetNewestPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.NewestPodcasts.Episode).To(HaveLen(2))
})
It("includes streamId so clients can play the episode", func() {
r := httptest.NewRequest("GET", "/rest/getNewestPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetNewestPodcasts(r)
Expect(err).ToNot(HaveOccurred())
for _, ep := range resp.NewestPodcasts.Episode {
Expect(ep.StreamId).To(Equal("stream-" + ep.ID))
}
})
It("respects count parameter", func() {
r := newGetRequest("count=1")
r = r.WithContext(userCtx)
resp, err := api.GetNewestPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.NewestPodcasts.Episode).To(HaveLen(1))
})
It("defaults count to 20", func() {
r := httptest.NewRequest("GET", "/rest/getNewestPodcasts", nil)
r = r.WithContext(userCtx)
_, err := api.GetNewestPodcasts(r)
Expect(err).ToNot(HaveOccurred())
})
})
Describe("CreatePodcastChannel", func() {
It("calls service with URL", func() {
r := newGetRequest("url=https://example.com/feed.xml")
r = r.WithContext(adminCtx)
_, err := api.CreatePodcastChannel(r)
Expect(err).ToNot(HaveOccurred())
Expect(svc.addChannelURL).To(Equal("https://example.com/feed.xml"))
})
It("denies non-admin users", func() {
r := newGetRequest("url=https://example.com/feed.xml")
r = r.WithContext(userCtx)
_, err := api.CreatePodcastChannel(r)
Expect(err).To(HaveOccurred())
})
It("returns error when url param is missing", func() {
r := newGetRequest()
r = r.WithContext(adminCtx)
_, err := api.CreatePodcastChannel(r)
Expect(err).To(HaveOccurred())
})
})
Describe("RefreshPodcasts", func() {
It("calls service", func() {
r := httptest.NewRequest("GET", "/rest/refreshPodcasts", nil)
r = r.WithContext(adminCtx)
_, err := api.RefreshPodcasts(r)
Expect(err).ToNot(HaveOccurred())
Expect(svc.refreshCalled).To(BeTrue())
})
It("denies non-admin users", func() {
r := httptest.NewRequest("GET", "/rest/refreshPodcasts", nil)
r = r.WithContext(userCtx)
_, err := api.RefreshPodcasts(r)
Expect(err).To(HaveOccurred())
})
})
Describe("DeletePodcastChannel", func() {
It("calls service with id", func() {
r := newGetRequest("id=ch-1")
r = r.WithContext(adminCtx)
_, err := api.DeletePodcastChannel(r)
Expect(err).ToNot(HaveOccurred())
Expect(svc.deleteChannelID).To(Equal("ch-1"))
})
It("denies non-admin users", func() {
r := newGetRequest("id=ch-1")
r = r.WithContext(userCtx)
_, err := api.DeletePodcastChannel(r)
Expect(err).To(HaveOccurred())
})
})
Describe("DeletePodcastEpisode", func() {
It("calls service with id", func() {
r := newGetRequest("id=ep-1")
r = r.WithContext(adminCtx)
_, err := api.DeletePodcastEpisode(r)
Expect(err).ToNot(HaveOccurred())
Expect(svc.deleteEpisodeID).To(Equal("ep-1"))
})
It("denies non-admin users", func() {
r := newGetRequest("id=ep-1")
r = r.WithContext(userCtx)
_, err := api.DeletePodcastEpisode(r)
Expect(err).To(HaveOccurred())
})
})
Describe("DownloadPodcastEpisode", func() {
It("calls service with id", func() {
r := newGetRequest("id=ep-1")
r = r.WithContext(adminCtx)
_, err := api.DownloadPodcastEpisode(r)
Expect(err).ToNot(HaveOccurred())
Expect(svc.downloadEpisodeID).To(Equal("ep-1"))
})
It("denies non-admin users", func() {
r := newGetRequest("id=ep-1")
r = r.WithContext(userCtx)
_, err := api.DownloadPodcastEpisode(r)
Expect(err).To(HaveOccurred())
})
})
Describe("GetPodcastEpisode", func() {
BeforeEach(func() {
episodeRepo.Data["ep-1"] = &model.PodcastEpisode{
ID: "ep-1",
Title: "Test Episode",
Status: model.PodcastStatusCompleted,
}
})
It("returns single episode", func() {
r := newGetRequest("id=ep-1")
r = r.WithContext(userCtx)
resp, err := api.GetPodcastEpisode(r)
Expect(err).ToNot(HaveOccurred())
Expect(resp.PodcastEpisode).ToNot(BeNil())
Expect(resp.PodcastEpisode.ID).To(Equal("ep-1"))
})
It("returns error for unknown id", func() {
r := newGetRequest("id=no-such-id")
r = r.WithContext(userCtx)
_, err := api.GetPodcastEpisode(r)
Expect(err).To(HaveOccurred())
})
It("is accessible by regular users", func() {
r := newGetRequest("id=ep-1")
r = r.WithContext(userCtx)
_, err := api.GetPodcastEpisode(r)
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 funding items in response", func() {
fundingRepo := tests.CreateMockPodcastFundingRepo()
_ = fundingRepo.SaveForChannel(context.Background(), "ch-p20", []model.PodcastFundingItem{
{URL: "https://example.com/donate", Text: "Support us!"},
})
ds.MockedPodcastFunding = fundingRepo
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.Funding).To(HaveLen(1))
Expect(ch.Funding[0].URL).To(Equal("https://example.com/donate"))
Expect(ch.Funding[0].Text).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(context.Background(), "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(context.Background(), []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(context.Background(), "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(context.Background(), "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(context.Background(), "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(context.Background(), &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(context.Background(), &model.PodcastLiveItem{
ChannelID: "ch-t3",
Status: "live",
StartTime: time.Date(2024, 4, 27, 8, 0, 0, 0, time.UTC),
EndTime: time.Date(2024, 4, 27, 9, 0, 0, 0, time.UTC),
})
ds.MockedPodcastLiveItem = liveItemRepo
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
r = r.WithContext(userCtx)
resp, err := api.GetPodcasts(r)
Expect(err).ToNot(HaveOccurred())
li := resp.Podcasts.Channel[0].LiveItem
Expect(li).ToNot(BeNil())
Expect(li.StartTime).To(Equal("2024-04-27T08:00:00Z"))
Expect(li.EndTime).To(Equal("2024-04-27T09:00:00Z"))
})
})
})

View file

@ -54,6 +54,10 @@ type Subsonic struct {
InternetRadioStations *InternetRadioStations `xml:"internetRadioStations,omitempty" json:"internetRadioStations,omitempty"`
Podcasts *Podcasts `xml:"podcasts,omitempty" json:"podcasts,omitempty"`
NewestPodcasts *NewestPodcasts `xml:"newestPodcasts,omitempty" json:"newestPodcasts,omitempty"`
PodcastEpisode *PodcastEpisode `xml:"podcastEpisode,omitempty" json:"podcastEpisode,omitempty"`
JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus,omitempty" json:"jukeboxStatus,omitempty"`
JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"`
@ -690,6 +694,127 @@ type TranscodeDecision struct {
TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"`
}
// Podcast types
type Podcasts struct {
Channel []PodcastChannel `xml:"channel,omitempty" json:"channel,omitempty"`
}
type NewestPodcasts struct {
Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"`
}
type PodcastFundingResp struct {
URL string `xml:"url,attr,omitempty" json:"url,omitempty"`
Text string `xml:"text,attr,omitempty" json:"text,omitempty"`
}
type PodcastImageResp struct {
URL string `xml:"url,attr,omitempty" json:"url,omitempty"`
Width int `xml:"width,attr,omitempty" json:"width,omitempty"`
}
type PodcastChannel struct {
ID string `xml:"id,attr" json:"id"`
URL string `xml:"url,attr" json:"url"`
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
OriginalImageUrl string `xml:"originalImageUrl,attr,omitempty" json:"originalImageUrl,omitempty"`
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"`
UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"`
Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"`
LocationName string `xml:"locationName,attr,omitempty" json:"locationName,omitempty"`
LocationGeo string `xml:"locationGeo,attr,omitempty" json:"locationGeo,omitempty"`
LocationOSM string `xml:"locationOsm,attr,omitempty" json:"locationOsm,omitempty"`
License string `xml:"license,attr,omitempty" json:"license,omitempty"`
PublisherName string `xml:"publisherName,attr,omitempty" json:"publisherName,omitempty"`
PublisherURL string `xml:"publisherUrl,attr,omitempty" json:"publisherUrl,omitempty"`
Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"`
Funding []PodcastFundingResp `xml:"funding,omitempty" json:"funding,omitempty"`
Images []PodcastImageResp `xml:"image,omitempty" json:"images,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"`
// 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"`
LocationName string `xml:"locationName,attr,omitempty" json:"locationName,omitempty"`
LocationGeo string `xml:"locationGeo,attr,omitempty" json:"locationGeo,omitempty"`
LocationOSM string `xml:"locationOsm,attr,omitempty" json:"locationOsm,omitempty"`
License string `xml:"license,attr,omitempty" json:"license,omitempty"`
Transcript []PodcastTranscriptResp `xml:"transcript,omitempty" json:"transcript,omitempty"`
Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"`
Images []PodcastImageResp `xml:"image,omitempty" json:"images,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
type StreamDetails struct {
Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"`

View file

@ -23,7 +23,7 @@ var _ = Describe("Search", func() {
ds = &tests.MockDataStore{}
auth.Init(ds)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
// Get references to the mock repositories so we can inspect their Options
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)

View file

@ -32,7 +32,7 @@ var _ = Describe("Transcode endpoints", func() {
mockMFRepo = &tests.MockMediaFileRepo{}
ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo}
mockTD = &mockTranscodeDecision{}
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil)
w = httptest.NewRecorder()
})
@ -508,7 +508,7 @@ var _ = Describe("Transcode endpoints", func() {
It("builds correct StreamRequest for direct play", func() {
fakeStreamer := &fakeMediaStreamer{}
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil)
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}})
mockTD.resolvedReq = stream.Request{}
@ -525,7 +525,7 @@ var _ = Describe("Transcode endpoints", func() {
It("builds correct StreamRequest for transcoding", func() {
fakeStreamer := &fakeMediaStreamer{}
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil)
mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}})
mockTD.resolvedReq = stream.Request{
Format: "mp3",

View file

@ -8,30 +8,38 @@ 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
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
MockedPodcastFunding model.PodcastFundingRepository
MockedPodcastImage model.PodcastImageRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
// GC tracking
GCCalled bool
@ -321,6 +329,110 @@ func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository {
return db.MockedArtworkQueue
}
func (db *MockDataStore) PodcastChannel() model.PodcastChannelRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastChannel != nil {
return db.MockedPodcastChannel
}
if db.RealDS != nil {
return db.RealDS.PodcastChannel()
}
db.MockedPodcastChannel = CreateMockPodcastChannelRepo()
return db.MockedPodcastChannel
}
func (db *MockDataStore) PodcastEpisode() model.PodcastEpisodeRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastEpisode != nil {
return db.MockedPodcastEpisode
}
if db.RealDS != nil {
return db.RealDS.PodcastEpisode()
}
db.MockedPodcastEpisode = CreateMockPodcastEpisodeRepo()
return db.MockedPodcastEpisode
}
func (db *MockDataStore) PodcastTranscript() model.PodcastTranscriptRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastTranscript != nil {
return db.MockedPodcastTranscript
}
if db.RealDS != nil {
return db.RealDS.PodcastTranscript()
}
db.MockedPodcastTranscript = CreateMockPodcastTranscriptRepo()
return db.MockedPodcastTranscript
}
func (db *MockDataStore) PodcastPerson() model.PodcastPersonRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastPerson != nil {
return db.MockedPodcastPerson
}
if db.RealDS != nil {
return db.RealDS.PodcastPerson()
}
db.MockedPodcastPerson = CreateMockPodcastPersonRepo()
return db.MockedPodcastPerson
}
func (db *MockDataStore) PodcastPodroll() model.PodcastPodrollRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastPodroll != nil {
return db.MockedPodcastPodroll
}
if db.RealDS != nil {
return db.RealDS.PodcastPodroll()
}
db.MockedPodcastPodroll = CreateMockPodcastPodrollRepo()
return db.MockedPodcastPodroll
}
func (db *MockDataStore) PodcastLiveItem() model.PodcastLiveItemRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastLiveItem != nil {
return db.MockedPodcastLiveItem
}
if db.RealDS != nil {
return db.RealDS.PodcastLiveItem()
}
db.MockedPodcastLiveItem = CreateMockPodcastLiveItemRepo()
return db.MockedPodcastLiveItem
}
func (db *MockDataStore) PodcastFunding() model.PodcastFundingRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastFunding != nil {
return db.MockedPodcastFunding
}
if db.RealDS != nil {
return db.RealDS.PodcastFunding()
}
db.MockedPodcastFunding = CreateMockPodcastFundingRepo()
return db.MockedPodcastFunding
}
func (db *MockDataStore) PodcastImage() model.PodcastImageRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastImage != nil {
return db.MockedPodcastImage
}
if db.RealDS != nil {
return db.RealDS.PodcastImage()
}
db.MockedPodcastImage = CreateMockPodcastImageRepo()
return db.MockedPodcastImage
}
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
return block(db)
}

View file

@ -0,0 +1,84 @@
package tests
import (
"context"
"errors"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
type MockPodcastChannelRepo struct {
model.PodcastChannelRepository
Data map[string]*model.PodcastChannel
Err bool
}
func CreateMockPodcastChannelRepo() *MockPodcastChannelRepo {
return &MockPodcastChannelRepo{Data: map[string]*model.PodcastChannel{}}
}
func (m *MockPodcastChannelRepo) Get(ctx context.Context, chanID string) (*model.PodcastChannel, error) {
if m.Err {
return nil, errors.New("error")
}
if c, ok := m.Data[chanID]; ok {
return c, nil
}
return nil, model.ErrNotFound
}
func (m *MockPodcastChannelRepo) GetAll(ctx context.Context, withEpisodes bool) (model.PodcastChannels, error) {
if m.Err {
return nil, errors.New("error")
}
result := make(model.PodcastChannels, 0, len(m.Data))
for _, c := range m.Data {
result = append(result, *c)
}
return result, nil
}
func (m *MockPodcastChannelRepo) Create(ctx context.Context, channel *model.PodcastChannel) error {
if m.Err {
return errors.New("error")
}
if channel.ID == "" {
channel.ID = id.NewRandom()
}
m.Data[channel.ID] = channel
return nil
}
func (m *MockPodcastChannelRepo) UpdateChannel(ctx context.Context, channel *model.PodcastChannel) error {
if m.Err {
return errors.New("error")
}
m.Data[channel.ID] = channel
return nil
}
func (m *MockPodcastChannelRepo) ExistsByURL(ctx context.Context, url string) (bool, error) {
if m.Err {
return false, errors.New("error")
}
for _, c := range m.Data {
if c.URL == url {
return true, nil
}
}
return false, nil
}
func (m *MockPodcastChannelRepo) Delete(ctx context.Context, ids ...string) error {
if m.Err {
return errors.New("error")
}
for _, chanID := range ids {
if _, ok := m.Data[chanID]; !ok {
return model.ErrNotFound
}
delete(m.Data, chanID)
}
return nil
}

View file

@ -0,0 +1,116 @@
package tests
import (
"context"
"errors"
"sort"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
type MockPodcastEpisodeRepo struct {
model.PodcastEpisodeRepository
Data map[string]*model.PodcastEpisode
Err bool
}
func CreateMockPodcastEpisodeRepo() *MockPodcastEpisodeRepo {
return &MockPodcastEpisodeRepo{Data: map[string]*model.PodcastEpisode{}}
}
func (m *MockPodcastEpisodeRepo) Get(ctx context.Context, epID string) (*model.PodcastEpisode, error) {
if m.Err {
return nil, errors.New("error")
}
if ep, ok := m.Data[epID]; ok {
return ep, nil
}
return nil, model.ErrNotFound
}
func (m *MockPodcastEpisodeRepo) GetNewest(ctx context.Context, count int) (model.PodcastEpisodes, error) {
if m.Err {
return nil, errors.New("error")
}
all := make(model.PodcastEpisodes, 0, len(m.Data))
for _, ep := range m.Data {
all = append(all, *ep)
}
sort.Slice(all, func(i, j int) bool {
return all[i].PublishDate.After(all[j].PublishDate)
})
if count > 0 && len(all) > count {
all = all[:count]
}
return all, nil
}
func (m *MockPodcastEpisodeRepo) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastEpisodes, error) {
if m.Err {
return nil, errors.New("error")
}
ids := make(map[string]bool, len(channelIDs))
for _, id := range channelIDs {
ids[id] = true
}
var result model.PodcastEpisodes
for _, ep := range m.Data {
if ids[ep.ChannelID] {
result = append(result, *ep)
}
}
return result, nil
}
func (m *MockPodcastEpisodeRepo) GetByChannel(ctx context.Context, channelID string) (model.PodcastEpisodes, error) {
if m.Err {
return nil, errors.New("error")
}
result := model.PodcastEpisodes{}
for _, ep := range m.Data {
if ep.ChannelID == channelID {
result = append(result, *ep)
}
}
return result, nil
}
func (m *MockPodcastEpisodeRepo) GetByGUID(ctx context.Context, channelID, guid string) (*model.PodcastEpisode, error) {
if m.Err {
return nil, errors.New("error")
}
for _, ep := range m.Data {
if ep.ChannelID == channelID && ep.GUID == guid {
return ep, nil
}
}
return nil, model.ErrNotFound
}
func (m *MockPodcastEpisodeRepo) Create(ctx context.Context, ep *model.PodcastEpisode) error {
if m.Err {
return errors.New("error")
}
if ep.ID == "" {
ep.ID = id.NewRandom()
}
m.Data[ep.ID] = ep
return nil
}
func (m *MockPodcastEpisodeRepo) Update(ctx context.Context, ep *model.PodcastEpisode) error {
if m.Err {
return errors.New("error")
}
m.Data[ep.ID] = ep
return nil
}
func (m *MockPodcastEpisodeRepo) Delete(ctx context.Context, epID string) error {
if m.Err {
return errors.New("error")
}
delete(m.Data, epID)
return nil
}

View file

@ -0,0 +1,67 @@
package tests
import (
"context"
"errors"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
type MockPodcastFundingRepo struct {
model.PodcastFundingRepository
Data map[string]*model.PodcastFundingItem
Err bool
}
func CreateMockPodcastFundingRepo() *MockPodcastFundingRepo {
return &MockPodcastFundingRepo{Data: map[string]*model.PodcastFundingItem{}}
}
func (m *MockPodcastFundingRepo) GetByChannel(ctx context.Context, channelID string) (model.PodcastFundingItems, error) {
if m.Err {
return nil, errors.New("error")
}
var result model.PodcastFundingItems
for _, f := range m.Data {
if f.ChannelID == channelID {
result = append(result, *f)
}
}
return result, nil
}
func (m *MockPodcastFundingRepo) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastFundingItems, error) {
if m.Err {
return nil, errors.New("error")
}
ids := make(map[string]bool, len(channelIDs))
for _, id := range channelIDs {
ids[id] = true
}
var result model.PodcastFundingItems
for _, f := range m.Data {
if ids[f.ChannelID] {
result = append(result, *f)
}
}
return result, nil
}
func (m *MockPodcastFundingRepo) SaveForChannel(ctx context.Context, channelID string, items []model.PodcastFundingItem) error {
if m.Err {
return errors.New("error")
}
for k, f := range m.Data {
if f.ChannelID == channelID {
delete(m.Data, k)
}
}
for i := range items {
items[i].ID = id.NewRandom()
items[i].ChannelID = channelID
f := items[i]
m.Data[f.ID] = &f
}
return nil
}

View file

@ -0,0 +1,115 @@
package tests
import (
"context"
"errors"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
)
type MockPodcastImageRepo struct {
model.PodcastImageRepository
Data map[string]*model.PodcastImage
Err bool
}
func CreateMockPodcastImageRepo() *MockPodcastImageRepo {
return &MockPodcastImageRepo{Data: map[string]*model.PodcastImage{}}
}
func (m *MockPodcastImageRepo) GetByChannel(ctx context.Context, channelID string) (model.PodcastImages, error) {
if m.Err {
return nil, errors.New("error")
}
var result model.PodcastImages
for _, img := range m.Data {
if img.ChannelID == channelID {
result = append(result, *img)
}
}
return result, nil
}
func (m *MockPodcastImageRepo) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastImages, error) {
if m.Err {
return nil, errors.New("error")
}
ids := make(map[string]bool, len(channelIDs))
for _, id := range channelIDs {
ids[id] = true
}
var result model.PodcastImages
for _, img := range m.Data {
if ids[img.ChannelID] {
result = append(result, *img)
}
}
return result, nil
}
func (m *MockPodcastImageRepo) GetByEpisode(ctx context.Context, episodeID string) (model.PodcastImages, error) {
if m.Err {
return nil, errors.New("error")
}
var result model.PodcastImages
for _, img := range m.Data {
if img.EpisodeID == episodeID {
result = append(result, *img)
}
}
return result, nil
}
func (m *MockPodcastImageRepo) GetByEpisodes(ctx context.Context, episodeIDs []string) (model.PodcastImages, 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.PodcastImages
for _, img := range m.Data {
if ids[img.EpisodeID] {
result = append(result, *img)
}
}
return result, nil
}
func (m *MockPodcastImageRepo) SaveForChannel(ctx context.Context, channelID string, images []model.PodcastImage) error {
if m.Err {
return errors.New("error")
}
for k, img := range m.Data {
if img.ChannelID == channelID {
delete(m.Data, k)
}
}
for i := range images {
images[i].ID = id.NewRandom()
images[i].ChannelID = channelID
img := images[i]
m.Data[img.ID] = &img
}
return nil
}
func (m *MockPodcastImageRepo) SaveForEpisode(ctx context.Context, episodeID string, images []model.PodcastImage) error {
if m.Err {
return errors.New("error")
}
for k, img := range m.Data {
if img.EpisodeID == episodeID {
delete(m.Data, k)
}
}
for i := range images {
images[i].ID = id.NewRandom()
images[i].EpisodeID = episodeID
img := images[i]
m.Data[img.ID] = &img
}
return nil
}

View file

@ -0,0 +1,54 @@
package tests
import (
"context"
"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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, channelID string) error {
if m.Err {
return errors.New("error")
}
delete(m.Data, channelID)
return nil
}

View file

@ -0,0 +1,98 @@
package tests
import (
"context"
"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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, episodeID string, persons []model.PodcastPerson) error {
if m.Err {
return errors.New("error")
}
for k, p := range m.Data {
if p.EpisodeID == episodeID {
delete(m.Data, k)
}
}
for i := range persons {
persons[i].ID = id.NewRandom()
persons[i].EpisodeID = episodeID
p := persons[i]
m.Data[p.ID] = &p
}
return nil
}

View file

@ -0,0 +1,71 @@
package tests
import (
"context"
"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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, channelID string, items []model.PodcastPodrollItem) error {
if m.Err {
return errors.New("error")
}
// Remove old items for this channel.
for k, v := range m.Data {
if v.ChannelID == channelID {
delete(m.Data, k)
}
}
for i := range items {
items[i].ID = id.NewRandom()
items[i].ChannelID = channelID
items[i].SortOrder = i
cp := items[i]
m.Data[cp.ID] = &cp
}
return nil
}

View file

@ -0,0 +1,75 @@
package tests
import (
"context"
"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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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(ctx context.Context, 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
}

View file

@ -19,6 +19,7 @@ import album from './album'
import artist from './artist'
import playlist from './playlist'
import radio from './radio'
import podcast from './podcast'
import share from './share'
import library from './library'
import plugin from './plugin'
@ -33,6 +34,7 @@ import {
saveQueueDialogReducer,
playerReducer,
albumViewReducer,
podcastViewReducer,
activityReducer,
settingsReducer,
replayGainReducer,
@ -69,6 +71,7 @@ const adminStore = createAdminStore({
library: libraryReducer,
player: playerReducer,
albumView: albumViewReducer,
podcastView: podcastViewReducer,
theme: themeReducer,
addToPlaylistDialog: addToPlaylistDialogReducer,
downloadMenuDialog: downloadMenuDialogReducer,
@ -131,6 +134,10 @@ const Admin = (props) => {
name="radio"
{...(permissions === 'admin' ? radio.admin : radio.all)}
/>,
<Resource
name="podcast"
{...(permissions === 'admin' ? podcast.admin : podcast.all)}
/>,
config.enableSharing && <Resource name="share" {...share} />,
<Resource
name="playlist"

View file

@ -2,6 +2,7 @@ export * from './library'
export * from './player'
export * from './themes'
export * from './albumView'
export * from './podcastView'
export * from './dialogs'
export * from './replayGain'
export * from './serverEvents'

View file

@ -0,0 +1,5 @@
export const PODCAST_MODE_GRID = 'PODCAST_GRID_MODE'
export const PODCAST_MODE_TABLE = 'PODCAST_TABLE_MODE'
export const podcastViewGrid = () => ({ type: PODCAST_MODE_GRID })
export const podcastViewTable = () => ({ type: PODCAST_MODE_TABLE })

View file

@ -49,9 +49,11 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
const linkTo = audioInfo.isRadio
? `/radio/${audioInfo.trackId}/show`
: song.playlistId
? `/playlist/${song.playlistId}/show`
: `/album/${song.albumId}/show`
: song.isPodcast
? `/podcast/${song.channelId}/show`
: song.playlistId
? `/playlist/${song.playlistId}/show`
: `/album/${song.albumId}/show`
return (
<Link to={linkTo} className={className} ref={dragSongRef}>

View file

@ -24,6 +24,9 @@ const setupHandlers = (stream, dispatchFn) => {
stream.addEventListener('nowPlayingCount', eventHandler(dispatchFn))
}
stream.addEventListener('keepAlive', eventHandler(dispatchFn))
stream.addEventListener('podcastEpisodeProgress', (event) => {
window.dispatchEvent(new CustomEvent('podcastEpisodeProgress', { detail: JSON.parse(event.data) }))
})
stream.onerror = (e) => {
// eslint-disable-next-line no-console
console.log('EventStream error', e)
@ -80,6 +83,9 @@ const startEventStreamLegacy = async (dispatchFn) => {
newStream.addEventListener('nowPlayingCount', eventHandler(dispatchFn))
}
newStream.addEventListener('keepAlive', eventHandler(dispatchFn))
newStream.addEventListener('podcastEpisodeProgress', (event) => {
window.dispatchEvent(new CustomEvent('podcastEpisodeProgress', { detail: JSON.parse(event.data) }))
})
newStream.onerror = (e) => {
// eslint-disable-next-line no-console
console.log('EventStream error', e)

View file

@ -139,7 +139,8 @@
"actions": {
"topSongs": "Top Songs",
"shuffle": "Shuffle",
"radio": "Radio"
"radio": "Radio",
"podcast": "Podcasts"
}
},
"user": {
@ -275,6 +276,43 @@
"playNow": "Play Now"
}
},
"podcast": {
"name": "Podcast |||| Podcasts",
"fields": {
"title": "Title",
"url": "Feed URL",
"description": "Description",
"status": "Status",
"publishDate": "Published",
"duration": "Duration",
"episodeCount": "Episodes"
},
"actions": {
"refresh": "Refresh All",
"download": "Download",
"addChannel": "Add Channel",
"fetchFeed": "Fetch Feed Info",
"copyUrl": "Copy Feed URL"
},
"notifications": {
"refreshStarted": "Podcast refresh started",
"downloadStarted": "Download started",
"channelAdded": "Channel added",
"urlCopied": "Feed URL copied to clipboard",
"urlCopyFailed": "Could not copy the feed URL",
"alreadyExists": "This channel is already registered.",
"channelDeleted": "Channel deleted",
"episodeDeleted": "Episode deleted"
},
"status": {
"new": "New",
"downloading": "Downloading",
"completed": "Downloaded",
"error": "Error",
"skipped": "Skipped",
"deleted": "Deleted"
}
},
"share": {
"name": "Share |||| Shares",
"fields": {

View file

@ -1,14 +1,15 @@
import React, { useCallback, useEffect, useMemo } from 'react'
import {
BulkActionsToolbar,
FunctionField,
Link,
ListToolbar,
TextField,
NumberField,
TextField,
useDataProvider,
useListContext,
useNotify,
useVersion,
useListContext,
FunctionField,
} from 'react-admin'
import clsx from 'clsx'
import { useDispatch } from 'react-redux'
@ -16,24 +17,56 @@ import { Card, useMediaQuery } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import ReactDragListView from 'react-drag-listview'
import {
ArtistLinkField,
DateField,
DurationField,
SongInfo,
QualityInfo,
RatingField,
SongContextMenu,
SongDatagrid,
SongInfo,
SongTitleField,
QualityInfo,
useSelectedFields,
useResourceRefresh,
DateField,
ArtistLinkField,
RatingField,
useSelectedFields,
} from '../common'
import { AlbumLinkField } from '../song/AlbumLinkField'
import { playTracks } from '../actions'
import { closeExtendedInfoDialog, playTracks } from '../actions'
import PlaylistSongBulkActions from './PlaylistSongBulkActions'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import config from '../config'
const AlbumOrChannelLinkField = (props) => {
const dispatch = useDispatch()
const record = props.record
const isPodcast = record?.genre === 'Podcast'
const linkTo = isPodcast
? `/podcast/${record.albumId}/show`
: `/album/${record.albumId}/show`
return (
<Link
to={linkTo}
onClick={(e) => {
e.stopPropagation()
dispatch(closeExtendedInfoDialog())
}}
>
{record?.album}
</Link>
)
}
AlbumOrChannelLinkField.defaultProps = { addLabel: true, sortBy: 'album' }
const PodcastAwareArtistField = (props) => {
const record = props.record
const isPodcast = record?.genre === 'Podcast'
const hasNoRealArtist =
!record?.artist || record?.artist === '[Unknown Artist]'
if (isPodcast && hasNoRealArtist) {
return <span>-</span>
}
return <ArtistLinkField {...props} />
}
PodcastAwareArtistField.defaultProps = { addLabel: true, sortBy: 'artist' }
const useStyles = makeStyles(
(theme) => ({
root: {},
@ -148,8 +181,8 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
return {
trackNumber: isDesktop && <TextField source="id" label={'#'} />,
title: <SongTitleField source="title" showTrackNumbers={false} />,
album: isDesktop && <AlbumLinkField source="album" />,
artist: isDesktop && <ArtistLinkField source="artist" />,
album: isDesktop && <AlbumOrChannelLinkField source="album" />,
artist: isDesktop && <PodcastAwareArtistField source="artist" />,
albumArtist: isDesktop && <ArtistLinkField source="albumArtist" />,
duration: (
<DurationField source="duration" className={classes.draggable} />

View file

@ -0,0 +1,73 @@
import React from 'react'
import { useDispatch } from 'react-redux'
import { IconButton, CircularProgress } from '@material-ui/core'
import GetAppIcon from '@material-ui/icons/GetApp'
import DeleteIcon from '@material-ui/icons/Delete'
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
import subsonic from '../subsonic'
import { setTrack } from '../actions'
const EpisodeActions = ({ episode, onRefresh, channelTitle }) => {
const dispatch = useDispatch()
const handleDownload = async () => {
await subsonic.downloadPodcastEpisode(episode.id)
onRefresh?.()
}
const handleDelete = async (e) => {
e.stopPropagation()
await subsonic.deletePodcastEpisode(episode.id)
onRefresh?.()
}
const handlePlay = (e) => {
e.stopPropagation()
dispatch(
setTrack({
id: episode.streamId,
title: episode.title,
album: channelTitle || episode.channelId,
artist: '',
duration: episode.duration,
suffix: episode.suffix,
isPodcast: true,
channelId: episode.channelId,
}),
)
}
if (episode.status === 'downloading') {
return <CircularProgress size={20} />
}
if (episode.status === 'completed') {
return (
<>
<IconButton aria-label="play" size="small" onClick={handlePlay}>
<PlayArrowIcon fontSize="small" />
</IconButton>
<IconButton aria-label="delete" size="small" onClick={handleDelete}>
<DeleteIcon fontSize="small" />
</IconButton>
</>
)
}
if (episode.status === 'new' || episode.status === 'error') {
return (
<>
<IconButton aria-label="download" size="small" onClick={handleDownload}>
<GetAppIcon fontSize="small" />
</IconButton>
<IconButton aria-label="delete" size="small" onClick={handleDelete}>
<DeleteIcon fontSize="small" />
</IconButton>
</>
)
}
return null
}
export default EpisodeActions

View file

@ -0,0 +1,83 @@
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../subsonic', () => ({
default: {
downloadPodcastEpisode: vi.fn().mockResolvedValue({}),
deletePodcastEpisode: vi.fn().mockResolvedValue({}),
},
}))
vi.mock('react-redux', () => ({
useDispatch: () => vi.fn(),
}))
import subsonic from '../subsonic'
import EpisodeActions from './EpisodeActions'
describe('EpisodeActions', () => {
const onRefresh = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('shows play and delete buttons for completed episode', () => {
render(
<EpisodeActions episode={{ id: 'ep-1', status: 'completed' }} onRefresh={onRefresh} />,
)
expect(screen.getByLabelText('play')).toBeTruthy()
expect(screen.getByLabelText('delete')).toBeTruthy()
expect(screen.queryByLabelText('download')).toBeNull()
})
it('shows download and delete buttons for new episode', () => {
render(
<EpisodeActions episode={{ id: 'ep-1', status: 'new' }} onRefresh={onRefresh} />,
)
expect(screen.getByLabelText('download')).toBeTruthy()
expect(screen.getByLabelText('delete')).toBeTruthy()
expect(screen.queryByLabelText('play')).toBeNull()
})
it('shows download and delete buttons for error episode', () => {
render(
<EpisodeActions episode={{ id: 'ep-1', status: 'error' }} onRefresh={onRefresh} />,
)
expect(screen.getByLabelText('download')).toBeTruthy()
expect(screen.getByLabelText('delete')).toBeTruthy()
})
it('shows spinner only for downloading episode', () => {
render(
<EpisodeActions episode={{ id: 'ep-1', status: 'downloading' }} onRefresh={onRefresh} />,
)
expect(screen.getByRole('progressbar')).toBeTruthy()
expect(screen.queryByLabelText('play')).toBeNull()
expect(screen.queryByLabelText('download')).toBeNull()
})
it('renders nothing for deleted episode', () => {
const { container } = render(
<EpisodeActions episode={{ id: 'ep-1', status: 'deleted' }} onRefresh={onRefresh} />,
)
expect(container).toBeEmptyDOMElement()
})
it('calls downloadPodcastEpisode when download clicked', () => {
render(
<EpisodeActions episode={{ id: 'ep-1', status: 'new' }} onRefresh={onRefresh} />,
)
fireEvent.click(screen.getByLabelText('download'))
expect(subsonic.downloadPodcastEpisode).toHaveBeenCalledWith('ep-1')
})
it('calls deletePodcastEpisode when delete clicked on completed episode', () => {
render(
<EpisodeActions episode={{ id: 'ep-1', status: 'completed' }} onRefresh={onRefresh} />,
)
fireEvent.click(screen.getByLabelText('delete'))
expect(subsonic.deletePodcastEpisode).toHaveBeenCalledWith('ep-1')
})
})

View file

@ -0,0 +1,170 @@
import React, { useState } from 'react'
import { useTranslate, useNotify, useRedirect, useRefresh, Title } from 'react-admin'
import {
Avatar,
Card,
CardContent,
CircularProgress,
Divider,
InputAdornment,
TextField,
Typography,
makeStyles,
} from '@material-ui/core'
import { Button } from 'react-admin'
import MicIcon from '@material-ui/icons/Mic'
import SearchIcon from '@material-ui/icons/Search'
import AddIcon from '@material-ui/icons/Add'
import subsonic from '../subsonic'
const useStyles = makeStyles((theme) => ({
root: { marginTop: theme.spacing(2) },
urlRow: { display: 'flex', gap: theme.spacing(1), alignItems: 'flex-start' },
urlInput: { flex: 1 },
preview: {
marginTop: theme.spacing(3),
display: 'flex',
gap: theme.spacing(2),
alignItems: 'flex-start',
},
previewImage: { width: 120, height: 120, borderRadius: 4, flexShrink: 0 },
previewInfo: { flex: 1 },
previewTitle: { fontWeight: 600, marginBottom: theme.spacing(0.5) },
previewDesc: { color: theme.palette.text.secondary, marginBottom: theme.spacing(1) },
addButton: { marginTop: theme.spacing(2) },
}))
const PodcastCreate = () => {
const translate = useTranslate()
const notify = useNotify()
const redirect = useRedirect()
const refresh = useRefresh()
const classes = useStyles()
const [feedUrl, setFeedUrl] = useState('')
const [fetching, setFetching] = useState(false)
const [adding, setAdding] = useState(false)
const [preview, setPreview] = useState(null)
const title = translate('ra.page.create', {
name: translate('resources.podcast.name', { smart_count: 1 }),
})
const handleFetch = async () => {
if (!feedUrl) return
setFetching(true)
setPreview(null)
try {
const res = await subsonic.previewPodcastFeed(feedUrl)
setPreview(res.json)
} catch {
notify('ra.notification.http_error', { type: 'error' })
} finally {
setFetching(false)
}
}
const handleAdd = async () => {
setAdding(true)
try {
await subsonic.createPodcastChannel(feedUrl)
notify('resources.podcast.notifications.channelAdded')
redirect('/podcast')
refresh()
} catch {
notify('ra.notification.http_error', { type: 'error' })
} finally {
setAdding(false)
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleFetch()
}
}
return (
<Card className={classes.root}>
<Title subTitle={title} />
<CardContent>
<div className={classes.urlRow}>
<TextField
className={classes.urlInput}
label={translate('resources.podcast.fields.url')}
value={feedUrl}
onChange={(e) => { setFeedUrl(e.target.value); setPreview(null) }}
onKeyDown={handleKeyDown}
type="url"
variant="outlined"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<MicIcon color="action" />
</InputAdornment>
),
}}
/>
<Button
variant="contained"
color="primary"
onClick={handleFetch}
disabled={fetching || !feedUrl}
label="resources.podcast.actions.fetchFeed"
style={{ marginTop: 8 }}
>
{fetching ? <CircularProgress size={18} color="inherit" /> : <SearchIcon />}
</Button>
</div>
{preview && (
<>
<Divider style={{ marginTop: 24, marginBottom: 8 }} />
<div className={classes.preview}>
{preview.imageUrl ? (
<img src={preview.imageUrl} alt={preview.title} className={classes.previewImage} />
) : (
<Avatar variant="rounded" className={classes.previewImage}>
<MicIcon style={{ fontSize: 48 }} />
</Avatar>
)}
<div className={classes.previewInfo}>
<Typography variant="h6" className={classes.previewTitle}>
{preview.title}
</Typography>
{preview.episodeCount > 0 && (
<Typography variant="body2" color="textSecondary">
{translate('resources.podcast.fields.episodeCount')}: {preview.episodeCount}
</Typography>
)}
{preview.description && (
<Typography variant="body2" className={classes.previewDesc}>
{preview.description}
</Typography>
)}
{preview.alreadyExists ? (
<Typography variant="body2" color="error" style={{ marginTop: 8 }}>
{translate('resources.podcast.notifications.alreadyExists')}
</Typography>
) : (
<Button
className={classes.addButton}
variant="contained"
color="primary"
onClick={handleAdd}
disabled={adding}
label="resources.podcast.actions.addChannel"
>
{adding ? <CircularProgress size={18} color="inherit" /> : <AddIcon />}
</Button>
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
)
}
export default PodcastCreate

View file

@ -0,0 +1,103 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../subsonic', () => ({
default: {
createPodcastChannel: vi.fn().mockResolvedValue({}),
previewPodcastFeed: vi.fn().mockResolvedValue({
json: {
title: 'Example Podcast',
description: 'An example feed',
episodeCount: 3,
alreadyExists: false,
},
}),
},
}))
const mockRedirect = vi.fn()
const mockRefresh = vi.fn()
const mockNotify = vi.fn()
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
useTranslate: () => (key) => key,
useNotify: () => mockNotify,
useRedirect: () => mockRedirect,
useRefresh: () => mockRefresh,
Title: () => null,
Button: ({ children, onClick, label, disabled }) => (
<button aria-label={label} onClick={onClick} disabled={disabled}>
{children}
</button>
),
}
})
import subsonic from '../subsonic'
import PodcastCreate from './PodcastCreate'
const fetchPreview = async (url) => {
fireEvent.change(screen.getByRole('textbox'), { target: { value: url } })
fireEvent.click(
screen.getByLabelText('resources.podcast.actions.fetchFeed'),
)
await waitFor(() => {
expect(subsonic.previewPodcastFeed).toHaveBeenCalledWith(url)
})
}
describe('PodcastCreate', () => {
beforeEach(() => vi.clearAllMocks())
it('renders a URL input field', () => {
render(<PodcastCreate />)
expect(screen.getByRole('textbox')).toBeTruthy()
})
it('fetches a preview of the feed for the entered URL', async () => {
render(<PodcastCreate />)
await fetchPreview('https://example.com/feed.xml')
expect(await screen.findByText('Example Podcast')).toBeTruthy()
})
it('calls createPodcastChannel with the entered URL when adding the previewed channel', async () => {
render(<PodcastCreate />)
await fetchPreview('https://example.com/feed.xml')
fireEvent.click(
await screen.findByLabelText('resources.podcast.actions.addChannel'),
)
await waitFor(() => {
expect(subsonic.createPodcastChannel).toHaveBeenCalledWith(
'https://example.com/feed.xml',
)
})
})
it('redirects to /podcast after successfully adding the channel', async () => {
render(<PodcastCreate />)
await fetchPreview('https://example.com/feed.xml')
fireEvent.click(
await screen.findByLabelText('resources.podcast.actions.addChannel'),
)
await waitFor(() => {
expect(mockRedirect).toHaveBeenCalledWith('/podcast')
})
})
it('notifies on success', async () => {
render(<PodcastCreate />)
await fetchPreview('https://example.com/feed.xml')
fireEvent.click(
await screen.findByLabelText('resources.podcast.actions.addChannel'),
)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
'resources.podcast.notifications.channelAdded',
)
})
})
})

View file

@ -0,0 +1,101 @@
import React from 'react'
import {
GridList,
GridListTile,
GridListTileBar,
Typography,
useMediaQuery,
} from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import MicIcon from '@material-ui/icons/Mic'
import { useListContext, linkToRecord } from 'react-admin'
import { Link } from 'react-router-dom'
import withWidth from '@material-ui/core/withWidth'
const useStyles = makeStyles((theme) => ({
root: { margin: theme.spacing(1) },
tileContainer: { cursor: 'pointer' },
link: { display: 'block', textDecoration: 'none', color: 'inherit' },
cover: { width: '100%', display: 'block', objectFit: 'cover' },
placeholder: {
width: '100%',
paddingBottom: '100%',
position: 'relative',
backgroundColor: theme.palette.grey[300],
},
placeholderIcon: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
},
tileBar: {
background: 'linear-gradient(to top, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 100%)',
},
title: {
fontSize: '0.85rem',
fontWeight: 500,
marginTop: theme.spacing(0.5),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
}))
const buildSrcSet = (images) => {
if (!images || images.length === 0) return undefined
return images.map((img) => `${img.url} ${img.width}w`).join(', ')
}
const getColsForWidth = (width) => {
if (width === 'xs') return 2
if (width === 'sm') return 3
if (width === 'md') return 4
if (width === 'lg') return 5
return 6
}
const PodcastGridView = ({ width, ...props }) => {
const classes = useStyles()
const { ids, data, basePath } = useListContext(props)
if (!ids || !data) return null
return (
<div className={classes.root}>
<GridList cellHeight="auto" cols={getColsForWidth(width)} spacing={16}>
{ids.map((id) => {
const record = data[id]
if (!record) return null
return (
<GridListTile key={id}>
<div className={classes.tileContainer}>
<Link className={classes.link} to={linkToRecord(basePath, id, 'show')}>
{record.imageUrl ? (
<img
src={record.imageUrl}
srcSet={buildSrcSet(record.images)}
sizes="(max-width: 600px) 50vw, (max-width: 960px) 33vw, (max-width: 1280px) 25vw, 20vw"
alt={record.title}
className={classes.cover}
/>
) : (
<div className={classes.placeholder}>
<MicIcon className={classes.placeholderIcon} style={{ fontSize: 48, color: '#888' }} />
</div>
)}
<GridListTileBar className={classes.tileBar} title="" />
</Link>
<Link className={classes.link} to={linkToRecord(basePath, id, 'show')}>
<Typography className={classes.title}>{record.title}</Typography>
</Link>
</div>
</GridListTile>
)
})}
</GridList>
</div>
)
}
export default withWidth()(PodcastGridView)

View file

@ -0,0 +1,202 @@
import React from 'react'
import { Avatar, ButtonGroup, IconButton, makeStyles, Tooltip, Typography, useMediaQuery } from '@material-ui/core'
import FileCopyIcon from '@material-ui/icons/FileCopy'
import MicIcon from '@material-ui/icons/Mic'
import RefreshIcon from '@material-ui/icons/Refresh'
import ViewModuleIcon from '@material-ui/icons/ViewModule'
import ViewHeadlineIcon from '@material-ui/icons/ViewHeadline'
import {
Button,
CreateButton,
Datagrid,
DateField,
Filter,
sanitizeListRestProps,
SearchInput,
SimpleList,
TextField,
TopToolbar,
useNotify,
useRefresh,
useTranslate,
} from 'react-admin'
import { useDispatch, useSelector } from 'react-redux'
import { List } from '../common'
import subsonic from '../subsonic'
import StatusBadge from './StatusBadge'
import PodcastGridView from './PodcastGridView'
import { podcastViewGrid, podcastViewTable } from '../actions'
const useStyles = makeStyles({
row: { '&:hover': { '& $contextMenu': { visibility: 'visible' } } },
contextMenu: { visibility: 'hidden' },
toggleTitle: { margin: '1rem' },
buttonGroup: { width: '100%', justifyContent: 'center' },
leftButton: { paddingRight: '0.5rem' },
rightButton: { paddingLeft: '0.5rem' },
})
const PodcastFilter = (props) => (
<Filter {...props} variant="outlined">
<SearchInput id="search" source="title" alwaysOn />
</Filter>
)
const bestImageUrl = (record, targetWidth) => {
if (!record.images || record.images.length === 0) return record.imageUrl
const sorted = [...record.images].sort((a, b) => a.width - b.width)
const best = sorted.find((img) => img.width >= targetWidth) || sorted[sorted.length - 1]
return best ? best.url : record.imageUrl
}
const CoverArtField = ({ record }) => {
if (!record) return null
if (record.imageUrl) {
return (
<Avatar src={bestImageUrl(record, 55)} variant="rounded" style={{ width: 55, height: 55 }} alt={record.title} />
)
}
return (
<Avatar variant="rounded" style={{ width: 55, height: 55 }}>
<MicIcon />
</Avatar>
)
}
CoverArtField.defaultProps = { label: '', sortable: false }
const FeedUrlField = ({ record }) => {
const notify = useNotify()
const translate = useTranslate()
if (!record?.url) return null
const handleCopy = (e) => {
e.stopPropagation()
navigator.clipboard
.writeText(record.url)
.then(() => notify('resources.podcast.notifications.urlCopied'))
.catch(() => notify('resources.podcast.notifications.urlCopyFailed', 'warning'))
}
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{record.url}
</span>
<Tooltip title={translate('resources.podcast.actions.copyUrl', { _: 'Copy URL' })}>
<IconButton size="small" onClick={handleCopy}>
<FileCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</span>
)
}
FeedUrlField.defaultProps = { label: 'resources.podcast.fields.url', sortable: false }
const StatusField = ({ record }) => {
if (!record || record.status !== 'error') return null
return <StatusBadge status="error" errorMessage={record.errorMessage} />
}
StatusField.defaultProps = { label: '' }
const RefreshButton = () => {
const notify = useNotify()
const refresh = useRefresh()
const handleClick = async () => {
await subsonic.refreshPodcasts()
notify('resources.podcast.notifications.refreshStarted')
refresh()
}
return (
<Button onClick={handleClick} label="resources.podcast.actions.refresh">
<RefreshIcon />
</Button>
)
}
const PodcastViewToggler = React.forwardRef(({ showTitle = true }, ref) => {
const dispatch = useDispatch()
const podcastView = useSelector((state) => state.podcastView)
const classes = useStyles()
const translate = useTranslate()
return (
<div ref={ref}>
{showTitle && (
<Typography className={classes.toggleTitle}>
{translate('ra.toggleFieldsMenu.layout')}
</Typography>
)}
<ButtonGroup variant="text" color="primary" className={classes.buttonGroup}>
<Button
size="small"
className={classes.leftButton}
label={translate('ra.toggleFieldsMenu.grid')}
color={podcastView.grid ? 'primary' : 'secondary'}
onClick={() => dispatch(podcastViewGrid())}
>
<ViewModuleIcon fontSize="inherit" />
</Button>
<Button
size="small"
className={classes.rightButton}
label={translate('ra.toggleFieldsMenu.table')}
color={podcastView.grid ? 'secondary' : 'primary'}
onClick={() => dispatch(podcastViewTable())}
>
<ViewHeadlineIcon fontSize="inherit" />
</Button>
</ButtonGroup>
</div>
)
})
PodcastViewToggler.displayName = 'PodcastViewToggler'
const PodcastListActions = ({ className, filters, resource, showFilter, displayedFilters, filterValues, isAdmin, ...rest }) => {
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{isAdmin && <RefreshButton />}
{isAdmin && <CreateButton basePath="/podcast" />}
{filters && React.cloneElement(filters, { resource, showFilter, displayedFilters, filterValues, context: 'button' })}
<PodcastViewToggler showTitle={false} />
</TopToolbar>
)
}
const PodcastList = ({ permissions, ...props }) => {
const classes = useStyles()
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const isAdmin = permissions === 'admin'
const podcastView = useSelector((state) => state.podcastView)
return (
<List
{...props}
exporter={false}
sort={{ field: 'title', order: 'ASC' }}
bulkActionButtons={isAdmin ? undefined : false}
hasCreate={isAdmin}
actions={<PodcastListActions isAdmin={isAdmin} />}
filters={<PodcastFilter />}
>
{isXsmall ? (
<SimpleList
linkType="show"
leftAvatar={(r) => <CoverArtField record={r} />}
primaryText={(r) => r.title}
secondaryText={(r) => r.url}
/>
) : podcastView.grid ? (
<PodcastGridView />
) : (
<Datagrid rowClick="show" classes={{ row: classes.row }}>
<CoverArtField source="id" />
<TextField source="title" />
<FeedUrlField source="url" />
<StatusField source="status" sortable={false} />
<DateField source="updatedAt" showTime />
</Datagrid>
)}
</List>
)
}
export default PodcastList

View file

@ -0,0 +1,242 @@
import React, { useEffect, useRef, useState } from 'react'
import {
Card,
CardContent,
Typography,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
makeStyles,
Link,
} from '@material-ui/core'
import { Button, useTranslate, useShowController, Title } from 'react-admin'
import { useDispatch } from 'react-redux'
import MicIcon from '@material-ui/icons/Mic'
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
import ShuffleIcon from '@material-ui/icons/Shuffle'
import { RiPlayList2Fill, RiPlayListAddFill } from 'react-icons/ri'
import StatusBadge from './StatusBadge'
import EpisodeActions from './EpisodeActions'
import subsonic from '../subsonic'
import { setTrack, playTracks, shuffleTracks, playNext, addTracks } from '../actions'
const songFromEpisode = (episode, channelTitle) => ({
id: episode.streamId,
title: episode.title,
album: channelTitle || episode.channelId,
artist: '',
duration: episode.duration,
suffix: episode.suffix,
isPodcast: true,
channelId: episode.channelId,
})
const buildTracksData = (episodes, channelTitle) => {
const data = {}
const ids = []
episodes
.filter((ep) => ep.status === 'completed' && ep.streamId)
.forEach((ep) => {
const song = songFromEpisode(ep, channelTitle)
data[song.id] = song
ids.push(song.id)
})
return { data, ids }
}
const EpisodePlayButtons = ({ episodes, channelTitle }) => {
const dispatch = useDispatch()
const translate = useTranslate()
const { data, ids } = buildTracksData(episodes, channelTitle)
if (!ids.length) return null
return (
<div style={{ marginBottom: 8 }}>
<Button onClick={() => dispatch(playTracks(data, ids))} label={translate('resources.album.actions.playAll')}>
<PlayArrowIcon />
</Button>
<Button onClick={() => dispatch(shuffleTracks(data, ids))} label={translate('resources.album.actions.shuffle')}>
<ShuffleIcon />
</Button>
<Button onClick={() => dispatch(playNext(data, ids))} label={translate('resources.album.actions.playNext')}>
<RiPlayList2Fill />
</Button>
<Button onClick={() => dispatch(addTracks(data, ids))} label={translate('resources.album.actions.addToQueue')}>
<RiPlayListAddFill />
</Button>
</div>
)
}
const useStyles = makeStyles((theme) => ({
card: { marginTop: theme.spacing(2) },
header: { display: 'flex', gap: theme.spacing(2), marginBottom: theme.spacing(3) },
playableRow: { cursor: 'pointer', '&:hover': { backgroundColor: theme.palette.action.hover } },
avatar: {
width: 192,
height: 192,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.palette.grey[300],
borderRadius: 4,
flexShrink: 0,
},
meta: { flex: 1 },
description: { marginTop: theme.spacing(1), color: theme.palette.text.secondary },
tableWrapper: { marginTop: theme.spacing(2), overflowX: 'auto' },
}))
const buildSrcSet = (images) => {
if (!images || images.length === 0) return undefined
return images.map((img) => `${img.url} ${img.width}w`).join(', ')
}
const formatDuration = (seconds) => {
if (!seconds) return ''
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${m}:${String(s).padStart(2, '0')}`
}
const PodcastShow = (props) => {
const classes = useStyles()
const translate = useTranslate()
const dispatch = useDispatch()
const { record } = useShowController(props)
const [episodes, setEpisodes] = useState([])
// Incremented on every load and on channel change; responses from a
// superseded request are ignored so they can't overwrite the current channel.
const requestGen = useRef(0)
const loadEpisodes = () => {
if (!record?.id) return
const gen = ++requestGen.current
subsonic
.getPodcasts(record.id, true)
.then((res) => {
if (gen !== requestGen.current) return
const channels = res?.json?.['subsonic-response']?.podcasts?.channel || []
const ch = channels.find((c) => c.id === record.id)
setEpisodes(ch?.episode || [])
})
.catch(() => {})
}
useEffect(() => {
setEpisodes([]) // don't show the previous channel's episodes
loadEpisodes()
return () => {
requestGen.current++
}
}, [record?.id])
// Stay subscribed to SSE progress while the view is mounted: a download
// started from here may not be flagged 'downloading' yet when we refresh.
useEffect(() => {
const handler = (e) => {
const { episodeId, downloadedBytes, size, duration, status } = e.detail
if (status === 'completed' || status === 'error') {
// Reload to get updated streamId and full episode data
loadEpisodes()
return
}
setEpisodes((prev) =>
prev.map((ep) =>
ep.id === episodeId
? { ...ep, downloadedBytes, size, ...(duration ? { duration } : {}) }
: ep,
),
)
}
window.addEventListener('podcastEpisodeProgress', handler)
return () => window.removeEventListener('podcastEpisodeProgress', handler)
}, [record?.id])
if (!record) return null
return (
<Card className={classes.card}>
<Title subTitle={record.title} />
<CardContent>
<div className={classes.header}>
<div className={classes.avatar}>
{record.imageUrl ? (
<img
src={record.imageUrl}
srcSet={buildSrcSet(record.images)}
sizes="192px"
alt={record.title}
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 4 }}
/>
) : (
<MicIcon style={{ fontSize: 40, color: '#888' }} />
)}
</div>
<div className={classes.meta}>
<Typography variant="h5">{record.title}</Typography>
<Link href={record.url} target="_blank" rel="noopener noreferrer" variant="body2">
{record.url}
</Link>
{record.description && (
<Typography variant="body2" className={classes.description}>
{record.description}
</Typography>
)}
{record.status === 'error' && (
<StatusBadge status="error" errorMessage={record.errorMessage} />
)}
</div>
</div>
<EpisodePlayButtons episodes={episodes} channelTitle={record.title} />
<div className={classes.tableWrapper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{translate('resources.podcast.fields.title')}</TableCell>
<TableCell>{translate('resources.podcast.fields.publishDate')}</TableCell>
<TableCell>{translate('resources.podcast.fields.duration')}</TableCell>
<TableCell>{translate('resources.podcast.fields.status')}</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{episodes.map((ep) => (
<TableRow
key={ep.id}
className={ep.status === 'completed' ? classes.playableRow : undefined}
onClick={() => ep.status === 'completed' && dispatch(setTrack(songFromEpisode(ep, record.title)))}
>
<TableCell>{ep.title}</TableCell>
<TableCell>
{ep.publishDate ? new Date(ep.publishDate).toLocaleDateString() : ''}
</TableCell>
<TableCell>{formatDuration(ep.duration)}</TableCell>
<TableCell>
<StatusBadge status={ep.status} errorMessage={ep.errorMessage} downloadedBytes={ep.downloadedBytes} size={ep.size} />
</TableCell>
<TableCell>
<EpisodeActions
episode={ep}
onRefresh={loadEpisodes}
channelTitle={record.title}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}
export default PodcastShow

View file

@ -0,0 +1,48 @@
import React from 'react'
import { Chip, CircularProgress, Tooltip, Typography, Box, makeStyles } from '@material-ui/core'
import { useTranslate } from 'react-admin'
const useStyles = makeStyles((theme) => ({
completed: { backgroundColor: theme.palette.success?.main || '#4caf50', color: '#fff' },
error: { backgroundColor: theme.palette.error.main, color: '#fff', cursor: 'default' },
new: {},
skipped: { backgroundColor: theme.palette.warning?.main || '#ff9800', color: '#fff' },
}))
const StatusBadge = ({ status, errorMessage, downloadedBytes, size }) => {
const translate = useTranslate()
const classes = useStyles()
if (!status || status === 'deleted') return null
const label = translate(`resources.podcast.status.${status}`, { _: status })
if (status === 'downloading') {
const pct = size > 0 && downloadedBytes > 0 ? Math.round((downloadedBytes / size) * 100) : null
return (
<Box display="flex" alignItems="center" style={{ gap: 6 }}>
<CircularProgress size={14} />
<Typography variant="caption">{label}</Typography>
{pct !== null && <Typography variant="caption">{`${pct}%`}</Typography>}
</Box>
)
}
if (status === 'error' && errorMessage) {
return (
<Tooltip title={errorMessage}>
<Chip className={classes.error} label={label} size="small" />
</Tooltip>
)
}
return (
<Chip
className={classes[status] || classes.new}
label={label}
size="small"
/>
)
}
export default StatusBadge

View file

@ -0,0 +1,53 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import StatusBadge from './StatusBadge'
vi.mock('react-admin', () => ({
useTranslate: () => (key) => key,
}))
vi.mock('@material-ui/core', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
Tooltip: ({ children }) => children,
}
})
describe('StatusBadge', () => {
it('renders label for completed status', () => {
render(<StatusBadge status="completed" />)
expect(screen.getByText('resources.podcast.status.completed')).toBeTruthy()
})
it('renders label for downloading status', () => {
render(<StatusBadge status="downloading" />)
expect(screen.getByText('resources.podcast.status.downloading')).toBeTruthy()
})
it('renders label for error status', () => {
render(<StatusBadge status="error" errorMessage="Connection refused" />)
expect(screen.getByText('resources.podcast.status.error')).toBeTruthy()
})
it('renders label for new status', () => {
render(<StatusBadge status="new" />)
expect(screen.getByText('resources.podcast.status.new')).toBeTruthy()
})
it('renders label for skipped status', () => {
render(<StatusBadge status="skipped" />)
expect(screen.getByText('resources.podcast.status.skipped')).toBeTruthy()
})
it('renders nothing for deleted status', () => {
const { container } = render(<StatusBadge status="deleted" />)
expect(container).toBeEmptyDOMElement()
})
it('renders nothing when status is undefined', () => {
const { container } = render(<StatusBadge />)
expect(container).toBeEmptyDOMElement()
})
})

26
ui/src/podcast/index.jsx Normal file
View file

@ -0,0 +1,26 @@
import React from 'react'
import MicIcon from '@material-ui/icons/Mic'
import MicNoneIcon from '@material-ui/icons/MicNone'
import DynamicMenuIcon from '../layout/DynamicMenuIcon'
import PodcastList from './PodcastList'
import PodcastShow from './PodcastShow'
import PodcastCreate from './PodcastCreate'
const all = {
list: PodcastList,
show: PodcastShow,
icon: (
<DynamicMenuIcon
path={'podcast'}
icon={MicNoneIcon}
activeIcon={MicIcon}
/>
),
}
const admin = {
...all,
create: PodcastCreate,
}
export default { all, admin }

View file

@ -3,6 +3,7 @@ export * from './themeReducer'
export * from './dialogReducer'
export * from './playerReducer'
export * from './albumView'
export * from './podcastView'
export * from './activityReducer'
export * from './settingsReducer'
export * from './replayGainReducer'

View file

@ -0,0 +1,15 @@
import { PODCAST_MODE_GRID, PODCAST_MODE_TABLE } from '../actions'
export const podcastViewReducer = (
previousState = { grid: true },
payload,
) => {
const { type } = payload
switch (type) {
case PODCAST_MODE_GRID:
case PODCAST_MODE_TABLE:
return { ...previousState, grid: type === PODCAST_MODE_GRID }
default:
return previousState
}
}

View file

@ -131,6 +131,29 @@ const getTopSongs = (artist, count = 50) => {
return httpClient(url('getTopSongs', null, { artist, count }))
}
const getPodcasts = (id = null, includeEpisodes = true) =>
httpClient(url('getPodcasts', id, { includeEpisodes }))
const getNewestPodcasts = (count = 20) =>
httpClient(url('getNewestPodcasts', null, { count }))
const createPodcastChannel = (feedUrl) =>
httpClient(url('createPodcastChannel', null, { url: feedUrl }))
const previewPodcastFeed = (feedUrl) =>
httpClient(baseUrl(`/api/podcast/preview?url=${encodeURIComponent(feedUrl)}`))
const refreshPodcasts = () => httpClient(url('refreshPodcasts'))
const deletePodcastChannel = (id) =>
httpClient(url('deletePodcastChannel', id))
const deletePodcastEpisode = (id) =>
httpClient(url('deletePodcastEpisode', id))
const downloadPodcastEpisode = (id) =>
httpClient(url('downloadPodcastEpisode', id))
const streamUrl = (id, options) => {
return baseUrl(
url('stream', id, {
@ -160,4 +183,12 @@ export default {
getArtistInfo,
getTopSongs,
getSimilarSongs2,
getPodcasts,
getNewestPodcasts,
createPodcastChannel,
previewPodcastFeed,
refreshPodcasts,
deletePodcastChannel,
deletePodcastEpisode,
downloadPodcastEpisode,
}

View file

@ -265,3 +265,61 @@ describe('reportPlayback', () => {
expect(url).toContain('state=playing')
})
})
describe('podcast API URL builders', () => {
beforeEach(() => {
const localStorageMock = {
getItem: vi.fn((key) => {
const values = {
username: 'testuser',
'subsonic-token': 'testtoken',
'subsonic-salt': 'testsalt',
}
return values[key] || null
}),
}
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
})
it('getPodcasts includes includeEpisodes param', () => {
const result = subsonic.url('getPodcasts', null, { includeEpisodes: true })
expect(result).toContain('getPodcasts')
expect(result).toContain('includeEpisodes=true')
})
it('getPodcasts includes id when provided', () => {
const result = subsonic.url('getPodcasts', 'ch-1', { includeEpisodes: true })
expect(result).toContain('id=ch-1')
})
it('getNewestPodcasts includes count', () => {
const result = subsonic.url('getNewestPodcasts', null, { count: 5 })
expect(result).toContain('getNewestPodcasts')
expect(result).toContain('count=5')
})
it('createPodcastChannel includes url param', () => {
const feedUrl = 'https://example.com/feed.xml'
const result = subsonic.url('createPodcastChannel', null, { url: feedUrl })
expect(result).toContain('createPodcastChannel')
expect(result).toContain(encodeURIComponent(feedUrl))
})
it('deletePodcastChannel includes id', () => {
const result = subsonic.url('deletePodcastChannel', 'ch-1')
expect(result).toContain('deletePodcastChannel')
expect(result).toContain('id=ch-1')
})
it('deletePodcastEpisode includes id', () => {
const result = subsonic.url('deletePodcastEpisode', 'ep-1')
expect(result).toContain('deletePodcastEpisode')
expect(result).toContain('id=ep-1')
})
it('downloadPodcastEpisode includes id', () => {
const result = subsonic.url('downloadPodcastEpisode', 'ep-1')
expect(result).toContain('downloadPodcastEpisode')
expect(result).toContain('id=ep-1')
})
})