mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-11 03:47:18 +02:00
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:
parent
16567f147b
commit
775747264b
50 changed files with 3769 additions and 36 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
401
core/podcasts/podcasts.go
Normal 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)
|
||||
}
|
||||
17
core/podcasts/podcasts_suite_test.go
Normal file
17
core/podcasts/podcasts_suite_test.go
Normal 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")
|
||||
}
|
||||
256
core/podcasts/podcasts_test.go
Normal file
256
core/podcasts/podcasts_test.go
Normal 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
202
core/podcasts/rss.go
Normal 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
116
core/podcasts/rss_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
63
db/migrations/20260427165650_add_podcast.go
Normal file
63
db/migrations/20260427165650_add_podcast.go
Normal 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
|
||||
}
|
||||
21
db/migrations/20260427184047_add_podcast_downloaded_bytes.go
Normal file
21
db/migrations/20260427184047_add_podcast_downloaded_bytes.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
71
model/podcast.go
Normal 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
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
141
persistence/podcast_channel_repository.go
Normal file
141
persistence/podcast_channel_repository.go
Normal 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)
|
||||
136
persistence/podcast_channel_repository_test.go
Normal file
136
persistence/podcast_channel_repository_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
78
persistence/podcast_episode_repository.go
Normal file
78
persistence/podcast_episode_repository.go
Normal 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)
|
||||
132
persistence/podcast_episode_repository_test.go
Normal file
132
persistence/podcast_episode_repository_test.go
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
45
server/nativeapi/podcasts.go
Normal file
45
server/nativeapi/podcasts.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
216
server/subsonic/podcasts.go
Normal 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
|
||||
}
|
||||
302
server/subsonic/podcasts_test.go
Normal file
302
server/subsonic/podcasts_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
69
tests/mock_podcast_channel_repo.go
Normal file
69
tests/mock_podcast_channel_repo.go
Normal 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
|
||||
}
|
||||
98
tests/mock_podcast_episode_repo.go
Normal file
98
tests/mock_podcast_episode_repo.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
5
ui/src/actions/podcastView.js
Normal file
5
ui/src/actions/podcastView.js
Normal 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 })
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
47
ui/src/podcast/EpisodeActions.jsx
Normal file
47
ui/src/podcast/EpisodeActions.jsx
Normal 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
|
||||
83
ui/src/podcast/EpisodeActions.test.jsx
Normal file
83
ui/src/podcast/EpisodeActions.test.jsx
Normal 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')
|
||||
})
|
||||
})
|
||||
170
ui/src/podcast/PodcastCreate.jsx
Normal file
170
ui/src/podcast/PodcastCreate.jsx
Normal 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
|
||||
72
ui/src/podcast/PodcastCreate.test.jsx
Normal file
72
ui/src/podcast/PodcastCreate.test.jsx
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
90
ui/src/podcast/PodcastGridView.jsx
Normal file
90
ui/src/podcast/PodcastGridView.jsx
Normal 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)
|
||||
192
ui/src/podcast/PodcastList.jsx
Normal file
192
ui/src/podcast/PodcastList.jsx
Normal 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
|
||||
220
ui/src/podcast/PodcastShow.jsx
Normal file
220
ui/src/podcast/PodcastShow.jsx
Normal 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
|
||||
47
ui/src/podcast/StatusBadge.jsx
Normal file
47
ui/src/podcast/StatusBadge.jsx
Normal 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
|
||||
53
ui/src/podcast/StatusBadge.test.jsx
Normal file
53
ui/src/podcast/StatusBadge.test.jsx
Normal 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
26
ui/src/podcast/index.jsx
Normal 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 }
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
15
ui/src/reducers/podcastView.js
Normal file
15
ui/src/reducers/podcastView.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue