feat(podcast): add podcast feature with UX improvements - #5420

Backend
- Add podcast data model (PodcastChannel, PodcastEpisode) with migrations
- Implement Subsonic API endpoints: getPodcasts, getNewestPodcasts,
  createPodcastChannel, refreshPodcasts, deletePodcastChannel,
  deletePodcastEpisode, downloadPodcastEpisode, getPodcastEpisode
- Add native REST API endpoints: GET/DELETE /api/podcast,
  GET /api/podcast/preview (feed info without creating channel)
- Inject events.Broker into podcast service for SSE support
- Emit PodcastEpisodeProgress SSE events during download (every 512 KB)
  and on completion/error with status field
- Use HTTP Content-Length as fallback when RSS feed omits enclosure size
- Write ID3 tags (title, album, genre=Podcast) to downloaded files via
  ffmpeg so the library scanner reads correct metadata
- Set MediaFile fields (Title, Album, AlbumID=channelId, AlbumArtist,
  Genre) on episode registration
- Add duplicate URL check in AddChannel
- Add ExistsByURL to PodcastChannelRepository

Frontend - Podcast list
- Add grid/list view toggle (Redux podcastViewReducer) matching album list
- New PodcastGridView component with responsive column count (2-6 cols)
- Cover image 100px in table view
- Remove Feed URL column; add inline copy-to-clipboard button

Frontend - Podcast show (episode list)
- Real-time download progress (%) in Status column via SSE, no polling
  - Spinner only before first SSE event; N% once data arrives
  - Size column removed; Downloading badge replaced with progress
- Completed episodes play on row click; separate play button removed
- Play / Shuffle / Play Next / Add to Queue buttons above episode list
  (only shown when completed episodes exist)
- On download completion, reload episodes to obtain streamId for
  immediate playback without page refresh
- Clicking album name in AudioTitle navigates to podcast channel page

Frontend - Podcast create
- Full-width URL input with Fetch Feed Info button and Enter key support
- Preview card (cover image, title, episode count, description) before
  committing channel creation
- Add Channel button appears only after preview; shows already-registered
  message if channel URL exists

Frontend - Playlist
- Album link navigates to podcast channel page for podcast tracks
  (identified by genre=Podcast)
- Artist column shows '-' for podcast tracks with empty artist field

Closes #5420

Signed-off-by: ji-ho lee <search5@gmail.com>
This commit is contained in:
Jiho Andrew Lee 2026-04-27 23:40:15 +09:00
commit 775747264b
50 changed files with 3769 additions and 36 deletions

View file

@ -20,6 +20,7 @@ import (
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
@ -108,8 +109,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
podcastService := podcasts.NewPodcastService(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, podcastService, 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 {
@ -247,7 +248,8 @@ type probeOutput struct {
}
type probeFormat struct {
BitRate string `json:"bit_rate"`
BitRate string `json:"bit_rate"`
Duration string `json:"duration"`
}
type probeStream struct {
@ -303,6 +305,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
}

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

@ -0,0 +1,401 @@
package podcasts
import (
"context"
"fmt"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"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 {
ds model.DataStore
ff ffmpeg.FFmpeg
broker events.Broker
}
func NewPodcastService(ds model.DataStore, ff ffmpeg.FFmpeg, broker events.Broker) Podcasts {
return &podcastService{ds: ds, ff: ff, broker: broker}
}
// podcastLibraryID returns the ID of the podcast virtual library,
// creating it if it doesn't exist. The library root is DataFolder so that
// MediaFile paths stored as "podcasts/{ch}/{ep}.mp3" resolve correctly via AbsolutePath().
func (s *podcastService) podcastLibraryID(ctx context.Context) (int, error) {
libs, err := s.ds.Library(ctx).GetAll()
if err != nil {
return 0, err
}
for _, lib := range libs {
if lib.Name == podcastLibraryName {
return lib.ID, nil
}
}
lib := &model.Library{
Name: podcastLibraryName,
Path: conf.Server.DataFolder,
}
if err := s.ds.Library(ctx).Put(lib); err != nil {
return 0, err
}
return lib.ID, nil
}
func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error {
exists, err := s.ds.PodcastChannel(ctx).ExistsByURL(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,
}
if err := s.ds.PodcastChannel(ctx).Create(ch); err != nil {
return err
}
for i := range feed.Episodes {
ep := feed.Episodes[i]
ep.ChannelID = ch.ID
ep.Status = model.PodcastStatusNew
if err := s.ds.PodcastEpisode(ctx).Create(&ep); err != nil {
return err
}
}
ch.Status = model.PodcastStatusCompleted
return s.ds.PodcastChannel(ctx).UpdateChannel(ch)
}
func (s *podcastService) RefreshChannels(ctx context.Context) error {
channels, err := s.ds.PodcastChannel(ctx).GetAll(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
}
epRepo := s.ds.PodcastEpisode(ctx)
for i := range feed.Episodes {
ep := feed.Episodes[i]
_, err := epRepo.GetByGUID(ch.ID, ep.GUID)
if err == nil {
continue // already exists
}
ep.ChannelID = ch.ID
ep.Status = model.PodcastStatusNew
if err := epRepo.Create(&ep); err != nil {
return err
}
}
return nil
}
func (s *podcastService) DownloadEpisode(ctx context.Context, id string) error {
ep, err := s.ds.PodcastEpisode(ctx).Get(id)
if err != nil {
return err
}
ch, err := s.ds.PodcastChannel(ctx).Get(ep.ChannelID)
if err != nil {
return err
}
ep.Status = model.PodcastStatusDownloading
ep.UpdatedAt = time.Now()
if err := s.ds.PodcastEpisode(ctx).Update(ep); err != nil {
return err
}
go s.doDownload(context.Background(), 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(conf.Server.DataFolder, "podcasts", ep.ChannelID)
if err := os.MkdirAll(dir, 0755); err != nil {
s.setEpisodeError(ctx, ep, err)
return
}
dest := filepath.Join(dir, ep.ID+"."+suffix)
f, err := os.Create(dest)
if err != nil {
s.setEpisodeError(ctx, ep, err)
return
}
defer f.Close()
resp, err := http.Get(ep.EnclosureURL) //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
}
// 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 {
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 a podcast virtual library whose root is DataFolder; 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, conf.Server.DataFolder+string(filepath.Separator))
now := time.Now()
mf := &model.MediaFile{
ID: id.NewRandom(),
LibraryID: libID,
Path: relPath,
Title: ep.Title,
Album: ch.Title,
AlbumID: ch.ID,
Artist: "",
AlbumArtist: ch.Title,
Genre: "Podcast",
Duration: float32(ep.Duration),
Size: size,
BitRate: ep.BitRate,
Suffix: suffix,
CreatedAt: now,
UpdatedAt: now,
}
if putErr := s.ds.MediaFile(ctx).Put(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(ctx).Update(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(ctx).Update(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),
})
}
}
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="+title,
"-metadata", "album="+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(ctx).Get(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(ctx).Delete(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(ctx).Update(ep)
}
func (s *podcastService) DeleteChannel(ctx context.Context, id string) error {
episodes, err := s.ds.PodcastEpisode(ctx).GetByChannel(id)
if err != nil {
return err
}
for _, ep := range episodes {
if ep.Path != "" {
_ = os.Remove(ep.Path)
}
}
return s.ds.PodcastChannel(ctx).Delete(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(pw.ctx).Update(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
}
func fetchAndParse(rssURL string) (*rssFeed, error) {
resp, err := http.Get(rssURL) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("fetching RSS feed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading RSS feed: %w", err)
}
return ParseRSSFeed(data)
}

View file

@ -0,0 +1,17 @@
package podcasts_test
import (
"testing"
"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)
RegisterFailHandler(Fail)
RunSpecs(t, "Podcasts Suite")
}

View file

@ -0,0 +1,256 @@
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 = GinkgoT().TempDir()
svc = podcasts.NewPodcastService(ds, 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 + 신규 1
})
})
Describe("DownloadEpisode", func() {
var episode *model.PodcastEpisode
BeforeEach(func() {
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, "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("records the file path after download", func() {
_ = svc.DownloadEpisode(ctx, "ep-1")
expectedPath := filepath.Join(conf.Server.DataFolder, "podcasts", "ch-1", "ep-1.mp3")
Eventually(func() string {
return episodeRepo.Data["ep-1"].Path
}, "3s").Should(Equal(expectedPath))
})
})
Describe("DeleteEpisode", func() {
It("removes episode from DB", func() {
episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", ChannelID: "ch-1"}
err := svc.DeleteEpisode(ctx, "ep-1")
Expect(err).ToNot(HaveOccurred())
Expect(episodeRepo.Data).To(BeEmpty())
})
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() {
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("DownloadEpisode with timestamp", func() {
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())
})
})
})

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

@ -0,0 +1,202 @@
package podcasts
import (
"encoding/xml"
"fmt"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/navidrome/navidrome/model"
)
// Go's encoding/xml has a known limitation with inherited namespace prefixes.
// We fall back to a regex scan for itunes:image when struct tag parsing yields nothing.
var itunesImageRe = regexp.MustCompile(`<[^:>]*:image[^>]+href="([^"]*)"`)
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
}
type FeedPreview struct {
Title string `json:"title"`
Description string `json:"description"`
ImageURL string `json:"imageUrl"`
EpisodeCount int `json:"episodeCount"`
AlreadyExists bool `json:"alreadyExists"`
}
func ParseFeedPreview(rssURL string) (*FeedPreview, error) {
feed, err := fetchAndParse(rssURL)
if err != nil {
return nil, err
}
return &FeedPreview{
Title: feed.Title,
Description: feed.Description,
ImageURL: feed.ImageURL,
EpisodeCount: len(feed.Episodes),
}, nil
}
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"`
}
type rssImage struct {
URL string `xml:"url"`
}
type itunesImage struct {
Href string `xml:"href,attr"`
}
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"`
}
type enclosure struct {
URL string `xml:"url,attr"`
Length int64 `xml:"length,attr"`
Type string `xml:"type,attr"`
}
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,
}
// itunes:image takes precedence over <image><url>.
// Use regex fallback because Go's xml package doesn't always resolve
// namespace prefixes inherited from ancestor elements.
if href := extractItunesImageHref(data); href != "" {
feed.ImageURL = href
} else if ch.ItunesImage.Href != "" {
feed.ImageURL = ch.ItunesImage.Href
} else {
feed.ImageURL = ch.Image.URL
}
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)
ep := model.PodcastEpisode{
GUID: item.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,
}
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
}
}
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
}
// fallback: extract from URL path
if u, err := url.Parse(enclosureURL); err == nil {
if ext := path.Ext(u.Path); ext != "" {
return strings.TrimPrefix(ext, ".")
}
}
return ""
}

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

@ -0,0 +1,116 @@
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("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())
})
})
})

View file

@ -0,0 +1,63 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
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

@ -42,6 +42,8 @@ type DataStore interface {
Plugin(ctx context.Context) PluginRepository
Artwork(ctx context.Context) ArtworkRepository
ArtworkQueue(ctx context.Context) ArtworkQueueRepository
PodcastChannel(ctx context.Context) PodcastChannelRepository
PodcastEpisode(ctx context.Context) PodcastEpisodeRepository
Resource(ctx context.Context, model any) ResourceRepository

71
model/podcast.go Normal file
View file

@ -0,0 +1,71 @@
package model
import "time"
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"`
Episodes PodcastEpisodes `structs:"-" json:"episodes,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"`
}
type PodcastChannels []PodcastChannel
type PodcastEpisodes []PodcastEpisode
type PodcastChannelRepository interface {
Get(id string) (*PodcastChannel, error)
GetAll(withEpisodes bool) (PodcastChannels, error)
ExistsByURL(url string) (bool, error)
Create(channel *PodcastChannel) error
UpdateChannel(channel *PodcastChannel) error
Delete(id string) error
}
type PodcastEpisodeRepository interface {
Get(id string) (*PodcastEpisode, error)
GetNewest(count int) (PodcastEpisodes, error)
GetByChannel(channelID string) (PodcastEpisodes, error)
GetByGUID(channelID, guid string) (*PodcastEpisode, error)
Create(ep *PodcastEpisode) error
Update(ep *PodcastEpisode) error
Delete(id string) error
}

View file

@ -105,6 +105,14 @@ func (s *SQLStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepositor
return NewArtworkQueueRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) PodcastChannel(ctx context.Context) model.PodcastChannelRepository {
return NewPodcastChannelRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) PodcastEpisode(ctx context.Context) model.PodcastEpisodeRepository {
return NewPodcastEpisodeRepository(ctx, s.getDBXBuilder())
}
func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository {
switch m.(type) {
case model.User:
@ -125,6 +133,8 @@ func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository
return s.Playlist(ctx).(model.ResourceRepository)
case model.Radio:
return s.Radio(ctx).(model.ResourceRepository)
case model.PodcastChannel:
return s.PodcastChannel(ctx).(model.ResourceRepository)
case model.Share:
return s.Share(ctx).(model.ResourceRepository)
case model.Tag:

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
@ -252,6 +287,22 @@ var _ = BeforeSuite(func() {
}
}
pcr := NewPodcastChannelRepository(ctx, conn)
for i := range testPodcastChannels {
c := testPodcastChannels[i]
if err := pcr.Create(&c); err != nil {
panic(err)
}
}
per := NewPodcastEpisodeRepository(ctx, conn)
for i := range testPodcastEpisodes {
e := testPodcastEpisodes[i]
if err := per.Create(&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(ctx context.Context, db dbx.Builder) model.PodcastChannelRepository {
r := &podcastChannelRepository{}
r.ctx = ctx
r.db = db
r.registerModel(&model.PodcastChannel{}, nil)
return r
}
func (r *podcastChannelRepository) isPermitted() bool {
return loggedUser(r.ctx).IsAdmin
}
func (r *podcastChannelRepository) Get(chanID string) (*model.PodcastChannel, error) {
sel := r.newSelect().Columns("*").Where(Eq{"id": chanID})
res := model.PodcastChannel{}
if err := r.queryOne(sel, &res); err != nil {
return nil, err
}
return &res, nil
}
func (r *podcastChannelRepository) GetAll(withEpisodes bool) (model.PodcastChannels, error) {
sel := r.newSelect().Columns("*").OrderBy("title")
var channels model.PodcastChannels
if err := r.queryAll(sel, &channels); err != nil {
return nil, err
}
if withEpisodes {
epRepo := NewPodcastEpisodeRepository(r.ctx, r.db)
for i := range channels {
eps, err := epRepo.GetByChannel(channels[i].ID)
if err != nil {
return nil, err
}
channels[i].Episodes = eps
}
}
return channels, nil
}
func (r *podcastChannelRepository) ExistsByURL(url string) (bool, error) {
sel := r.newSelect().Columns("count(*)").Where(Eq{"url": url})
count, err := r.count(sel)
return count > 0, err
}
func (r *podcastChannelRepository) Create(channel *model.PodcastChannel) error {
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
now := time.Now()
channel.CreatedAt = now
channel.UpdatedAt = now
if channel.ID == "" {
channel.ID = id.NewRandom()
}
_, err := r.put(channel.ID, channel)
return err
}
func (r *podcastChannelRepository) UpdateChannel(channel *model.PodcastChannel) error {
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
channel.UpdatedAt = time.Now()
_, err := r.put(channel.ID, channel)
return err
}
func (r *podcastChannelRepository) Delete(chanID string) error {
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
return r.delete(Eq{"id": chanID})
}
func (r *podcastChannelRepository) EntityName() string {
return "podcast_channel"
}
func (r *podcastChannelRepository) NewInstance() any {
return &model.PodcastChannel{}
}
func (r *podcastChannelRepository) Read(chanID string) (any, error) {
return r.Get(chanID)
}
func (r *podcastChannelRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*")
var channels model.PodcastChannels
err := r.queryAll(sel, &channels)
return channels, err
}
func (r *podcastChannelRepository) Save(entity any) (string, error) {
ch := entity.(*model.PodcastChannel)
if !r.isPermitted() {
return "", rest.ErrPermissionDenied
}
err := r.Create(ch)
if errors.Is(err, model.ErrNotFound) {
return "", rest.ErrNotFound
}
return ch.ID, err
}
func (r *podcastChannelRepository) Update(id string, entity any, cols ...string) error {
ch := entity.(*model.PodcastChannel)
ch.ID = id
if !r.isPermitted() {
return rest.ErrPermissionDenied
}
return r.UpdateChannel(ch)
}
func (r *podcastChannelRepository) Count(options ...rest.QueryOptions) (int64, error) {
sql := r.newSelect(r.parseRestOptions(r.ctx, options...))
return r.count(sql)
}
var _ model.PodcastChannelRepository = (*podcastChannelRepository)(nil)
var _ rest.Repository = (*podcastChannelRepository)(nil)

View file

@ -0,0 +1,136 @@
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 adminRepo model.PodcastChannelRepository
var userRepo model.PodcastChannelRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
adminCtx := request.WithUser(ctx, adminUser)
userCtx := request.WithUser(ctx, regularUser)
adminRepo = NewPodcastChannelRepository(adminCtx, GetDBXBuilder())
userRepo = NewPodcastChannelRepository(userCtx, GetDBXBuilder())
})
Describe("Get", func() {
It("returns an existing channel", func() {
ch, err := adminRepo.Get("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 := adminRepo.Get("no-such-id")
Expect(err).To(MatchError(model.ErrNotFound))
})
})
Describe("GetAll", func() {
It("returns all channels without episodes", func() {
channels, err := adminRepo.GetAll(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 := adminRepo.GetAll(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 := adminRepo.Create(ch)
Expect(err).ToNot(HaveOccurred())
Expect(ch.ID).ToNot(BeEmpty())
saved, err := adminRepo.Get(ch.ID)
Expect(err).ToNot(HaveOccurred())
Expect(saved.Title).To(Equal("New Podcast"))
// cleanup
_ = adminRepo.Delete(ch.ID)
})
It("denies non-admin users", func() {
err := userRepo.Create(&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,
}
_ = adminRepo.Create(ch)
ch.Title = "After Update"
err := adminRepo.UpdateChannel(ch)
Expect(err).ToNot(HaveOccurred())
saved, _ := adminRepo.Get(ch.ID)
Expect(saved.Title).To(Equal("After Update"))
// cleanup
_ = adminRepo.Delete(ch.ID)
})
})
Describe("Delete", func() {
It("deletes an existing channel", func() {
ch := &model.PodcastChannel{URL: "https://del.example.com/feed.xml", Status: model.PodcastStatusNew}
_ = adminRepo.Create(ch)
err := adminRepo.Delete(ch.ID)
Expect(err).ToNot(HaveOccurred())
_, err = adminRepo.Get(ch.ID)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("denies non-admin users", func() {
err := userRepo.Delete("pc-1")
Expect(err).To(MatchError(rest.ErrPermissionDenied))
})
})
Describe("Regular user read access", func() {
It("allows regular users to read channels", func() {
channels, err := userRepo.GetAll(false)
Expect(err).ToNot(HaveOccurred())
Expect(channels).ToNot(BeEmpty())
})
})
})

View file

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

View file

@ -0,0 +1,132 @@
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 repo model.PodcastEpisodeRepository
BeforeEach(func() {
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
repo = NewPodcastEpisodeRepository(ctx, GetDBXBuilder())
})
Describe("Get", func() {
It("returns an existing episode", func() {
ep, err := repo.Get("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("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(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(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("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("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("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("pc-1", "no-such-guid")
Expect(err).To(MatchError(model.ErrNotFound))
})
It("returns ErrNotFound when channel does not match", func() {
_, err := repo.GetByGUID("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(ep)
Expect(err).ToNot(HaveOccurred())
Expect(ep.ID).ToNot(BeEmpty())
err = repo.Delete(ep.ID)
Expect(err).ToNot(HaveOccurred())
_, err = repo.Get(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(ep)
ep.Status = model.PodcastStatusCompleted
ep.Path = "/podcasts/pc-1/ep.mp3"
err := repo.Update(ep)
Expect(err).ToNot(HaveOccurred())
saved, _ := repo.Get(ep.ID)
Expect(saved.Status).To(Equal(model.PodcastStatusCompleted))
Expect(saved.Path).To(Equal("/podcasts/pc-1/ep.mp3"))
// cleanup
_ = repo.Delete(ep.ID)
})
})
})

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 {
api.R(r, "/player", model.Player{}, true)
api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
api.addRadioRoute(r)
api.addPodcastRoute(r)
api.R(r, "/tag", model.Tag{}, false)
api.R(r, "/scrobble", model.Scrobble{}, false)
if conf.Server.EnableSharing {

View file

@ -0,0 +1,45 @@
package nativeapi
import (
"context"
"encoding/json"
"net/http"
"github.com/deluan/rest"
"github.com/go-chi/chi/v5"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
)
func (api *Router) addPodcastRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
return api.ds.Resource(ctx, model.PodcastChannel{})
}
r.Route("/podcast", func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
r.Get("/preview", api.podcastPreview)
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", rest.Get(constructor))
r.Delete("/", rest.Delete(constructor))
})
})
}
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(r.Context()).ExistsByURL(feedURL)
feed.AlreadyExists = exists
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(feed)
}

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()
@ -225,9 +229,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

@ -16,6 +16,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "transcoding", Versions: []int32{1}},
{Name: "playbackReport", Versions: []int32{1}},
{Name: "topSongsByArtistId", Versions: []int32{1}},
{Name: "getPodcastEpisode", Versions: []int32{1}},
}
if api.sonic != nil && api.sonic.HasProvider() {
extensions = append(extensions, responses.OpenSubsonicExtension{

View file

@ -41,47 +41,16 @@ 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 6 OpenSubsonicExtensions without sonicSimilarity", func() {
It("should return the base 8 OpenSubsonicExtensions without sonicSimilarity", func() {
router.ServeHTTP(w, r)
// Make sure the endpoint is public, by not passing any authentication
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var response responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(7),
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}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
))
Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
)
})
})
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)
})
It("should return 7 extensions including sonicSimilarity", func() {
router.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var response responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
@ -93,8 +62,41 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}),
))
Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
)
})
})
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, nil, sonicService)
})
It("should return 9 extensions including sonicSimilarity and getPodcastEpisode", func() {
router.ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusOK))
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
var response responses.JsonWrapper
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
HaveLen(9),
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}}),
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}),
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
))
})
})

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

@ -0,0 +1,216 @@
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(ctx)
var channels model.PodcastChannels
var err error
if id != "" {
ch, e := chRepo.Get(id)
if e != nil {
return nil, e
}
channels = model.PodcastChannels{*ch}
} else {
channels, err = chRepo.GetAll(includeEpisodes)
if err != nil {
return nil, err
}
}
if includeEpisodes && id != "" {
eps, e := api.ds.PodcastEpisode(ctx).GetByChannel(id)
if e != nil {
return nil, e
}
channels[0].Episodes = eps
}
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,
}
if includeEpisodes {
for _, ep := range ch.Episodes {
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(r.Context()).GetNewest(count)
if err != nil {
return nil, err
}
resp := newResponse()
resp.NewestPodcasts = &responses.NewestPodcasts{}
for _, ep := range eps {
child := responses.Child{
Id: ep.ID,
Title: ep.Title,
IsDir: false,
Parent: ep.ChannelID,
Duration: int32(ep.Duration),
Size: ep.Size,
BitRate: int32(ep.BitRate),
Suffix: ep.Suffix,
ContentType: ep.ContentType,
Type: "podcast",
ChannelId: ep.ChannelID,
Description: ep.Description,
Status: string(ep.Status),
}
if !ep.PublishDate.IsZero() {
child.PublishDate = ep.PublishDate.UTC().Format(time.RFC3339)
}
resp.NewestPodcasts.Episode = append(resp.NewestPodcasts.Episode, child)
}
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
}
ep, err := api.ds.PodcastEpisode(r.Context()).Get(id)
if err != nil {
return nil, err
}
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,
}
if !ep.PublishDate.IsZero() {
re.PublishDate = ep.PublishDate.UTC().Format(time.RFC3339)
}
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,302 @@
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", PublishDate: now.Add(-time.Hour), Status: model.PodcastStatusCompleted},
"ep-2": {ID: "ep-2", Title: "Ep2", ChannelID: "ch-1", PublishDate: now, Status: model.PodcastStatusNew},
}
})
It("returns episodes in Child 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("sets type to podcast", 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.Type).To(Equal("podcast"))
}
})
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())
})
})
})

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"`
@ -164,7 +168,12 @@ type Child struct {
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"`
BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"`
*OpenSubsonicChild `xml:",omitempty" json:",omitempty"`
// Podcast-specific fields (used in getNewestPodcasts)
ChannelId string `xml:"channelId,attr,omitempty" json:"channelId,omitempty"`
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
Status string `xml:"status,attr,omitempty" json:"status,omitempty"`
PublishDate string `xml:"publishDate,attr,omitempty" json:"publishDate,omitempty"`
*OpenSubsonicChild `xml:",omitempty" json:",omitempty"`
}
type OpenSubsonicChild struct {
@ -685,6 +694,45 @@ 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 []Child `xml:"episode,omitempty" json:"episode,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"`
}
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"`
}
// StreamDetails describes audio stream properties for transcoding decisions
type StreamDetails struct {
Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"`

View file

@ -30,6 +30,8 @@ type MockDataStore struct {
MockedPlugin model.PluginRepository
MockedArtwork model.ArtworkRepository
MockedArtworkQueue model.ArtworkQueueRepository
MockedPodcastChannel model.PodcastChannelRepository
MockedPodcastEpisode model.PodcastEpisodeRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
@ -321,6 +323,32 @@ func (db *MockDataStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRep
return db.MockedArtworkQueue
}
func (db *MockDataStore) PodcastChannel(ctx context.Context) model.PodcastChannelRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastChannel != nil {
return db.MockedPodcastChannel
}
if db.RealDS != nil {
return db.RealDS.PodcastChannel(ctx)
}
db.MockedPodcastChannel = CreateMockPodcastChannelRepo()
return db.MockedPodcastChannel
}
func (db *MockDataStore) PodcastEpisode(ctx context.Context) model.PodcastEpisodeRepository {
db.repoMu.Lock()
defer db.repoMu.Unlock()
if db.MockedPodcastEpisode != nil {
return db.MockedPodcastEpisode
}
if db.RealDS != nil {
return db.RealDS.PodcastEpisode(ctx)
}
db.MockedPodcastEpisode = CreateMockPodcastEpisodeRepo()
return db.MockedPodcastEpisode
}
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
return block(db)
}
@ -343,6 +371,8 @@ func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepo
return db.Playlist(ctx).(model.ResourceRepository)
case model.Radio, *model.Radio:
return db.Radio(ctx).(model.ResourceRepository)
case model.PodcastChannel, *model.PodcastChannel:
return db.PodcastChannel(ctx).(model.ResourceRepository)
case model.Share, *model.Share:
return db.Share(ctx).(model.ResourceRepository)
case model.Genre, *model.Genre:

View file

@ -0,0 +1,69 @@
package tests
import (
"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(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(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(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(channel *model.PodcastChannel) error {
if m.Err {
return errors.New("error")
}
m.Data[channel.ID] = channel
return nil
}
func (m *MockPodcastChannelRepo) Delete(chanID string) error {
if m.Err {
return errors.New("error")
}
if _, ok := m.Data[chanID]; !ok {
return model.ErrNotFound
}
delete(m.Data, chanID)
return nil
}

View file

@ -0,0 +1,98 @@
package tests
import (
"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(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(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) GetByChannel(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(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(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(ep *model.PodcastEpisode) error {
if m.Err {
return errors.New("error")
}
m.Data[ep.ID] = ep
return nil
}
func (m *MockPodcastEpisodeRepo) Delete(epID string) error {
if m.Err {
return errors.New("error")
}
delete(m.Data, epID)
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

@ -138,7 +138,8 @@
"actions": {
"topSongs": "Top Songs",
"shuffle": "Shuffle",
"radio": "Radio"
"radio": "Radio",
"podcast": "Podcasts"
}
},
"user": {
@ -251,6 +252,42 @@
"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",
"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,53 @@ 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
if (record?.genre === 'Podcast' && !record?.artist) {
return <span>-</span>
}
return <ArtistLinkField {...props} />
}
PodcastAwareArtistField.defaultProps = { addLabel: true, sortBy: 'artist' }
const useStyles = makeStyles(
(theme) => ({
root: {},
@ -148,8 +178,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,47 @@
import React from 'react'
import { IconButton, CircularProgress } from '@material-ui/core'
import GetAppIcon from '@material-ui/icons/GetApp'
import DeleteIcon from '@material-ui/icons/Delete'
import subsonic from '../subsonic'
const EpisodeActions = ({ episode, onRefresh }) => {
const handleDownload = async () => {
await subsonic.downloadPodcastEpisode(episode.id)
onRefresh?.()
}
const handleDelete = async () => {
await subsonic.deletePodcastEpisode(episode.id)
onRefresh?.()
}
if (episode.status === 'downloading') {
return <CircularProgress size={20} />
}
if (episode.status === 'completed') {
return (
<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,72 @@
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({}) },
}))
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,
}
})
import subsonic from '../subsonic'
import PodcastCreate from './PodcastCreate'
describe('PodcastCreate', () => {
beforeEach(() => vi.clearAllMocks())
it('renders a URL input field', () => {
render(<PodcastCreate />)
expect(screen.getByRole('textbox')).toBeTruthy()
})
it('calls createPodcastChannel with the entered URL on submit', async () => {
render(<PodcastCreate />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://example.com/feed.xml' },
})
fireEvent.submit(screen.getByRole('form'))
await waitFor(() => {
expect(subsonic.createPodcastChannel).toHaveBeenCalledWith(
'https://example.com/feed.xml',
)
})
})
it('redirects to /podcast after successful submit', async () => {
render(<PodcastCreate />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://example.com/feed.xml' },
})
fireEvent.submit(screen.getByRole('form'))
await waitFor(() => {
expect(mockRedirect).toHaveBeenCalledWith('/podcast')
})
})
it('notifies on success', async () => {
render(<PodcastCreate />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://example.com/feed.xml' },
})
fireEvent.submit(screen.getByRole('form'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
'resources.podcast.notifications.channelAdded',
)
})
})
})

View file

@ -0,0 +1,90 @@
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 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} 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,192 @@
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 CoverArtField = ({ record }) => {
if (!record) return null
if (record.imageUrl) {
return (
<Avatar src={record.imageUrl} 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)
notify('resources.podcast.notifications.urlCopied')
}
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
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,220 @@
import React, { useEffect, 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 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([])
const loadEpisodes = () => {
if (!record?.id) return
subsonic
.getPodcasts(record.id, true)
.then((res) => {
const channels = res?.json?.['subsonic-response']?.podcasts?.channel || []
const ch = channels.find((c) => c.id === record.id)
setEpisodes(ch?.episode || [])
})
.catch(() => {})
}
useEffect(loadEpisodes, [record?.id])
// Subscribe to SSE progress only while episodes are downloading
const hasDownloading = episodes.some((ep) => ep.status === 'downloading')
useEffect(() => {
if (!hasDownloading) return
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)
}, [hasDownloading])
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}
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} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}
export default PodcastShow

View file

@ -0,0 +1,47 @@
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} />
{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')
})
})