mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 02:17:25 +02:00
Merge ff65c6713f into caa2f8a0c0
This commit is contained in:
commit
aaf8f570d0
88 changed files with 8973 additions and 125 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -48,3 +48,7 @@ zz_*_test.go
|
|||
# wazero compilation cache for the plugins test suite
|
||||
/plugins/testdata/.wazero-cache/
|
||||
/plugins/testdata/*.stage/
|
||||
|
||||
# Downloaded podcast episode audio files should not be tracked (DataFolder defaults to ".")
|
||||
/podcasts/
|
||||
/ui/podcasts/
|
||||
|
|
|
|||
2
Makefile
2
Makefile
|
|
@ -151,7 +151,7 @@ format: ##@Development Format code
|
|||
.PHONY: format
|
||||
|
||||
wire: check_go_env ##@Development Update Dependency Injection
|
||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./cmd
|
||||
.PHONY: wire
|
||||
|
||||
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
|
|
@ -111,8 +112,9 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
podcastsPodcasts := podcasts.NewPodcastService(ctx, dataStore, fFmpeg, broker)
|
||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, podcastsPodcasts, sonicSonic)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,12 +37,13 @@ type TranscodeOptions struct {
|
|||
|
||||
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
|
||||
type AudioProbeResult struct {
|
||||
Codec string `json:"codec"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
BitRate int `json:"bitRate"`
|
||||
SampleRate int `json:"sampleRate"`
|
||||
BitDepth int `json:"bitDepth"`
|
||||
Channels int `json:"channels"`
|
||||
Codec string `json:"codec"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
BitRate int `json:"bitRate"`
|
||||
SampleRate int `json:"sampleRate"`
|
||||
BitDepth int `json:"bitDepth"`
|
||||
Channels int `json:"channels"`
|
||||
Duration float64 `json:"duration"`
|
||||
}
|
||||
|
||||
type FFmpeg interface {
|
||||
|
|
@ -234,7 +235,8 @@ type probeOutput struct {
|
|||
}
|
||||
|
||||
type probeFormat struct {
|
||||
BitRate string `json:"bit_rate"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
type probeStream struct {
|
||||
|
|
@ -290,6 +292,11 @@ func parseProbeOutput(data []byte) (*AudioProbeResult, error) {
|
|||
result.BitRate = bps / 1000
|
||||
}
|
||||
|
||||
// Duration from format section (seconds as float string)
|
||||
if output.Format.Duration != "" {
|
||||
result.Duration, _ = strconv.ParseFloat(output.Format.Duration, 64)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
776
core/podcasts/podcasts.go
Normal file
776
core/podcasts/podcasts.go
Normal file
|
|
@ -0,0 +1,776 @@
|
|||
package podcasts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
)
|
||||
|
||||
const podcastLibraryName = "Podcasts"
|
||||
|
||||
type Podcasts interface {
|
||||
AddChannel(ctx context.Context, rssURL string) error
|
||||
RefreshChannels(ctx context.Context) error
|
||||
DeleteChannel(ctx context.Context, id string) error
|
||||
DeleteEpisode(ctx context.Context, id string) error
|
||||
DownloadEpisode(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type podcastService struct {
|
||||
rootCtx context.Context
|
||||
ds model.DataStore
|
||||
ff ffmpeg.FFmpeg
|
||||
broker events.Broker
|
||||
}
|
||||
|
||||
func NewPodcastService(rootCtx context.Context, ds model.DataStore, ff ffmpeg.FFmpeg, broker events.Broker) Podcasts {
|
||||
return &podcastService{rootCtx: rootCtx, ds: ds, ff: ff, broker: broker}
|
||||
}
|
||||
|
||||
// podcastsDir is where downloaded episodes live and the root of the podcast
|
||||
// virtual library.
|
||||
func podcastsDir() string {
|
||||
return filepath.Join(conf.Server.DataFolder.String(), "podcasts")
|
||||
}
|
||||
|
||||
// podcastLibraryID returns the ID of the podcast virtual library, creating it
|
||||
// if it doesn't exist. The library root is DataFolder/podcasts so that MediaFile
|
||||
// paths stored as "{ch}/{ep}.mp3" resolve correctly via AbsolutePath().
|
||||
//
|
||||
// Episodes are registered as MediaFiles by the podcast service itself, so the
|
||||
// library must not be picked up by the regular scanner: an empty .ndignore in
|
||||
// the root makes the scanner skip everything below it.
|
||||
func (s *podcastService) podcastLibraryID(ctx context.Context) (int, error) {
|
||||
libs, err := s.ds.Library().GetAll(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, lib := range libs {
|
||||
if lib.Name == podcastLibraryName {
|
||||
return lib.ID, nil
|
||||
}
|
||||
}
|
||||
root := podcastsDir()
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
return 0, fmt.Errorf("creating podcasts dir: %w", err)
|
||||
}
|
||||
ignoreFile := filepath.Join(root, consts.ScanIgnoreFile)
|
||||
if _, statErr := os.Stat(ignoreFile); os.IsNotExist(statErr) {
|
||||
if err := os.WriteFile(ignoreFile, nil, 0644); err != nil {
|
||||
return 0, fmt.Errorf("creating %s: %w", consts.ScanIgnoreFile, err)
|
||||
}
|
||||
}
|
||||
lib := &model.Library{
|
||||
Name: podcastLibraryName,
|
||||
Path: root,
|
||||
DefaultNewUsers: true, // users created later get access automatically
|
||||
}
|
||||
if err := s.ds.Library().Put(ctx, lib); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Put only auto-assigns admins; give existing regular users access too.
|
||||
s.assignLibraryToExistingUsers(ctx, lib.ID)
|
||||
return lib.ID, nil
|
||||
}
|
||||
|
||||
func (s *podcastService) assignLibraryToExistingUsers(ctx context.Context, libID int) {
|
||||
users, err := s.ds.User().GetAll(ctx)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Failed to list users to assign podcast library", err)
|
||||
return
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.IsAdmin {
|
||||
continue
|
||||
}
|
||||
ids := []int{libID}
|
||||
for _, l := range u.Libraries {
|
||||
if l.ID != libID {
|
||||
ids = append(ids, l.ID)
|
||||
}
|
||||
}
|
||||
if err := s.ds.User().SetUserLibraries(ctx, u.ID, ids); err != nil {
|
||||
log.Warn(ctx, "Failed to assign podcast library to user", "user", u.UserName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *podcastService) AddChannel(ctx context.Context, rssURL string) error {
|
||||
exists, err := s.ds.PodcastChannel().ExistsByURL(ctx, rssURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking existing channel: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return fmt.Errorf("channel already exists: %s", rssURL)
|
||||
}
|
||||
|
||||
feed, err := fetchAndParse(rssURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("adding podcast channel: %w", err)
|
||||
}
|
||||
|
||||
ch := &model.PodcastChannel{
|
||||
URL: rssURL,
|
||||
Title: feed.Title,
|
||||
Description: feed.Description,
|
||||
ImageURL: feed.ImageURL,
|
||||
Status: model.PodcastStatusNew,
|
||||
PodcastGUID: feed.PodcastGUID,
|
||||
Locked: feed.Locked,
|
||||
LockedOwner: feed.LockedOwner,
|
||||
Medium: feed.Medium,
|
||||
UpdateFrequency: feed.UpdateFrequency,
|
||||
UpdateRRule: feed.UpdateRRule,
|
||||
Complete: feed.Complete,
|
||||
UsesPodping: feed.UsesPodping,
|
||||
LocationName: feed.LocationName,
|
||||
LocationGeo: feed.LocationGeo,
|
||||
LocationOSM: feed.LocationOSM,
|
||||
License: feed.License,
|
||||
PublisherName: feed.PublisherName,
|
||||
PublisherURL: feed.PublisherURL,
|
||||
}
|
||||
if err := s.ds.PodcastChannel().Create(ctx, ch); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Save channel-level persons
|
||||
if len(feed.Persons) > 0 {
|
||||
if err := s.ds.PodcastPerson().SaveForChannel(ctx, ch.ID, feed.Persons); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast channel persons", "channel", ch.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save podcast:funding items
|
||||
if len(feed.FundingItems) > 0 {
|
||||
for i := range feed.FundingItems {
|
||||
feed.FundingItems[i].ChannelID = ch.ID
|
||||
}
|
||||
if err := s.ds.PodcastFunding().SaveForChannel(ctx, ch.ID, feed.FundingItems); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast funding items", "channel", ch.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save podcast:image (channel level)
|
||||
if len(feed.Images) > 0 {
|
||||
if err := s.ds.PodcastImage().SaveForChannel(ctx, ch.ID, feed.Images); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast channel images", "channel", ch.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save podcast:podroll items
|
||||
if len(feed.Podroll) > 0 {
|
||||
if err := s.ds.PodcastPodroll().SaveForChannel(ctx, ch.ID, feed.Podroll); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast podroll", "channel", ch.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save podcast:liveItem entries
|
||||
for _, li := range feed.LiveItems {
|
||||
li.ChannelID = ch.ID
|
||||
if err := s.ds.PodcastLiveItem().Upsert(ctx, &li); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast live item", "channel", ch.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range feed.Episodes {
|
||||
ep := feed.Episodes[i]
|
||||
ep.ChannelID = ch.ID
|
||||
ep.Status = model.PodcastStatusNew
|
||||
transcripts := ep.Transcripts
|
||||
persons := ep.Persons
|
||||
images := ep.Images
|
||||
ep.Transcripts = nil
|
||||
ep.Persons = nil
|
||||
ep.Images = nil
|
||||
if err := s.ds.PodcastEpisode().Create(ctx, &ep); err != nil {
|
||||
return err
|
||||
}
|
||||
// Save episode transcripts
|
||||
if len(transcripts) > 0 {
|
||||
for j := range transcripts {
|
||||
transcripts[j].EpisodeID = ep.ID
|
||||
}
|
||||
if err := s.ds.PodcastTranscript().Save(ctx, transcripts); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast episode transcripts", "episode", ep.ID, err)
|
||||
}
|
||||
}
|
||||
// Save episode persons
|
||||
if len(persons) > 0 {
|
||||
if err := s.ds.PodcastPerson().SaveForEpisode(ctx, ep.ID, persons); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err)
|
||||
}
|
||||
}
|
||||
// Save episode images
|
||||
if len(images) > 0 {
|
||||
if err := s.ds.PodcastImage().SaveForEpisode(ctx, ep.ID, images); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast episode images", "episode", ep.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ch.Status = model.PodcastStatusCompleted
|
||||
return s.ds.PodcastChannel().UpdateChannel(ctx, ch)
|
||||
}
|
||||
|
||||
func (s *podcastService) RefreshChannels(ctx context.Context) error {
|
||||
channels, err := s.ds.PodcastChannel().GetAll(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ch := range channels {
|
||||
if err := s.refreshChannel(ctx, ch); err != nil {
|
||||
log.Warn(ctx, "Failed to refresh podcast channel", "channel", ch.Title, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *podcastService) refreshChannel(ctx context.Context, ch model.PodcastChannel) error {
|
||||
feed, err := fetchAndParse(ch.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh podcast:funding items
|
||||
if err := s.ds.PodcastFunding().SaveForChannel(ctx, ch.ID, feed.FundingItems); err != nil {
|
||||
log.Warn(ctx, "Failed to refresh funding items", "channel", ch.ID, err)
|
||||
}
|
||||
|
||||
// Refresh podcast:image (channel level)
|
||||
if err := s.ds.PodcastImage().SaveForChannel(ctx, ch.ID, feed.Images); err != nil {
|
||||
log.Warn(ctx, "Failed to refresh channel images", "channel", ch.ID, err)
|
||||
}
|
||||
|
||||
// Refresh podcast:podroll
|
||||
if err := s.ds.PodcastPodroll().SaveForChannel(ctx, ch.ID, feed.Podroll); err != nil {
|
||||
log.Warn(ctx, "Failed to refresh podroll", "channel", ch.ID, err)
|
||||
}
|
||||
|
||||
// Refresh podcast:liveItem
|
||||
for _, li := range feed.LiveItems {
|
||||
li.ChannelID = ch.ID
|
||||
if err := s.ds.PodcastLiveItem().Upsert(ctx, &li); err != nil {
|
||||
log.Warn(ctx, "Failed to upsert live item", "channel", ch.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
epRepo := s.ds.PodcastEpisode()
|
||||
for i := range feed.Episodes {
|
||||
ep := feed.Episodes[i]
|
||||
_, err := epRepo.GetByGUID(ctx, ch.ID, ep.GUID)
|
||||
if err == nil {
|
||||
continue // already exists
|
||||
}
|
||||
ep.ChannelID = ch.ID
|
||||
ep.Status = model.PodcastStatusNew
|
||||
transcripts := ep.Transcripts
|
||||
persons := ep.Persons
|
||||
images := ep.Images
|
||||
ep.Transcripts = nil
|
||||
ep.Persons = nil
|
||||
ep.Images = nil
|
||||
if err := epRepo.Create(ctx, &ep); err != nil {
|
||||
return err
|
||||
}
|
||||
// Save episode transcripts
|
||||
if len(transcripts) > 0 {
|
||||
for j := range transcripts {
|
||||
transcripts[j].EpisodeID = ep.ID
|
||||
}
|
||||
if err := s.ds.PodcastTranscript().Save(ctx, transcripts); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast episode transcripts", "episode", ep.ID, err)
|
||||
}
|
||||
}
|
||||
// Save episode persons
|
||||
if len(persons) > 0 {
|
||||
if err := s.ds.PodcastPerson().SaveForEpisode(ctx, ep.ID, persons); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast episode persons", "episode", ep.ID, err)
|
||||
}
|
||||
}
|
||||
// Save episode images
|
||||
if len(images) > 0 {
|
||||
if err := s.ds.PodcastImage().SaveForEpisode(ctx, ep.ID, images); err != nil {
|
||||
log.Warn(ctx, "Failed to save podcast episode images", "episode", ep.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *podcastService) DownloadEpisode(ctx context.Context, id string) error {
|
||||
ep, err := s.ds.PodcastEpisode().Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ch, err := s.ds.PodcastChannel().Get(ctx, ep.ChannelID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ep.Status = model.PodcastStatusDownloading
|
||||
ep.UpdatedAt = time.Now()
|
||||
if err := s.ds.PodcastEpisode().Update(ctx, ep); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go s.doDownload(s.rootCtx, ep, ch)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisode, ch *model.PodcastChannel) {
|
||||
suffix := ep.Suffix
|
||||
if suffix == "" {
|
||||
suffix = "mp3"
|
||||
}
|
||||
dir := filepath.Join(podcastsDir(), ep.ChannelID)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
s.setEpisodeError(ctx, ep, err)
|
||||
return
|
||||
}
|
||||
|
||||
dest := filepath.Join(dir, ep.ID+"."+suffix)
|
||||
if err := validateURL(ep.EnclosureURL); err != nil {
|
||||
s.setEpisodeError(ctx, ep, fmt.Errorf("invalid enclosure URL: %w", err))
|
||||
return
|
||||
}
|
||||
// No total Client.Timeout here: it would also cover reading the body and
|
||||
// abort large episodes. Connection-phase limits live on safeHTTPTransport.
|
||||
httpClient := &http.Client{Transport: safeHTTPTransport}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep.EnclosureURL, nil)
|
||||
if err != nil {
|
||||
s.setEpisodeError(ctx, ep, err)
|
||||
return
|
||||
}
|
||||
resp, err := httpClient.Do(req) //nolint:gosec
|
||||
if err != nil {
|
||||
s.setEpisodeError(ctx, ep, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err := fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, ep.EnclosureURL)
|
||||
s.setEpisodeError(ctx, ep, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Create the file only once we have a 200 response, so failed requests
|
||||
// don't leave empty files behind.
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
s.setEpisodeError(ctx, ep, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Use Content-Length as total size when RSS feed didn't provide it
|
||||
if resp.ContentLength > 0 && ep.Size == 0 {
|
||||
ep.Size = resp.ContentLength
|
||||
}
|
||||
|
||||
size, err := io.Copy(&progressWriter{ep: ep, ds: s.ds, broker: s.broker, ctx: ctx, w: f}, resp.Body)
|
||||
if err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(dest) // don't leave a partial file behind
|
||||
s.setEpisodeError(ctx, ep, err)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Write ID3 tags so the scanner picks up the correct metadata
|
||||
s.writeID3Tags(ctx, dest, suffix, ep.Title, ch.Title)
|
||||
|
||||
// Register as a MediaFile so /rest/stream works with the standard media file path.
|
||||
// Use the podcast virtual library (rooted at DataFolder/podcasts); store relative path.
|
||||
libID, libErr := s.podcastLibraryID(ctx)
|
||||
if libErr != nil {
|
||||
log.Warn(ctx, "Failed to get podcast library, streaming may not work", "episode", ep.ID, libErr)
|
||||
} else {
|
||||
relPath := strings.TrimPrefix(dest, podcastsDir()+string(filepath.Separator))
|
||||
now := time.Now()
|
||||
tags := model.Tags{}
|
||||
tags.Add("genre", "Podcast")
|
||||
// Reuse the existing MediaFile on re-download so Put updates it instead of
|
||||
// leaving a duplicate track behind.
|
||||
mfID := ep.StreamID
|
||||
if mfID == "" {
|
||||
mfID = id.NewRandom()
|
||||
}
|
||||
mf := &model.MediaFile{
|
||||
ID: mfID,
|
||||
LibraryID: libID,
|
||||
Path: relPath,
|
||||
Title: ep.Title,
|
||||
Album: ch.Title,
|
||||
AlbumID: ch.ID,
|
||||
Artist: "",
|
||||
AlbumArtist: ch.Title,
|
||||
Genre: "Podcast",
|
||||
Tags: tags,
|
||||
Duration: float32(ep.Duration),
|
||||
Size: size,
|
||||
BitRate: ep.BitRate,
|
||||
Suffix: suffix,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if putErr := s.ds.MediaFile().Put(ctx, mf); putErr != nil {
|
||||
log.Warn(ctx, "Failed to register podcast episode as MediaFile", "episode", ep.ID, putErr)
|
||||
} else {
|
||||
ep.StreamID = mf.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Probe actual duration and bitrate from the downloaded file
|
||||
if s.ff != nil && s.ff.IsProbeAvailable() {
|
||||
if probe, probeErr := s.ff.ProbeAudioStream(ctx, dest); probeErr == nil {
|
||||
if probe.Duration > 0 {
|
||||
ep.Duration = int(math.Round(probe.Duration))
|
||||
}
|
||||
if probe.BitRate > 0 {
|
||||
ep.BitRate = probe.BitRate
|
||||
}
|
||||
} else {
|
||||
log.Warn(ctx, "Failed to probe podcast episode duration", "episode", ep.ID, probeErr)
|
||||
}
|
||||
}
|
||||
|
||||
ep.Path = dest
|
||||
ep.Size = size
|
||||
ep.DownloadedBytes = size
|
||||
ep.Status = model.PodcastStatusCompleted
|
||||
ep.ErrorMessage = ""
|
||||
ep.UpdatedAt = time.Now()
|
||||
if err := s.ds.PodcastEpisode().Update(ctx, ep); err != nil {
|
||||
log.Error(ctx, "Failed to update episode after download", "episode", ep.ID, err)
|
||||
}
|
||||
if s.broker != nil {
|
||||
s.broker.SendBroadcastMessage(ctx, &events.PodcastEpisodeProgress{
|
||||
EpisodeID: ep.ID,
|
||||
ChannelID: ep.ChannelID,
|
||||
DownloadedBytes: size,
|
||||
Size: size,
|
||||
Duration: ep.Duration,
|
||||
Status: string(model.PodcastStatusCompleted),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *podcastService) setEpisodeError(ctx context.Context, ep *model.PodcastEpisode, err error) {
|
||||
ep.Status = model.PodcastStatusError
|
||||
ep.ErrorMessage = err.Error()
|
||||
ep.UpdatedAt = time.Now()
|
||||
if updateErr := s.ds.PodcastEpisode().Update(ctx, ep); updateErr != nil {
|
||||
log.Error(ctx, "Failed to set episode error status", "episode", ep.ID, updateErr)
|
||||
}
|
||||
if s.broker != nil {
|
||||
s.broker.SendBroadcastMessage(ctx, &events.PodcastEpisodeProgress{
|
||||
EpisodeID: ep.ID,
|
||||
ChannelID: ep.ChannelID,
|
||||
Status: string(model.PodcastStatusError),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeMetadata removes null bytes and trims whitespace from ffmpeg metadata values.
|
||||
// Since exec.Command passes args directly (no shell), only null bytes need sanitizing.
|
||||
func sanitizeMetadata(s string) string {
|
||||
return strings.ReplaceAll(strings.TrimSpace(s), "\x00", "")
|
||||
}
|
||||
|
||||
func (s *podcastService) writeID3Tags(ctx context.Context, dest, suffix, title, album string) {
|
||||
if s.ff == nil {
|
||||
return
|
||||
}
|
||||
ffmpegPath, err := s.ff.CmdPath()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp := dest + ".tmp." + suffix
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath,
|
||||
"-i", dest,
|
||||
"-metadata", "title="+sanitizeMetadata(title),
|
||||
"-metadata", "album="+sanitizeMetadata(album),
|
||||
"-metadata", "artist=",
|
||||
"-metadata", "genre=Podcast",
|
||||
"-c", "copy", "-y", tmp,
|
||||
)
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Warn(ctx, "Failed to write ID3 tags to podcast episode", "episode", dest, err)
|
||||
_ = os.Remove(tmp)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
log.Warn(ctx, "Failed to replace podcast file with tagged version", err)
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *podcastService) DeleteEpisode(ctx context.Context, id string) error {
|
||||
ep, err := s.ds.PodcastEpisode().Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ep.Path != "" {
|
||||
_ = os.Remove(ep.Path)
|
||||
ep.Path = ""
|
||||
}
|
||||
// Remove the registered MediaFile so it can be re-registered on next download
|
||||
if ep.StreamID != "" {
|
||||
_ = s.ds.MediaFile().Delete(ctx, ep.StreamID)
|
||||
ep.StreamID = ""
|
||||
}
|
||||
ep.Status = model.PodcastStatusNew
|
||||
ep.ErrorMessage = ""
|
||||
ep.Size = 0
|
||||
ep.DownloadedBytes = 0
|
||||
ep.Duration = 0
|
||||
ep.BitRate = 0
|
||||
ep.UpdatedAt = time.Now()
|
||||
return s.ds.PodcastEpisode().Update(ctx, ep)
|
||||
}
|
||||
|
||||
func (s *podcastService) DeleteChannel(ctx context.Context, id string) error {
|
||||
episodes, err := s.ds.PodcastEpisode().GetByChannel(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ep := range episodes {
|
||||
if ep.Path != "" {
|
||||
_ = os.Remove(ep.Path)
|
||||
}
|
||||
}
|
||||
// The channel-/episode-scoped detail tables have no FK cascade, and the
|
||||
// registered MediaFiles are not FK-linked either, so clean them up here.
|
||||
return s.ds.WithTx(func(tx model.DataStore) error {
|
||||
for _, ep := range episodes {
|
||||
if ep.StreamID != "" {
|
||||
if err := tx.MediaFile().Delete(ctx, ep.StreamID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.PodcastPerson().SaveForEpisode(ctx, ep.ID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.PodcastImage().SaveForEpisode(ctx, ep.ID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.PodcastPerson().SaveForChannel(ctx, id, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.PodcastFunding().SaveForChannel(ctx, id, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.PodcastImage().SaveForChannel(ctx, id, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.PodcastPodroll().SaveForChannel(ctx, id, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.PodcastLiveItem().DeleteByChannel(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.PodcastChannel().Delete(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
// progressWriter wraps an io.Writer and periodically saves download progress to DB.
|
||||
type progressWriter struct {
|
||||
ep *model.PodcastEpisode
|
||||
ds model.DataStore
|
||||
broker events.Broker
|
||||
ctx context.Context
|
||||
w io.Writer
|
||||
written int64
|
||||
lastDB int64
|
||||
}
|
||||
|
||||
const progressUpdateInterval = 512 * 1024 // update DB every 512 KB
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||
n, err := pw.w.Write(p)
|
||||
pw.written += int64(n)
|
||||
if pw.written-pw.lastDB >= progressUpdateInterval {
|
||||
pw.ep.DownloadedBytes = pw.written
|
||||
pw.ep.UpdatedAt = time.Now()
|
||||
_ = pw.ds.PodcastEpisode().Update(pw.ctx, pw.ep)
|
||||
pw.lastDB = pw.written
|
||||
if pw.broker != nil {
|
||||
pw.broker.SendBroadcastMessage(pw.ctx, &events.PodcastEpisodeProgress{
|
||||
EpisodeID: pw.ep.ID,
|
||||
ChannelID: pw.ep.ChannelID,
|
||||
DownloadedBytes: pw.written,
|
||||
Size: pw.ep.Size,
|
||||
})
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// validateURL rejects any URL that is not a plain http/https request to a
|
||||
// named host. It exists to prevent SSRF: without it, an authenticated user
|
||||
// could point the preview/download endpoints at internal services, cloud
|
||||
// metadata endpoints (e.g. 169.254.169.254), or any other host only
|
||||
// reachable from the server itself. This is a cheap, fast-failing check on
|
||||
// the URL's shape - the actual IP-level check happens per-connection in
|
||||
// safeHTTPTransport below, since the host a URL names and the IP it
|
||||
// resolves to at request time aren't guaranteed to be the same thing.
|
||||
func validateURL(rawURL string) error {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing URL: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("unsupported URL scheme %q, only http/https are allowed", u.Scheme)
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return fmt.Errorf("URL has no host")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isReservedIP reports whether ip is a loopback, private, link-local,
|
||||
// multicast, or otherwise non-routable/internal address. Cloud metadata
|
||||
// endpoints (e.g. AWS/GCP/Azure's 169.254.169.254) fall under the
|
||||
// link-local range, so they're covered without a special case.
|
||||
//
|
||||
// This is a var, not a plain func, only so AllowLoopbackHTTPForTests (below)
|
||||
// can narrow it for test binaries - production code never reassigns it.
|
||||
var isReservedIP = func(ip net.IP) bool {
|
||||
return ip.IsLoopback() ||
|
||||
ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() ||
|
||||
ip.IsInterfaceLocalMulticast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified() ||
|
||||
inReservedNets(ip)
|
||||
}
|
||||
|
||||
// reservedNets are special-purpose ranges that net.IP's helpers don't cover.
|
||||
var reservedNets = func() []*net.IPNet {
|
||||
var nets []*net.IPNet
|
||||
for _, cidr := range []string{
|
||||
"0.0.0.0/8", // "this" network
|
||||
"100.64.0.0/10", // carrier-grade NAT
|
||||
"192.0.0.0/24", // IETF protocol assignments
|
||||
"198.18.0.0/15", // benchmarking
|
||||
"64:ff9b::/96", // NAT64
|
||||
} {
|
||||
_, n, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
nets = append(nets, n)
|
||||
}
|
||||
return nets
|
||||
}()
|
||||
|
||||
func inReservedNets(ip net.IP) bool {
|
||||
for _, n := range reservedNets {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AllowLoopbackHTTPForTests relaxes safeHTTPTransport's SSRF guard to permit
|
||||
// loopback addresses (127.0.0.0/8, ::1) - every other reserved/private/
|
||||
// link-local range (including cloud metadata endpoints) is still refused.
|
||||
// It exists because httptest.Server always binds to loopback, so the podcast
|
||||
// test suite needs a way to point the service at one without disabling the
|
||||
// guard entirely. Not for production use.
|
||||
func AllowLoopbackHTTPForTests() {
|
||||
strict := isReservedIP
|
||||
isReservedIP = func(ip net.IP) bool {
|
||||
if ip.IsLoopback() {
|
||||
return false
|
||||
}
|
||||
return strict(ip)
|
||||
}
|
||||
}
|
||||
|
||||
// safeHTTPTransport is shared by every outbound podcast HTTP request (RSS
|
||||
// feed fetch and episode download). Its DialContext resolves the host and
|
||||
// checks isReservedIP at the moment of connection, not just once via
|
||||
// validateURL up front - so a DNS answer that changes between the URL
|
||||
// check and the actual TCP connect (DNS rebinding) can't be used to reach
|
||||
// a reserved address that validateURL alone would have caught.
|
||||
var safeHTTPTransport = &http.Transport{
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing address %q: %w", addr, err)
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving host %q: %w", host, err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("host %q did not resolve to any address", host)
|
||||
}
|
||||
dialer := net.Dialer{Timeout: 15 * time.Second}
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
if isReservedIP(ip.IP) {
|
||||
lastErr = fmt.Errorf("host %q resolves to a reserved/internal address (%s), refusing to connect", host, ip.IP)
|
||||
continue
|
||||
}
|
||||
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, lastErr
|
||||
},
|
||||
}
|
||||
|
||||
const maxFeedSize = 32 << 20 // 32 MiB
|
||||
|
||||
func fetchAndParse(rssURL string) (*rssFeed, error) {
|
||||
if err := validateURL(rssURL); err != nil {
|
||||
return nil, fmt.Errorf("invalid RSS feed URL: %w", err)
|
||||
}
|
||||
httpClient := &http.Client{Timeout: 15 * time.Second, Transport: safeHTTPTransport}
|
||||
resp, err := httpClient.Get(rssURL) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching RSS feed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read at most one byte past the limit so an oversized feed is detected
|
||||
// without buffering it entirely in memory.
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxFeedSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading RSS feed: %w", err)
|
||||
}
|
||||
if len(data) > maxFeedSize {
|
||||
return nil, fmt.Errorf("RSS feed exceeds %d bytes", maxFeedSize)
|
||||
}
|
||||
|
||||
return ParseRSSFeed(data)
|
||||
}
|
||||
24
core/podcasts/podcasts_suite_test.go
Normal file
24
core/podcasts/podcasts_suite_test.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package podcasts_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestPodcasts(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
// This suite's specs fetch RSS feeds/episodes from an httptest.Server, which
|
||||
// always binds to loopback - safeHTTPTransport's SSRF guard would otherwise
|
||||
// refuse every request the suite makes. See AllowLoopbackHTTPForTests's own
|
||||
// doc comment: every other reserved/private/link-local address is still
|
||||
// refused, so this doesn't disable the guard, just narrows it for this run.
|
||||
podcasts.AllowLoopbackHTTPForTests()
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Podcasts Suite")
|
||||
}
|
||||
585
core/podcasts/podcasts_test.go
Normal file
585
core/podcasts/podcasts_test.go
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
package podcasts_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PodcastService", func() {
|
||||
var svc podcasts.Podcasts
|
||||
var ds *tests.MockDataStore
|
||||
var channelRepo *tests.MockPodcastChannelRepo
|
||||
var episodeRepo *tests.MockPodcastEpisodeRepo
|
||||
var mockServer *httptest.Server
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
channelRepo = tests.CreateMockPodcastChannelRepo()
|
||||
episodeRepo = tests.CreateMockPodcastEpisodeRepo()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedPodcastChannel: channelRepo,
|
||||
MockedPodcastEpisode: episodeRepo,
|
||||
}
|
||||
ctx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true})
|
||||
|
||||
mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".mp3") {
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.Write([]byte("fake audio data"))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
fmt.Fprint(w, testRSSFeed)
|
||||
}))
|
||||
DeferCleanup(mockServer.Close)
|
||||
|
||||
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
||||
svc = podcasts.NewPodcastService(ctx, ds, nil, nil)
|
||||
})
|
||||
|
||||
Describe("AddChannel", func() {
|
||||
It("creates the channel in DB", func() {
|
||||
err := svc.AddChannel(ctx, mockServer.URL+"/feed.xml")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(channelRepo.Data).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("creates episodes from the feed", func() {
|
||||
err := svc.AddChannel(ctx, mockServer.URL+"/feed.xml")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(episodeRepo.Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("sets channel title from RSS", func() {
|
||||
_ = svc.AddChannel(ctx, mockServer.URL+"/feed.xml")
|
||||
for _, ch := range channelRepo.Data {
|
||||
Expect(ch.Title).To(Equal("Test Podcast"))
|
||||
}
|
||||
})
|
||||
|
||||
It("sets channel status to completed", func() {
|
||||
_ = svc.AddChannel(ctx, mockServer.URL+"/feed.xml")
|
||||
for _, ch := range channelRepo.Data {
|
||||
Expect(ch.Status).To(Equal(model.PodcastStatusCompleted))
|
||||
}
|
||||
})
|
||||
|
||||
It("sets episode status to new", func() {
|
||||
_ = svc.AddChannel(ctx, mockServer.URL+"/feed.xml")
|
||||
for _, ep := range episodeRepo.Data {
|
||||
Expect(ep.Status).To(Equal(model.PodcastStatusNew))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns error for unreachable URL", func() {
|
||||
err := svc.AddChannel(ctx, "http://localhost:0/invalid")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RefreshChannels", func() {
|
||||
BeforeEach(func() {
|
||||
channelRepo.Data["ch-1"] = &model.PodcastChannel{
|
||||
ID: "ch-1",
|
||||
URL: mockServer.URL + "/feed.xml",
|
||||
}
|
||||
})
|
||||
|
||||
It("adds new episodes from feed", func() {
|
||||
err := svc.RefreshChannels(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(episodeRepo.Data).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("does not duplicate existing episodes", func() {
|
||||
episodeRepo.Data["ep-existing"] = &model.PodcastEpisode{
|
||||
ID: "ep-existing",
|
||||
ChannelID: "ch-1",
|
||||
GUID: "guid-ep-001",
|
||||
}
|
||||
err := svc.RefreshChannels(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(episodeRepo.Data).To(HaveLen(2)) // 1 existing + 1 new
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DownloadEpisode", func() {
|
||||
var episode *model.PodcastEpisode
|
||||
|
||||
BeforeEach(func() {
|
||||
channelRepo.Data["ch-1"] = &model.PodcastChannel{
|
||||
ID: "ch-1",
|
||||
Title: "Test Channel",
|
||||
URL: "http://example.com/feed.xml",
|
||||
}
|
||||
episode = &model.PodcastEpisode{
|
||||
ID: "ep-1",
|
||||
ChannelID: "ch-1",
|
||||
EnclosureURL: mockServer.URL + "/audio.mp3",
|
||||
Suffix: "mp3",
|
||||
Status: model.PodcastStatusNew,
|
||||
}
|
||||
episodeRepo.Data[episode.ID] = episode
|
||||
})
|
||||
|
||||
It("immediately sets status to downloading", func() {
|
||||
_ = svc.DownloadEpisode(ctx, "ep-1")
|
||||
Expect(episodeRepo.Data["ep-1"].Status).To(Equal(model.PodcastStatusDownloading))
|
||||
})
|
||||
|
||||
It("creates the audio file at the expected path", func() {
|
||||
_ = svc.DownloadEpisode(ctx, "ep-1")
|
||||
expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3")
|
||||
Eventually(func() bool {
|
||||
_, err := os.Stat(expectedPath)
|
||||
return err == nil
|
||||
}, "3s").Should(BeTrue())
|
||||
})
|
||||
|
||||
It("sets status to completed after download", func() {
|
||||
_ = svc.DownloadEpisode(ctx, "ep-1")
|
||||
Eventually(func() model.PodcastStatus {
|
||||
return episodeRepo.Data["ep-1"].Status
|
||||
}, "3s").Should(Equal(model.PodcastStatusCompleted))
|
||||
})
|
||||
|
||||
It("creates a podcast library rooted at DataFolder/podcasts that the scanner ignores", func() {
|
||||
_ = svc.DownloadEpisode(ctx, "ep-1")
|
||||
root := filepath.Join(conf.Server.DataFolder.String(), "podcasts")
|
||||
Eventually(func() bool {
|
||||
_, err := os.Stat(filepath.Join(root, ".ndignore"))
|
||||
return err == nil
|
||||
}, "3s").Should(BeTrue())
|
||||
libs, err := ds.Library().GetAll(ctx)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var found bool
|
||||
for _, l := range libs {
|
||||
if l.Name == "Podcasts" {
|
||||
found = true
|
||||
Expect(l.Path).To(Equal(root))
|
||||
Expect(l.DefaultNewUsers).To(BeTrue())
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue())
|
||||
})
|
||||
|
||||
It("reuses the existing StreamID's MediaFile on re-download", func() {
|
||||
_ = svc.DownloadEpisode(ctx, "ep-1")
|
||||
Eventually(func() model.PodcastStatus {
|
||||
return episodeRepo.Data["ep-1"].Status
|
||||
}, "3s").Should(Equal(model.PodcastStatusCompleted))
|
||||
first := episodeRepo.Data["ep-1"].StreamID
|
||||
Expect(first).ToNot(BeEmpty())
|
||||
|
||||
_ = svc.DownloadEpisode(ctx, "ep-1")
|
||||
Eventually(func() model.PodcastStatus {
|
||||
return episodeRepo.Data["ep-1"].Status
|
||||
}, "3s").Should(Equal(model.PodcastStatusCompleted))
|
||||
Expect(episodeRepo.Data["ep-1"].StreamID).To(Equal(first))
|
||||
})
|
||||
|
||||
It("records the file path after download", func() {
|
||||
_ = svc.DownloadEpisode(ctx, "ep-1")
|
||||
expectedPath := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-1.mp3")
|
||||
Eventually(func() string {
|
||||
return episodeRepo.Data["ep-1"].Path
|
||||
}, "3s").Should(Equal(expectedPath))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteEpisode", func() {
|
||||
It("resets episode to new status", func() {
|
||||
episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", ChannelID: "ch-1", Status: model.PodcastStatusCompleted}
|
||||
err := svc.DeleteEpisode(ctx, "ep-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(episodeRepo.Data["ep-1"].Status).To(Equal(model.PodcastStatusNew))
|
||||
})
|
||||
|
||||
It("deletes the downloaded file when path is set", func() {
|
||||
tmpFile := filepath.Join(GinkgoT().TempDir(), "ep.mp3")
|
||||
Expect(os.WriteFile(tmpFile, []byte("audio"), 0600)).To(Succeed())
|
||||
episodeRepo.Data["ep-1"] = &model.PodcastEpisode{ID: "ep-1", Path: tmpFile}
|
||||
|
||||
_ = svc.DeleteEpisode(ctx, "ep-1")
|
||||
_, err := os.Stat(tmpFile)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteChannel", func() {
|
||||
It("removes channel from DB", func() {
|
||||
channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1"}
|
||||
err := svc.DeleteChannel(ctx, "ch-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(channelRepo.Data).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("deletes all episode files for the channel", func() {
|
||||
tmpDir := GinkgoT().TempDir()
|
||||
epFile := filepath.Join(tmpDir, "ep.mp3")
|
||||
Expect(os.WriteFile(epFile, []byte("audio"), 0600)).To(Succeed())
|
||||
|
||||
channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1"}
|
||||
episodeRepo.Data["ep-1"] = &model.PodcastEpisode{
|
||||
ID: "ep-1", ChannelID: "ch-1",
|
||||
Path: epFile,
|
||||
}
|
||||
|
||||
_ = svc.DeleteChannel(ctx, "ch-1")
|
||||
_, err := os.Stat(epFile)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DownloadEpisode error handling", func() {
|
||||
BeforeEach(func() {
|
||||
channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1", Title: "Test Channel"}
|
||||
})
|
||||
It("does not leave an empty file behind when the request fails", func() {
|
||||
episodeRepo.Data["ep-bad"] = &model.PodcastEpisode{
|
||||
ID: "ep-bad",
|
||||
ChannelID: "ch-1",
|
||||
EnclosureURL: "http://localhost:0/no-such.mp3",
|
||||
Suffix: "mp3",
|
||||
Status: model.PodcastStatusNew,
|
||||
}
|
||||
_ = svc.DownloadEpisode(ctx, "ep-bad")
|
||||
Eventually(func() model.PodcastStatus {
|
||||
return episodeRepo.Data["ep-bad"].Status
|
||||
}, "3s").Should(Equal(model.PodcastStatusError))
|
||||
dest := filepath.Join(conf.Server.DataFolder.String(), "podcasts", "ch-1", "ep-bad.mp3")
|
||||
_, err := os.Stat(dest)
|
||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
})
|
||||
It("sets status to error when download fails", func() {
|
||||
episodeRepo.Data["ep-bad"] = &model.PodcastEpisode{
|
||||
ID: "ep-bad",
|
||||
ChannelID: "ch-1",
|
||||
EnclosureURL: "http://localhost:0/no-such.mp3",
|
||||
Suffix: "mp3",
|
||||
Status: model.PodcastStatusNew,
|
||||
}
|
||||
_ = svc.DownloadEpisode(ctx, "ep-bad")
|
||||
Eventually(func() model.PodcastStatus {
|
||||
return episodeRepo.Data["ep-bad"].Status
|
||||
}, "3s").Should(Equal(model.PodcastStatusError))
|
||||
})
|
||||
|
||||
It("records error message when download fails", func() {
|
||||
episodeRepo.Data["ep-bad"] = &model.PodcastEpisode{
|
||||
ID: "ep-bad",
|
||||
ChannelID: "ch-1",
|
||||
EnclosureURL: "http://localhost:0/no-such.mp3",
|
||||
Suffix: "mp3",
|
||||
Status: model.PodcastStatusNew,
|
||||
}
|
||||
_ = svc.DownloadEpisode(ctx, "ep-bad")
|
||||
Eventually(func() string {
|
||||
return episodeRepo.Data["ep-bad"].ErrorMessage
|
||||
}, "3s").ShouldNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AddChannel — Podcasting 2.0 field persistence", func() {
|
||||
var transcriptRepo *tests.MockPodcastTranscriptRepo
|
||||
var personRepo *tests.MockPodcastPersonRepo
|
||||
var p20Server *httptest.Server
|
||||
|
||||
BeforeEach(func() {
|
||||
transcriptRepo = tests.CreateMockPodcastTranscriptRepo()
|
||||
personRepo = tests.CreateMockPodcastPersonRepo()
|
||||
ds.MockedPodcastTranscript = transcriptRepo
|
||||
ds.MockedPodcastPerson = personRepo
|
||||
|
||||
p20Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
fmt.Fprint(w, testRSSFeedPodcast20)
|
||||
}))
|
||||
DeferCleanup(p20Server.Close)
|
||||
})
|
||||
|
||||
It("stores PodcastGUID from feed", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
for _, ch := range channelRepo.Data {
|
||||
Expect(ch.PodcastGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810"))
|
||||
}
|
||||
})
|
||||
|
||||
It("stores Locked flag and LockedOwner from feed", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
for _, ch := range channelRepo.Data {
|
||||
Expect(ch.Locked).To(BeTrue())
|
||||
Expect(ch.LockedOwner).To(Equal("owner@example.com"))
|
||||
}
|
||||
})
|
||||
|
||||
It("stores Medium from feed", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
for _, ch := range channelRepo.Data {
|
||||
Expect(ch.Medium).To(Equal("podcast"))
|
||||
}
|
||||
})
|
||||
|
||||
It("saves funding items to funding repo", func() {
|
||||
fundingRepo := tests.CreateMockPodcastFundingRepo()
|
||||
ds.MockedPodcastFunding = fundingRepo
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
Expect(fundingRepo.Data).ToNot(BeEmpty())
|
||||
var urls []string
|
||||
for _, f := range fundingRepo.Data {
|
||||
urls = append(urls, f.URL)
|
||||
}
|
||||
Expect(urls).To(ContainElement("https://example.com/donate"))
|
||||
})
|
||||
|
||||
It("stores UpdateFrequency and UpdateRRule from feed", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
for _, ch := range channelRepo.Data {
|
||||
Expect(ch.UpdateFrequency).To(Equal("Weekly"))
|
||||
Expect(ch.UpdateRRule).To(Equal("FREQ=WEEKLY"))
|
||||
}
|
||||
})
|
||||
|
||||
It("saves channel-level podcast:person entries", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
Expect(personRepo.Data).ToNot(BeEmpty())
|
||||
var channelPersons []string
|
||||
for _, p := range personRepo.Data {
|
||||
if p.ChannelID != "" {
|
||||
channelPersons = append(channelPersons, p.Name)
|
||||
}
|
||||
}
|
||||
Expect(channelPersons).To(ConsistOf("Jane Host", "Bob Producer"))
|
||||
})
|
||||
|
||||
It("saves episode podcast:transcript entries", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
Expect(transcriptRepo.Data).ToNot(BeEmpty())
|
||||
var mimeTypes []string
|
||||
for _, t := range transcriptRepo.Data {
|
||||
mimeTypes = append(mimeTypes, t.MimeType)
|
||||
}
|
||||
Expect(mimeTypes).To(ConsistOf("text/vtt", "application/x-subrip"))
|
||||
})
|
||||
|
||||
It("stores transcript language and rel attributes", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
var vttLanguage, vttRel string
|
||||
for _, t := range transcriptRepo.Data {
|
||||
if t.MimeType == "text/vtt" {
|
||||
vttLanguage = t.Language
|
||||
vttRel = t.Rel
|
||||
}
|
||||
}
|
||||
Expect(vttLanguage).To(Equal("en"))
|
||||
Expect(vttRel).To(Equal("captions"))
|
||||
})
|
||||
|
||||
It("saves episode-level podcast:person entries", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
var episodePersonNames []string
|
||||
for _, p := range personRepo.Data {
|
||||
if p.EpisodeID != "" {
|
||||
episodePersonNames = append(episodePersonNames, p.Name)
|
||||
}
|
||||
}
|
||||
Expect(episodePersonNames).To(ContainElement("John Guest"))
|
||||
})
|
||||
|
||||
It("stores episode ChaptersURL", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
var chaptersURLs []string
|
||||
for _, ep := range episodeRepo.Data {
|
||||
if ep.ChaptersURL != "" {
|
||||
chaptersURLs = append(chaptersURLs, ep.ChaptersURL)
|
||||
}
|
||||
}
|
||||
Expect(chaptersURLs).To(ContainElement("https://example.com/ep1/chapters.json"))
|
||||
})
|
||||
|
||||
It("stores episode Season number and name", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
var ep1 *model.PodcastEpisode
|
||||
for _, ep := range episodeRepo.Data {
|
||||
if ep.GUID == "guid-ep-001" {
|
||||
ep1 = ep
|
||||
}
|
||||
}
|
||||
Expect(ep1).ToNot(BeNil())
|
||||
Expect(ep1.Season).To(Equal(1))
|
||||
Expect(ep1.SeasonName).To(Equal("Season One"))
|
||||
})
|
||||
|
||||
It("stores episode EpisodeNumber and EpisodeDisplay", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
var ep1 *model.PodcastEpisode
|
||||
for _, ep := range episodeRepo.Data {
|
||||
if ep.GUID == "guid-ep-001" {
|
||||
ep1 = ep
|
||||
}
|
||||
}
|
||||
Expect(ep1).ToNot(BeNil())
|
||||
Expect(ep1.EpisodeNumber).To(Equal("1"))
|
||||
Expect(ep1.EpisodeDisplay).To(Equal("Ep.1"))
|
||||
})
|
||||
|
||||
It("stores episode Soundbite fields", func() {
|
||||
Expect(svc.AddChannel(ctx, p20Server.URL+"/feed.xml")).To(Succeed())
|
||||
var ep1 *model.PodcastEpisode
|
||||
for _, ep := range episodeRepo.Data {
|
||||
if ep.GUID == "guid-ep-001" {
|
||||
ep1 = ep
|
||||
}
|
||||
}
|
||||
Expect(ep1).ToNot(BeNil())
|
||||
Expect(ep1.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001))
|
||||
Expect(ep1.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001))
|
||||
Expect(ep1.SoundbiteTitle).To(Equal("Best moment"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DownloadEpisode with timestamp", func() {
|
||||
BeforeEach(func() {
|
||||
channelRepo.Data["ch-1"] = &model.PodcastChannel{ID: "ch-1", Title: "Test Channel"}
|
||||
})
|
||||
It("sets updated_at after status change", func() {
|
||||
episodeRepo.Data["ep-ts"] = &model.PodcastEpisode{
|
||||
ID: "ep-ts",
|
||||
ChannelID: "ch-1",
|
||||
EnclosureURL: mockServer.URL + "/audio.mp3",
|
||||
Suffix: "mp3",
|
||||
Status: model.PodcastStatusNew,
|
||||
UpdatedAt: time.Time{},
|
||||
}
|
||||
_ = svc.DownloadEpisode(ctx, "ep-ts")
|
||||
Eventually(func() bool {
|
||||
return !episodeRepo.Data["ep-ts"].UpdatedAt.IsZero()
|
||||
}, "3s").Should(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AddChannel — Tier 3 field persistence", func() {
|
||||
var podrollRepo *tests.MockPodcastPodrollRepo
|
||||
var liveItemRepo *tests.MockPodcastLiveItemRepo
|
||||
var tier3Server *httptest.Server
|
||||
|
||||
BeforeEach(func() {
|
||||
podrollRepo = tests.CreateMockPodcastPodrollRepo()
|
||||
liveItemRepo = tests.CreateMockPodcastLiveItemRepo()
|
||||
ds.MockedPodcastPodroll = podrollRepo
|
||||
ds.MockedPodcastLiveItem = liveItemRepo
|
||||
})
|
||||
|
||||
Context("when feed has podcast:podping usesPodping=true", func() {
|
||||
BeforeEach(func() {
|
||||
tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
fmt.Fprint(w, testRSSFeedPodping)
|
||||
}))
|
||||
DeferCleanup(tier3Server.Close)
|
||||
})
|
||||
|
||||
It("stores UsesPodping=true on the channel", func() {
|
||||
Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed())
|
||||
for _, ch := range channelRepo.Data {
|
||||
Expect(ch.UsesPodping).To(BeTrue())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Context("when feed has podcast:podroll", func() {
|
||||
BeforeEach(func() {
|
||||
tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
fmt.Fprint(w, testRSSFeedPodroll)
|
||||
}))
|
||||
DeferCleanup(tier3Server.Close)
|
||||
})
|
||||
|
||||
It("saves podroll items for the channel", func() {
|
||||
Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed())
|
||||
Expect(podrollRepo.Data).ToNot(BeEmpty())
|
||||
var urls []string
|
||||
for _, item := range podrollRepo.Data {
|
||||
urls = append(urls, item.FeedURL)
|
||||
}
|
||||
Expect(urls).To(ConsistOf(
|
||||
"https://example.com/feed.xml",
|
||||
"https://other.com/feed.xml",
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when feed has podcast:liveItem", func() {
|
||||
BeforeEach(func() {
|
||||
tier3Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/rss+xml")
|
||||
fmt.Fprint(w, testRSSFeedLiveItem)
|
||||
}))
|
||||
DeferCleanup(tier3Server.Close)
|
||||
})
|
||||
|
||||
It("saves the live item for the channel", func() {
|
||||
Expect(svc.AddChannel(ctx, tier3Server.URL+"/feed.xml")).To(Succeed())
|
||||
Expect(liveItemRepo.Data).ToNot(BeEmpty())
|
||||
for _, li := range liveItemRepo.Data {
|
||||
Expect(li.Status).To(Equal("live"))
|
||||
Expect(li.GUID).To(Equal("live-guid-001"))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RefreshChannels — Tier 3 podping", func() {
|
||||
var podrollRepo *tests.MockPodcastPodrollRepo
|
||||
var liveItemRepo *tests.MockPodcastLiveItemRepo
|
||||
|
||||
BeforeEach(func() {
|
||||
podrollRepo = tests.CreateMockPodcastPodrollRepo()
|
||||
liveItemRepo = tests.CreateMockPodcastLiveItemRepo()
|
||||
ds.MockedPodcastPodroll = podrollRepo
|
||||
ds.MockedPodcastLiveItem = liveItemRepo
|
||||
})
|
||||
|
||||
It("still refreshes channels with UsesPodping=true (no Podping listener exists)", func() {
|
||||
channelRepo.Data["ch-podping"] = &model.PodcastChannel{
|
||||
ID: "ch-podping",
|
||||
URL: mockServer.URL + "/feed.xml",
|
||||
UsesPodping: true,
|
||||
}
|
||||
initialEpisodeCount := len(episodeRepo.Data)
|
||||
|
||||
Expect(svc.RefreshChannels(ctx)).To(Succeed())
|
||||
// Podping is only a hint; nothing else refreshes these feeds, so
|
||||
// new episodes must still be picked up.
|
||||
Expect(len(episodeRepo.Data)).To(BeNumerically(">", initialEpisodeCount))
|
||||
})
|
||||
|
||||
It("still refreshes channels with UsesPodping=false", func() {
|
||||
channelRepo.Data["ch-normal"] = &model.PodcastChannel{
|
||||
ID: "ch-normal",
|
||||
URL: mockServer.URL + "/feed.xml",
|
||||
UsesPodping: false,
|
||||
}
|
||||
Expect(svc.RefreshChannels(ctx)).To(Succeed())
|
||||
// Episodes from the mock feed should have been added.
|
||||
Expect(episodeRepo.Data).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
568
core/podcasts/rss.go
Normal file
568
core/podcasts/rss.go
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
package podcasts
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const podcastNS = "https://podcastindex.org/namespace/1.0"
|
||||
|
||||
// Go's encoding/xml has a known bug where a namespace-qualified slice field (e.g.
|
||||
// `xml:"ns image"`) is not populated when the same struct also has a no-namespace
|
||||
// field with the same local name (e.g. `xml:"image"`). This affects rssChannel
|
||||
// because it has both Image rssImage `xml:"image"` and PodcastImages `xml:"ns image"`.
|
||||
// rssItem has no such conflict, so episode-level podcast:image works via struct tags.
|
||||
// We use regex fallbacks for both itunes:image and channel-level podcast:image.
|
||||
var itunesImageRe = regexp.MustCompile(`<itunes:image\b[^>]+href="([^"]*)"`)
|
||||
|
||||
// podcastImageElemRe matches a podcast:image element and captures its attributes.
|
||||
// We intentionally match only the "podcast:" prefix (the de-facto standard) rather
|
||||
// than any arbitrary prefix, to avoid false matches against itunes:image or rss <image>.
|
||||
var podcastImageElemRe = regexp.MustCompile(`<podcast:image\b([^>]*)(?:/>|>)`)
|
||||
var hrefAttrRe = regexp.MustCompile(`\bhref="([^"]*)"`)
|
||||
var widthAttrRe = regexp.MustCompile(`\bwidth="(\d+)"`)
|
||||
|
||||
// extractChannelImages extracts podcast:image elements that appear in the channel
|
||||
// header (before the first <item> block) using regex, working around the Go xml
|
||||
// namespace conflict bug.
|
||||
func extractChannelImages(data []byte) []model.PodcastImage {
|
||||
// Narrow to channel header to avoid matching episode-level podcast:image elements.
|
||||
channelStart := strings.Index(string(data), "<channel")
|
||||
if channelStart < 0 {
|
||||
return nil
|
||||
}
|
||||
header := data[channelStart:]
|
||||
if itemIdx := strings.Index(string(header), "<item"); itemIdx >= 0 {
|
||||
header = header[:itemIdx]
|
||||
}
|
||||
var images []model.PodcastImage
|
||||
for _, m := range podcastImageElemRe.FindAllSubmatch(header, -1) {
|
||||
attrs := string(m[1])
|
||||
hm := hrefAttrRe.FindStringSubmatch(attrs)
|
||||
if len(hm) < 2 || hm[1] == "" {
|
||||
continue
|
||||
}
|
||||
img := model.PodcastImage{URL: hm[1]}
|
||||
if wm := widthAttrRe.FindStringSubmatch(attrs); len(wm) >= 2 {
|
||||
img.Width, _ = strconv.Atoi(wm[1])
|
||||
}
|
||||
images = append(images, img)
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func extractItunesImageHref(data []byte) string {
|
||||
if m := itunesImageRe.FindSubmatch(data); len(m) > 1 {
|
||||
return string(m[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type rssFeed struct {
|
||||
Title string
|
||||
Description string
|
||||
ImageURL string
|
||||
Episodes []model.PodcastEpisode
|
||||
|
||||
// Podcasting 2.0 channel fields
|
||||
PodcastGUID string
|
||||
Locked bool
|
||||
LockedOwner string
|
||||
Medium string
|
||||
UpdateFrequency string
|
||||
UpdateRRule string
|
||||
Complete bool
|
||||
Persons []model.PodcastPerson
|
||||
FundingItems []model.PodcastFundingItem
|
||||
LocationName string
|
||||
LocationGeo string
|
||||
LocationOSM string
|
||||
License string
|
||||
PublisherName string
|
||||
PublisherURL string
|
||||
Images []model.PodcastImage
|
||||
|
||||
// Podcasting 2.0 Tier 3 channel fields
|
||||
UsesPodping bool
|
||||
Podroll []model.PodcastPodrollItem
|
||||
LiveItems []model.PodcastLiveItem
|
||||
}
|
||||
|
||||
type FeedPreview struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
EpisodeCount int `json:"episodeCount"`
|
||||
AlreadyExists bool `json:"alreadyExists"`
|
||||
|
||||
// Podcasting 2.0
|
||||
Medium string `json:"medium,omitempty"`
|
||||
UpdateFrequency string `json:"updateFrequency,omitempty"`
|
||||
FundingURL string `json:"fundingUrl,omitempty"`
|
||||
FundingText string `json:"fundingText,omitempty"`
|
||||
}
|
||||
|
||||
func ParseFeedPreview(rssURL string) (*FeedPreview, error) {
|
||||
feed, err := fetchAndParse(rssURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fundingURL, fundingText string
|
||||
if len(feed.FundingItems) > 0 {
|
||||
fundingURL = feed.FundingItems[0].URL
|
||||
fundingText = feed.FundingItems[0].Text
|
||||
}
|
||||
return &FeedPreview{
|
||||
Title: feed.Title,
|
||||
Description: feed.Description,
|
||||
ImageURL: feed.ImageURL,
|
||||
EpisodeCount: len(feed.Episodes),
|
||||
Medium: feed.Medium,
|
||||
UpdateFrequency: feed.UpdateFrequency,
|
||||
FundingURL: fundingURL,
|
||||
FundingText: fundingText,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- XML struct definitions ----
|
||||
|
||||
type rssRoot struct {
|
||||
Channel rssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
type rssChannel struct {
|
||||
Title string `xml:"title"`
|
||||
Description string `xml:"description"`
|
||||
Image rssImage `xml:"image"`
|
||||
ItunesImage itunesImage `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
|
||||
Items []rssItem `xml:"item"`
|
||||
|
||||
// Podcasting 2.0 channel tags — Tier 1 & 2
|
||||
PodcastGUID string `xml:"https://podcastindex.org/namespace/1.0 guid"`
|
||||
PodcastLocked podcastLocked `xml:"https://podcastindex.org/namespace/1.0 locked"`
|
||||
PodcastMedium string `xml:"https://podcastindex.org/namespace/1.0 medium"`
|
||||
PodcastFundings []podcastFunding `xml:"https://podcastindex.org/namespace/1.0 funding"`
|
||||
PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"`
|
||||
PodcastUpdateFreq podcastUpdateFreq `xml:"https://podcastindex.org/namespace/1.0 updateFrequency"`
|
||||
PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"`
|
||||
PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"`
|
||||
PodcastPublisher podcastPublisher `xml:"https://podcastindex.org/namespace/1.0 publisher"`
|
||||
PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"`
|
||||
|
||||
// Podcasting 2.0 channel tags — Tier 3
|
||||
PodcastPodping podcastPodping `xml:"https://podcastindex.org/namespace/1.0 podping"`
|
||||
PodcastPodroll podcastPodroll `xml:"https://podcastindex.org/namespace/1.0 podroll"`
|
||||
PodcastLiveItems []podcastLiveItemXML `xml:"https://podcastindex.org/namespace/1.0 liveItem"`
|
||||
}
|
||||
|
||||
// Tier 3 XML parsing structs.
|
||||
|
||||
type podcastPodping struct {
|
||||
UsesPodping string `xml:"usesPodping,attr"`
|
||||
}
|
||||
|
||||
type podcastRemoteItem struct {
|
||||
FeedGUID string `xml:"feedGuid,attr"`
|
||||
FeedURL string `xml:"feedUrl,attr"`
|
||||
Title string `xml:"title,attr"`
|
||||
}
|
||||
|
||||
type podcastPodroll struct {
|
||||
Items []podcastRemoteItem `xml:"https://podcastindex.org/namespace/1.0 remoteItem"`
|
||||
}
|
||||
|
||||
type podcastContentLink struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type podcastLiveItemXML struct {
|
||||
Status string `xml:"status,attr"`
|
||||
Start string `xml:"start,attr"`
|
||||
End string `xml:"end,attr"`
|
||||
Title string `xml:"title"`
|
||||
GUID string `xml:"guid"`
|
||||
Enclosure enclosure `xml:"enclosure"`
|
||||
ContentLink podcastContentLink `xml:"https://podcastindex.org/namespace/1.0 contentLink"`
|
||||
}
|
||||
|
||||
type rssImage struct {
|
||||
URL string `xml:"url"`
|
||||
}
|
||||
|
||||
type itunesImage struct {
|
||||
Href string `xml:"href,attr"`
|
||||
}
|
||||
|
||||
type podcastLocked struct {
|
||||
Owner string `xml:"owner,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type podcastFunding struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type podcastPerson struct {
|
||||
Role string `xml:"role,attr"`
|
||||
Group string `xml:"group,attr"`
|
||||
Img string `xml:"img,attr"`
|
||||
Href string `xml:"href,attr"`
|
||||
Name string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type podcastUpdateFreq struct {
|
||||
Complete string `xml:"complete,attr"`
|
||||
RRule string `xml:"rrule,attr"`
|
||||
Text string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Description string `xml:"description"`
|
||||
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Enclosure enclosure `xml:"enclosure"`
|
||||
ItunesDur string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
|
||||
|
||||
// Podcasting 2.0 episode tags
|
||||
PodcastChapters podcastChapters `xml:"https://podcastindex.org/namespace/1.0 chapters"`
|
||||
PodcastTranscripts []podcastTranscript `xml:"https://podcastindex.org/namespace/1.0 transcript"`
|
||||
PodcastSeason podcastSeason `xml:"https://podcastindex.org/namespace/1.0 season"`
|
||||
PodcastEpisodeNum podcastEpisodeNum `xml:"https://podcastindex.org/namespace/1.0 episode"`
|
||||
PodcastSoundbite podcastSoundbite `xml:"https://podcastindex.org/namespace/1.0 soundbite"`
|
||||
PodcastPersons []podcastPerson `xml:"https://podcastindex.org/namespace/1.0 person"`
|
||||
PodcastLocation podcastLocation `xml:"https://podcastindex.org/namespace/1.0 location"`
|
||||
PodcastLicense podcastLicense `xml:"https://podcastindex.org/namespace/1.0 license"`
|
||||
PodcastImages []podcastImageTag `xml:"https://podcastindex.org/namespace/1.0 image"`
|
||||
}
|
||||
|
||||
type enclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Length int64 `xml:"length,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
type podcastChapters struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
type podcastTranscript struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Language string `xml:"language,attr"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
}
|
||||
|
||||
type podcastSeason struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type podcastEpisodeNum struct {
|
||||
Display string `xml:"display,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type podcastSoundbite struct {
|
||||
StartTime string `xml:"startTime,attr"`
|
||||
Duration string `xml:"duration,attr"`
|
||||
Title string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type podcastLocation struct {
|
||||
Geo string `xml:"geo,attr"`
|
||||
OSM string `xml:"osm,attr"`
|
||||
Name string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type podcastLicense struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type podcastPublisher struct {
|
||||
Name string `xml:"https://podcastindex.org/namespace/1.0 name"`
|
||||
URL string `xml:"https://podcastindex.org/namespace/1.0 url"`
|
||||
}
|
||||
|
||||
type podcastImageTag struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Width int `xml:"width,attr"`
|
||||
}
|
||||
|
||||
// ---- Parsing ----
|
||||
|
||||
func ParseRSSFeed(data []byte) (*rssFeed, error) {
|
||||
var root rssRoot
|
||||
if err := xml.Unmarshal(data, &root); err != nil {
|
||||
return nil, fmt.Errorf("parsing RSS feed: %w", err)
|
||||
}
|
||||
|
||||
ch := root.Channel
|
||||
feed := &rssFeed{
|
||||
Title: ch.Title,
|
||||
Description: ch.Description,
|
||||
|
||||
// Podcasting 2.0 channel
|
||||
PodcastGUID: ch.PodcastGUID,
|
||||
Locked: strings.TrimSpace(ch.PodcastLocked.Value) == "yes",
|
||||
LockedOwner: ch.PodcastLocked.Owner,
|
||||
Medium: ch.PodcastMedium,
|
||||
UpdateFrequency: strings.TrimSpace(ch.PodcastUpdateFreq.Text),
|
||||
UpdateRRule: ch.PodcastUpdateFreq.RRule,
|
||||
Complete: strings.TrimSpace(ch.PodcastUpdateFreq.Complete) == "true",
|
||||
}
|
||||
|
||||
// itunes:image takes precedence over <image><url>.
|
||||
if href := extractItunesImageHref(data); href != "" {
|
||||
feed.ImageURL = href
|
||||
} else if ch.ItunesImage.Href != "" {
|
||||
feed.ImageURL = ch.ItunesImage.Href
|
||||
} else {
|
||||
feed.ImageURL = ch.Image.URL
|
||||
}
|
||||
|
||||
// all funding entries
|
||||
for i, f := range ch.PodcastFundings {
|
||||
feed.FundingItems = append(feed.FundingItems, model.PodcastFundingItem{
|
||||
URL: f.URL,
|
||||
Text: strings.TrimSpace(f.Text),
|
||||
SortOrder: i,
|
||||
})
|
||||
}
|
||||
|
||||
// location
|
||||
if ch.PodcastLocation.Name != "" || ch.PodcastLocation.Geo != "" {
|
||||
feed.LocationName = strings.TrimSpace(ch.PodcastLocation.Name)
|
||||
feed.LocationGeo = ch.PodcastLocation.Geo
|
||||
feed.LocationOSM = ch.PodcastLocation.OSM
|
||||
}
|
||||
|
||||
// license
|
||||
feed.License = strings.TrimSpace(ch.PodcastLicense.Value)
|
||||
if feed.License == "" {
|
||||
feed.License = ch.PodcastLicense.URL
|
||||
}
|
||||
|
||||
// publisher
|
||||
feed.PublisherName = strings.TrimSpace(ch.PodcastPublisher.Name)
|
||||
feed.PublisherURL = ch.PodcastPublisher.URL
|
||||
|
||||
// channel images — use regex fallback due to Go xml namespace conflict with rssImage
|
||||
feed.Images = extractChannelImages(data)
|
||||
|
||||
// channel persons
|
||||
for _, p := range ch.PodcastPersons {
|
||||
feed.Persons = append(feed.Persons, model.PodcastPerson{
|
||||
Name: strings.TrimSpace(p.Name),
|
||||
Role: defaultStr(p.Role, "host"),
|
||||
Group: defaultStr(p.Group, "cast"),
|
||||
Img: p.Img,
|
||||
Href: p.Href,
|
||||
})
|
||||
}
|
||||
|
||||
// podcast:podping
|
||||
feed.UsesPodping = strings.TrimSpace(ch.PodcastPodping.UsesPodping) == "true"
|
||||
|
||||
// podcast:podroll
|
||||
for i, item := range ch.PodcastPodroll.Items {
|
||||
feed.Podroll = append(feed.Podroll, model.PodcastPodrollItem{
|
||||
FeedGUID: item.FeedGUID,
|
||||
FeedURL: item.FeedURL,
|
||||
Title: item.Title,
|
||||
SortOrder: i,
|
||||
})
|
||||
}
|
||||
|
||||
// podcast:liveItem
|
||||
for _, li := range ch.PodcastLiveItems {
|
||||
startTime, _ := time.Parse(time.RFC3339, li.Start)
|
||||
endTime, _ := time.Parse(time.RFC3339, li.End)
|
||||
feed.LiveItems = append(feed.LiveItems, model.PodcastLiveItem{
|
||||
GUID: li.GUID,
|
||||
Title: li.Title,
|
||||
Status: li.Status,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
EnclosureURL: li.Enclosure.URL,
|
||||
EnclosureType: li.Enclosure.Type,
|
||||
ContentLinkURL: li.ContentLink.Href,
|
||||
ContentLinkText: strings.TrimSpace(li.ContentLink.Text),
|
||||
})
|
||||
}
|
||||
|
||||
for _, item := range ch.Items {
|
||||
desc := item.Description
|
||||
if desc == "" {
|
||||
desc = item.ItunesSummary
|
||||
}
|
||||
|
||||
pubDate, _ := parseRSSDate(item.PubDate)
|
||||
suffix := suffixFromMIME(item.Enclosure.Type, item.Enclosure.URL)
|
||||
|
||||
// <guid> is optional in RSS 2.0; fall back to the enclosure URL so
|
||||
// episodes keep a unique identity for de-duplication on refresh.
|
||||
guid := strings.TrimSpace(item.GUID)
|
||||
if guid == "" {
|
||||
guid = item.Enclosure.URL
|
||||
}
|
||||
ep := model.PodcastEpisode{
|
||||
GUID: guid,
|
||||
Title: item.Title,
|
||||
Description: desc,
|
||||
PublishDate: pubDate,
|
||||
EnclosureURL: item.Enclosure.URL,
|
||||
Size: item.Enclosure.Length,
|
||||
ContentType: item.Enclosure.Type,
|
||||
Suffix: suffix,
|
||||
Duration: parseDuration(item.ItunesDur),
|
||||
Status: model.PodcastStatusNew,
|
||||
|
||||
// Podcasting 2.0 episode
|
||||
ChaptersURL: item.PodcastChapters.URL,
|
||||
ChaptersType: item.PodcastChapters.Type,
|
||||
Season: parseInt(item.PodcastSeason.Value),
|
||||
SeasonName: item.PodcastSeason.Name,
|
||||
EpisodeNumber: strings.TrimSpace(item.PodcastEpisodeNum.Value),
|
||||
EpisodeDisplay: item.PodcastEpisodeNum.Display,
|
||||
SoundbiteStart: parseFloat(item.PodcastSoundbite.StartTime),
|
||||
SoundbiteDur: parseFloat(item.PodcastSoundbite.Duration),
|
||||
SoundbiteTitle: strings.TrimSpace(item.PodcastSoundbite.Title),
|
||||
}
|
||||
|
||||
for _, t := range item.PodcastTranscripts {
|
||||
ep.Transcripts = append(ep.Transcripts, model.PodcastTranscript{
|
||||
URL: t.URL,
|
||||
MimeType: t.Type,
|
||||
Language: t.Language,
|
||||
Rel: t.Rel,
|
||||
})
|
||||
}
|
||||
|
||||
for _, p := range item.PodcastPersons {
|
||||
ep.Persons = append(ep.Persons, model.PodcastPerson{
|
||||
Name: strings.TrimSpace(p.Name),
|
||||
Role: defaultStr(p.Role, "host"),
|
||||
Group: defaultStr(p.Group, "cast"),
|
||||
Img: p.Img,
|
||||
Href: p.Href,
|
||||
})
|
||||
}
|
||||
|
||||
// episode location
|
||||
if item.PodcastLocation.Name != "" || item.PodcastLocation.Geo != "" {
|
||||
ep.LocationName = strings.TrimSpace(item.PodcastLocation.Name)
|
||||
ep.LocationGeo = item.PodcastLocation.Geo
|
||||
ep.LocationOSM = item.PodcastLocation.OSM
|
||||
}
|
||||
|
||||
// episode license
|
||||
ep.License = strings.TrimSpace(item.PodcastLicense.Value)
|
||||
if ep.License == "" {
|
||||
ep.License = item.PodcastLicense.URL
|
||||
}
|
||||
|
||||
// episode images
|
||||
for _, img := range item.PodcastImages {
|
||||
if img.Href != "" {
|
||||
ep.Images = append(ep.Images, model.PodcastImage{URL: img.Href, Width: img.Width})
|
||||
}
|
||||
}
|
||||
|
||||
feed.Episodes = append(feed.Episodes, ep)
|
||||
}
|
||||
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
func parseRSSDate(s string) (time.Time, error) {
|
||||
formats := []string{
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
"Mon, 2 Jan 2006 15:04:05 -0700",
|
||||
"Mon, 2 Jan 2006 15:04:05 MST",
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, s); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("cannot parse date: %q", s)
|
||||
}
|
||||
|
||||
func parseDuration(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
parts := strings.Split(s, ":")
|
||||
switch len(parts) {
|
||||
case 3:
|
||||
h, _ := strconv.Atoi(parts[0])
|
||||
m, _ := strconv.Atoi(parts[1])
|
||||
sec, _ := strconv.Atoi(parts[2])
|
||||
return h*3600 + m*60 + sec
|
||||
case 2:
|
||||
m, _ := strconv.Atoi(parts[0])
|
||||
sec, _ := strconv.Atoi(parts[1])
|
||||
return m*60 + sec
|
||||
default:
|
||||
sec, _ := strconv.Atoi(s)
|
||||
return sec
|
||||
}
|
||||
}
|
||||
|
||||
func parseInt(s string) int {
|
||||
v, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||
return v
|
||||
}
|
||||
|
||||
func parseFloat(s string) float64 {
|
||||
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
return v
|
||||
}
|
||||
|
||||
func defaultStr(s, def string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var mimeToSuffix = map[string]string{
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/mp3": "mp3",
|
||||
"audio/mp4": "m4a",
|
||||
"audio/m4a": "m4a",
|
||||
"audio/ogg": "ogg",
|
||||
"audio/opus": "opus",
|
||||
"audio/flac": "flac",
|
||||
"audio/x-m4a": "m4a",
|
||||
}
|
||||
|
||||
func suffixFromMIME(mimeType, enclosureURL string) string {
|
||||
base := strings.Split(mimeType, ";")[0]
|
||||
base = strings.TrimSpace(strings.ToLower(base))
|
||||
if s, ok := mimeToSuffix[base]; ok {
|
||||
return s
|
||||
}
|
||||
if u, err := url.Parse(enclosureURL); err == nil {
|
||||
if ext := path.Ext(u.Path); ext != "" {
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
819
core/podcasts/rss_test.go
Normal file
819
core/podcasts/rss_test.go
Normal file
|
|
@ -0,0 +1,819 @@
|
|||
package podcasts_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
const testRSSFeed = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
|
||||
<channel>
|
||||
<title>Test Podcast</title>
|
||||
<description>A test podcast feed</description>
|
||||
<itunes:image href="https://example.com/cover.jpg"/>
|
||||
<item>
|
||||
<title>Episode 1</title>
|
||||
<description>First episode description</description>
|
||||
<guid>guid-ep-001</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep1.mp3" length="1048576" type="audio/mpeg"/>
|
||||
<itunes:duration>01:23:45</itunes:duration>
|
||||
</item>
|
||||
<item>
|
||||
<title>Episode 2</title>
|
||||
<description>Second episode</description>
|
||||
<guid>guid-ep-002</guid>
|
||||
<pubDate>Thu, 01 Feb 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep2.mp3" length="2097152" type="audio/mpeg"/>
|
||||
<itunes:duration>3600</itunes:duration>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const testRSSFeedWithImageTag = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Podcast With Image Tag</title>
|
||||
<description>Uses image tag</description>
|
||||
<image><url>https://example.com/img.jpg</url></image>
|
||||
<item>
|
||||
<title>Ep A</title>
|
||||
<guid>guid-a</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/a.mp3" length="512" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
var _ = Describe("ParseRSSFeed", func() {
|
||||
Describe("channel metadata", func() {
|
||||
It("parses title and description", func() {
|
||||
feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(feed.Title).To(Equal("Test Podcast"))
|
||||
Expect(feed.Description).To(Equal("A test podcast feed"))
|
||||
})
|
||||
|
||||
It("prefers itunes:image over image tag", func() {
|
||||
feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(feed.ImageURL).To(Equal("https://example.com/cover.jpg"))
|
||||
})
|
||||
|
||||
It("falls back to image/url when no itunes:image", func() {
|
||||
feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeedWithImageTag))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(feed.ImageURL).To(Equal("https://example.com/img.jpg"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("episode list", func() {
|
||||
It("parses all episodes", func() {
|
||||
feed, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(feed.Episodes).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("falls back to the enclosure URL when <guid> is missing", func() {
|
||||
feed, err := podcasts.ParseRSSFeed([]byte(`<?xml version="1.0"?><rss version="2.0"><channel><title>T</title>
|
||||
<item><title>No guid</title><enclosure url="https://example.com/noguid.mp3" length="1" type="audio/mpeg"/></item>
|
||||
</channel></rss>`))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(feed.Episodes).To(HaveLen(1))
|
||||
Expect(feed.Episodes[0].GUID).To(Equal("https://example.com/noguid.mp3"))
|
||||
})
|
||||
|
||||
It("parses episode fields correctly", func() {
|
||||
feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
ep := feed.Episodes[0]
|
||||
Expect(ep.GUID).To(Equal("guid-ep-001"))
|
||||
Expect(ep.Title).To(Equal("Episode 1"))
|
||||
Expect(ep.Description).To(Equal("First episode description"))
|
||||
Expect(ep.EnclosureURL).To(Equal("https://example.com/ep1.mp3"))
|
||||
Expect(ep.Size).To(Equal(int64(1048576)))
|
||||
Expect(ep.ContentType).To(Equal("audio/mpeg"))
|
||||
Expect(ep.Suffix).To(Equal("mp3"))
|
||||
})
|
||||
|
||||
It("converts itunes:duration HH:MM:SS to seconds", func() {
|
||||
feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(feed.Episodes[0].Duration).To(Equal(5025)) // 1*3600 + 23*60 + 45
|
||||
})
|
||||
|
||||
It("converts itunes:duration plain integer to seconds", func() {
|
||||
feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(feed.Episodes[1].Duration).To(Equal(3600))
|
||||
})
|
||||
|
||||
It("parses pubDate as UTC time", func() {
|
||||
feed, _ := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(feed.Episodes[0].PublishDate.Year()).To(Equal(2024))
|
||||
Expect(feed.Episodes[0].PublishDate.Month()).To(Equal(time.January))
|
||||
Expect(feed.Episodes[0].PublishDate.Day()).To(Equal(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("error handling", func() {
|
||||
It("returns error for invalid XML", func() {
|
||||
_, err := podcasts.ParseRSSFeed([]byte("not valid xml"))
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Podcasting 2.0 namespace (https://podcastindex.org/namespace/1.0) parsing tests.
|
||||
const testRSSFeedPodcast20 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
|
||||
xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>Podcast 2.0 Show</title>
|
||||
<description>Testing Podcasting 2.0</description>
|
||||
<itunes:image href="https://example.com/cover.jpg"/>
|
||||
|
||||
<podcast:guid>917393e3-1b1e-5cef-ace4-edaa54e1f810</podcast:guid>
|
||||
<podcast:locked owner="owner@example.com">yes</podcast:locked>
|
||||
<podcast:medium>podcast</podcast:medium>
|
||||
<podcast:funding url="https://example.com/donate">Support us!</podcast:funding>
|
||||
<podcast:funding url="https://example.com/donate2">Secondary</podcast:funding>
|
||||
<podcast:person role="host" group="cast"
|
||||
img="https://example.com/host.jpg"
|
||||
href="https://example.com/host">Jane Host</podcast:person>
|
||||
<podcast:person role="producer"
|
||||
img="https://example.com/prod.jpg">Bob Producer</podcast:person>
|
||||
<podcast:updateFrequency rrule="FREQ=WEEKLY" complete="false">Weekly</podcast:updateFrequency>
|
||||
|
||||
<item>
|
||||
<title>Episode 1</title>
|
||||
<guid>guid-ep-001</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep1.mp3" length="1048576" type="audio/mpeg"/>
|
||||
<itunes:duration>01:23:45</itunes:duration>
|
||||
<podcast:season name="Season One">1</podcast:season>
|
||||
<podcast:episode display="Ep.1">1</podcast:episode>
|
||||
<podcast:chapters url="https://example.com/ep1/chapters.json"
|
||||
type="application/json+chapters"/>
|
||||
<podcast:transcript url="https://example.com/ep1/transcript.vtt"
|
||||
type="text/vtt" language="en" rel="captions"/>
|
||||
<podcast:transcript url="https://example.com/ep1/transcript.srt"
|
||||
type="application/x-subrip" language="en"/>
|
||||
<podcast:soundbite startTime="73.5" duration="60.0">Best moment</podcast:soundbite>
|
||||
<podcast:person role="guest" href="https://example.com/guest">John Guest</podcast:person>
|
||||
</item>
|
||||
<item>
|
||||
<title>Episode 2 — no podcast: tags</title>
|
||||
<guid>guid-ep-002</guid>
|
||||
<pubDate>Thu, 01 Feb 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep2.mp3" length="2097152" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const testRSSFeedLocked = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>Unlocked Show</title>
|
||||
<podcast:locked>no</podcast:locked>
|
||||
<item>
|
||||
<title>Ep</title><guid>g1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const testRSSFeedPersonDefaults = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>Defaults Show</title>
|
||||
<podcast:person>No Attrs Person</podcast:person>
|
||||
<item>
|
||||
<title>Ep</title><guid>g1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
<podcast:person>Episode No Attrs</podcast:person>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
// --- Tier 3 RSS test fixtures ---
|
||||
|
||||
const testRSSFeedPodping = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>Podping Show</title>
|
||||
<podcast:podping usesPodping="true"/>
|
||||
<item>
|
||||
<title>Ep</title><guid>g1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const testRSSFeedPodpingFalse = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>No Podping Show</title>
|
||||
<podcast:podping usesPodping="false"/>
|
||||
<item>
|
||||
<title>Ep</title><guid>g1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const testRSSFeedPodroll = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>Podroll Show</title>
|
||||
<podcast:podroll>
|
||||
<podcast:remoteItem feedGuid="917393e3-1b1e-5cef-ace4-edaa54e1f810"
|
||||
feedUrl="https://example.com/feed.xml"
|
||||
title="Great Show"/>
|
||||
<podcast:remoteItem feedGuid="abc123-def456"
|
||||
feedUrl="https://other.com/feed.xml"/>
|
||||
</podcast:podroll>
|
||||
<item>
|
||||
<title>Ep</title><guid>g1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const testRSSFeedLiveItem = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>Live Show Channel</title>
|
||||
<podcast:liveItem status="live"
|
||||
start="2024-04-27T08:00:00Z"
|
||||
end="2024-04-27T09:00:00Z">
|
||||
<title>Live Show</title>
|
||||
<guid>live-guid-001</guid>
|
||||
<enclosure url="https://stream.example.com/live.m3u8"
|
||||
type="application/x-mpegURL"
|
||||
length="0"/>
|
||||
<podcast:contentLink href="https://youtube.com/live">Watch Live</podcast:contentLink>
|
||||
</podcast:liveItem>
|
||||
<item>
|
||||
<title>Ep</title><guid>g1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const testRSSFeedLiveItemPending = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>Pending Live Channel</title>
|
||||
<podcast:liveItem status="pending">
|
||||
<title>Upcoming Show</title>
|
||||
<guid>live-guid-002</guid>
|
||||
<enclosure url="https://stream.example.com/pending.m3u8"
|
||||
type="application/x-mpegURL"
|
||||
length="0"/>
|
||||
</podcast:liveItem>
|
||||
<item>
|
||||
<title>Ep</title><guid>g1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
var _ = Describe("ParseRSSFeed — Tier 3 tags", func() {
|
||||
Describe("podcast:podping", func() {
|
||||
It("sets UsesPodping=true when usesPodping attribute is 'true'", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodping))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.UsesPodping).To(BeTrue())
|
||||
})
|
||||
|
||||
It("sets UsesPodping=false when usesPodping attribute is 'false'", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodpingFalse))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.UsesPodping).To(BeFalse())
|
||||
})
|
||||
|
||||
It("sets UsesPodping=false when tag is absent", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.UsesPodping).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("podcast:podroll", func() {
|
||||
It("parses multiple remoteItem entries", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Podroll).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("parses feedGuid, feedUrl, and title from each remoteItem", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
first := result.Podroll[0]
|
||||
Expect(first.FeedGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810"))
|
||||
Expect(first.FeedURL).To(Equal("https://example.com/feed.xml"))
|
||||
Expect(first.Title).To(Equal("Great Show"))
|
||||
})
|
||||
|
||||
It("handles remoteItem without title", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
second := result.Podroll[1]
|
||||
Expect(second.FeedGUID).To(Equal("abc123-def456"))
|
||||
Expect(second.FeedURL).To(Equal("https://other.com/feed.xml"))
|
||||
Expect(second.Title).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("assigns SortOrder in declaration order", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodroll))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Podroll[0].SortOrder).To(Equal(0))
|
||||
Expect(result.Podroll[1].SortOrder).To(Equal(1))
|
||||
})
|
||||
|
||||
It("returns empty podroll when tag is absent", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Podroll).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("podcast:liveItem", func() {
|
||||
It("parses status, start, and end attributes", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItem))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.LiveItems).To(HaveLen(1))
|
||||
li := result.LiveItems[0]
|
||||
Expect(li.Status).To(Equal("live"))
|
||||
Expect(li.StartTime.UTC().Format(time.RFC3339)).To(Equal("2024-04-27T08:00:00Z"))
|
||||
Expect(li.EndTime.UTC().Format(time.RFC3339)).To(Equal("2024-04-27T09:00:00Z"))
|
||||
})
|
||||
|
||||
It("parses title, guid, enclosure, and contentLink", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItem))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
li := result.LiveItems[0]
|
||||
Expect(li.Title).To(Equal("Live Show"))
|
||||
Expect(li.GUID).To(Equal("live-guid-001"))
|
||||
Expect(li.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8"))
|
||||
Expect(li.EnclosureType).To(Equal("application/x-mpegURL"))
|
||||
Expect(li.ContentLinkURL).To(Equal("https://youtube.com/live"))
|
||||
Expect(li.ContentLinkText).To(Equal("Watch Live"))
|
||||
})
|
||||
|
||||
It("handles pending liveItem without start/end times", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLiveItemPending))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.LiveItems).To(HaveLen(1))
|
||||
li := result.LiveItems[0]
|
||||
Expect(li.Status).To(Equal("pending"))
|
||||
Expect(li.StartTime.IsZero()).To(BeTrue())
|
||||
Expect(li.EndTime.IsZero()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns empty liveItems when tag is absent", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.LiveItems).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ParseRSSFeed — Podcasting 2.0 namespace", func() {
|
||||
Describe("channel-level tags", func() {
|
||||
It("podcast:guid — parses channel GUID", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.PodcastGUID).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810"))
|
||||
})
|
||||
|
||||
It("podcast:locked yes — sets Locked=true and LockedOwner", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Locked).To(BeTrue())
|
||||
Expect(result.LockedOwner).To(Equal("owner@example.com"))
|
||||
})
|
||||
|
||||
It("podcast:locked no — sets Locked=false", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocked))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Locked).To(BeFalse())
|
||||
})
|
||||
|
||||
It("podcast:medium — parses medium type", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Medium).To(Equal("podcast"))
|
||||
})
|
||||
|
||||
It("podcast:funding — stores first entry URL and text", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.FundingItems).ToNot(BeEmpty())
|
||||
Expect(result.FundingItems[0].URL).To(Equal("https://example.com/donate"))
|
||||
Expect(result.FundingItems[0].Text).To(Equal("Support us!"))
|
||||
})
|
||||
|
||||
It("podcast:funding — stores all entries", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
hasSecond := false
|
||||
for _, f := range result.FundingItems {
|
||||
if f.URL == "https://example.com/donate2" {
|
||||
hasSecond = true
|
||||
}
|
||||
}
|
||||
_ = hasSecond
|
||||
})
|
||||
|
||||
It("podcast:person — parses multiple channel persons", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Persons).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("podcast:person — parses name, role, group, img, href", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
host := result.Persons[0]
|
||||
Expect(host.Name).To(Equal("Jane Host"))
|
||||
Expect(host.Role).To(Equal("host"))
|
||||
Expect(host.Group).To(Equal("cast"))
|
||||
Expect(host.Img).To(Equal("https://example.com/host.jpg"))
|
||||
Expect(host.Href).To(Equal("https://example.com/host"))
|
||||
})
|
||||
|
||||
It("podcast:person — role defaults to 'host' when omitted", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Persons[0].Role).To(Equal("host"))
|
||||
})
|
||||
|
||||
It("podcast:person — group defaults to 'cast' when omitted", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Persons[0].Group).To(Equal("cast"))
|
||||
})
|
||||
|
||||
It("podcast:updateFrequency — parses display text and rrule", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.UpdateFrequency).To(Equal("Weekly"))
|
||||
Expect(result.UpdateRRule).To(Equal("FREQ=WEEKLY"))
|
||||
})
|
||||
|
||||
It("podcast:updateFrequency — complete=false sets Complete=false", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Complete).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("episode-level tags", func() {
|
||||
It("podcast:season — parses season number and name", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ep := result.Episodes[0]
|
||||
Expect(ep.Season).To(Equal(1))
|
||||
Expect(ep.SeasonName).To(Equal("Season One"))
|
||||
})
|
||||
|
||||
It("podcast:season — episodes without tag have Season=0", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Episodes[1].Season).To(Equal(0))
|
||||
})
|
||||
|
||||
It("podcast:episode — parses episode number and display label", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ep := result.Episodes[0]
|
||||
Expect(ep.EpisodeNumber).To(Equal("1"))
|
||||
Expect(ep.EpisodeDisplay).To(Equal("Ep.1"))
|
||||
})
|
||||
|
||||
It("podcast:chapters — parses chapters URL and type", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ep := result.Episodes[0]
|
||||
Expect(ep.ChaptersURL).To(Equal("https://example.com/ep1/chapters.json"))
|
||||
Expect(ep.ChaptersType).To(Equal("application/json+chapters"))
|
||||
})
|
||||
|
||||
It("podcast:chapters — episodes without tag have empty ChaptersURL", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Episodes[1].ChaptersURL).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("podcast:transcript — parses multiple transcripts per episode", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Episodes[0].Transcripts).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("podcast:transcript — parses URL, type, language, rel", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
t0 := result.Episodes[0].Transcripts[0]
|
||||
Expect(t0.URL).To(Equal("https://example.com/ep1/transcript.vtt"))
|
||||
Expect(t0.MimeType).To(Equal("text/vtt"))
|
||||
Expect(t0.Language).To(Equal("en"))
|
||||
Expect(t0.Rel).To(Equal("captions"))
|
||||
})
|
||||
|
||||
It("podcast:transcript — rel is empty when omitted", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
t1 := result.Episodes[0].Transcripts[1]
|
||||
Expect(t1.Rel).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("podcast:soundbite — parses startTime and duration as float", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ep := result.Episodes[0]
|
||||
Expect(ep.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001))
|
||||
Expect(ep.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001))
|
||||
Expect(ep.SoundbiteTitle).To(Equal("Best moment"))
|
||||
})
|
||||
|
||||
It("podcast:person — parses episode-level persons", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ep := result.Episodes[0]
|
||||
Expect(ep.Persons).To(HaveLen(1))
|
||||
Expect(ep.Persons[0].Name).To(Equal("John Guest"))
|
||||
Expect(ep.Persons[0].Role).To(Equal("guest"))
|
||||
})
|
||||
|
||||
It("podcast:person — episode person role defaults to 'host'", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Episodes[0].Persons[0].Role).To(Equal("host"))
|
||||
})
|
||||
|
||||
It("podcast:person — episode person group defaults to 'cast'", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPersonDefaults))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Episodes[0].Persons[0].Group).To(Equal("cast"))
|
||||
})
|
||||
|
||||
It("episodes without podcast: tags have zero/empty values", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ep := result.Episodes[1]
|
||||
Expect(ep.Season).To(Equal(0))
|
||||
Expect(ep.ChaptersURL).To(BeEmpty())
|
||||
Expect(ep.Transcripts).To(BeEmpty())
|
||||
Expect(ep.Persons).To(BeEmpty())
|
||||
Expect(ep.SoundbiteStart).To(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("backward compatibility", func() {
|
||||
It("standard RSS feed without podcast: namespace parses correctly", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Title).To(Equal("Test Podcast"))
|
||||
Expect(result.PodcastGUID).To(BeEmpty())
|
||||
Expect(result.Locked).To(BeFalse())
|
||||
Expect(result.Medium).To(BeEmpty())
|
||||
Expect(result.Persons).To(BeEmpty())
|
||||
Expect(result.Episodes[0].Transcripts).To(BeEmpty())
|
||||
Expect(result.Episodes[0].Season).To(Equal(0))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Podcasting 2.0 new metadata tags (location, license, publisher, image) ----
|
||||
|
||||
const testRSSFeedLocation = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>Location Show</title>
|
||||
<podcast:location geo="geo:30.2672,97.7431" osm="R113314">Austin, TX</podcast:location>
|
||||
<item>
|
||||
<title>Live From Austin</title>
|
||||
<guid>ep-loc-1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
<podcast:location geo="geo:51.5074,0.1278" osm="R65606">London, UK</podcast:location>
|
||||
</item>
|
||||
<item>
|
||||
<title>No Location Episode</title>
|
||||
<guid>ep-loc-2</guid>
|
||||
<pubDate>Tue, 02 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep2.mp3" length="1024" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const testRSSFeedLicense = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>License Show</title>
|
||||
<podcast:license url="https://creativecommons.org/licenses/by/4.0/">cc-by-4.0</podcast:license>
|
||||
<item>
|
||||
<title>Episode With License</title>
|
||||
<guid>ep-lic-1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
<podcast:license url="https://creativecommons.org/licenses/by-nd/4.0/">cc-by-nd-4.0</podcast:license>
|
||||
</item>
|
||||
<item>
|
||||
<title>License URL Only</title>
|
||||
<guid>ep-lic-2</guid>
|
||||
<pubDate>Tue, 02 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep2.mp3" length="1024" type="audio/mpeg"/>
|
||||
<podcast:license url="https://example.com/custom-license"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const testRSSFeedPublisher = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>Publisher Show</title>
|
||||
<podcast:publisher>
|
||||
<podcast:name>Acme Podcast Network</podcast:name>
|
||||
<podcast:url>https://acme.example.com</podcast:url>
|
||||
</podcast:publisher>
|
||||
<item>
|
||||
<title>Ep</title>
|
||||
<guid>ep-pub-1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
const testRSSFeedImages = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:podcast="https://podcastindex.org/namespace/1.0">
|
||||
<channel>
|
||||
<title>Images Show</title>
|
||||
<podcast:image href="https://example.com/img-3000.jpg" width="3000"/>
|
||||
<podcast:image href="https://example.com/img-1500.jpg" width="1500"/>
|
||||
<podcast:image href="https://example.com/img-300.jpg" width="300"/>
|
||||
<item>
|
||||
<title>Episode With Images</title>
|
||||
<guid>ep-img-1</guid>
|
||||
<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep.mp3" length="1024" type="audio/mpeg"/>
|
||||
<podcast:image href="https://example.com/ep-img-600.jpg" width="600"/>
|
||||
<podcast:image href="https://example.com/ep-img-150.jpg" width="150"/>
|
||||
</item>
|
||||
<item>
|
||||
<title>Episode Without Images</title>
|
||||
<guid>ep-img-2</guid>
|
||||
<pubDate>Tue, 02 Jan 2024 00:00:00 +0000</pubDate>
|
||||
<enclosure url="https://example.com/ep2.mp3" length="1024" type="audio/mpeg"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
var _ = Describe("ParseRSSFeed — new metadata tags", func() {
|
||||
Describe("podcast:location", func() {
|
||||
It("parses geo, osm, and name at channel level", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.LocationName).To(Equal("Austin, TX"))
|
||||
Expect(result.LocationGeo).To(Equal("geo:30.2672,97.7431"))
|
||||
Expect(result.LocationOSM).To(Equal("R113314"))
|
||||
})
|
||||
|
||||
It("parses geo, osm, and name at episode level", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ep := result.Episodes[0]
|
||||
Expect(ep.LocationName).To(Equal("London, UK"))
|
||||
Expect(ep.LocationGeo).To(Equal("geo:51.5074,0.1278"))
|
||||
Expect(ep.LocationOSM).To(Equal("R65606"))
|
||||
})
|
||||
|
||||
It("leaves location fields empty when tag is absent", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLocation))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ep := result.Episodes[1]
|
||||
Expect(ep.LocationName).To(BeEmpty())
|
||||
Expect(ep.LocationGeo).To(BeEmpty())
|
||||
Expect(ep.LocationOSM).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("leaves channel location fields empty when tag is absent", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.LocationName).To(BeEmpty())
|
||||
Expect(result.LocationGeo).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("podcast:license", func() {
|
||||
It("uses text content as license when present", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.License).To(Equal("cc-by-4.0"))
|
||||
})
|
||||
|
||||
It("parses license at episode level", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Episodes[0].License).To(Equal("cc-by-nd-4.0"))
|
||||
})
|
||||
|
||||
It("falls back to URL attr when text content is empty", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedLicense))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Episodes[1].License).To(Equal("https://example.com/custom-license"))
|
||||
})
|
||||
|
||||
It("leaves license empty when tag is absent", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.License).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("podcast:publisher", func() {
|
||||
It("parses publisher name and URL", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPublisher))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.PublisherName).To(Equal("Acme Podcast Network"))
|
||||
Expect(result.PublisherURL).To(Equal("https://acme.example.com"))
|
||||
})
|
||||
|
||||
It("leaves publisher fields empty when tag is absent", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.PublisherName).To(BeEmpty())
|
||||
Expect(result.PublisherURL).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("podcast:image", func() {
|
||||
It("parses multiple channel-level images", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Images).To(HaveLen(3))
|
||||
})
|
||||
|
||||
It("parses href and width for each channel image", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Images[0].URL).To(Equal("https://example.com/img-3000.jpg"))
|
||||
Expect(result.Images[0].Width).To(Equal(3000))
|
||||
Expect(result.Images[2].URL).To(Equal("https://example.com/img-300.jpg"))
|
||||
Expect(result.Images[2].Width).To(Equal(300))
|
||||
})
|
||||
|
||||
It("parses episode-level images", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ep := result.Episodes[0]
|
||||
Expect(ep.Images).To(HaveLen(2))
|
||||
Expect(ep.Images[0].URL).To(Equal("https://example.com/ep-img-600.jpg"))
|
||||
Expect(ep.Images[0].Width).To(Equal(600))
|
||||
})
|
||||
|
||||
It("episode without images has empty Images slice", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedImages))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Episodes[1].Images).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("channel without images has empty Images slice", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Images).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("podcast:funding — all entries", func() {
|
||||
It("stores all funding entries with correct sort order", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeedPodcast20))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.FundingItems).To(HaveLen(2))
|
||||
Expect(result.FundingItems[0].URL).To(Equal("https://example.com/donate"))
|
||||
Expect(result.FundingItems[0].Text).To(Equal("Support us!"))
|
||||
Expect(result.FundingItems[0].SortOrder).To(Equal(0))
|
||||
Expect(result.FundingItems[1].URL).To(Equal("https://example.com/donate2"))
|
||||
Expect(result.FundingItems[1].Text).To(Equal("Secondary"))
|
||||
Expect(result.FundingItems[1].SortOrder).To(Equal(1))
|
||||
})
|
||||
|
||||
It("returns empty FundingItems when tag is absent", func() {
|
||||
result, err := podcasts.ParseRSSFeed([]byte(testRSSFeed))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.FundingItems).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
"github.com/navidrome/navidrome/core/quickconnect"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
|
|
@ -36,4 +37,5 @@ var Set = wire.NewSet(
|
|||
quickconnect.GetInstance,
|
||||
metrics.GetInstance,
|
||||
lyrics.NewLyrics,
|
||||
podcasts.NewPodcastService,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ var embeddedIDColumns = []struct {
|
|||
}
|
||||
|
||||
func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
|
||||
if err := buildIDMap(ctx, tx); err != nil {
|
||||
if err := buildIDMap(ctx, tx, idColumns); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tc := range idColumns {
|
||||
|
|
@ -129,7 +129,10 @@ func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error {
|
|||
}
|
||||
|
||||
// buildIDMap stages old->new pairs for every id that changes, indexed for the update joins.
|
||||
func buildIDMap(ctx context.Context, tx *sql.Tx) error {
|
||||
// columns is a parameter (not always the package-level idColumns) so a later migration can
|
||||
// reuse this same collect-and-rewrite machinery for a different, disjoint set of columns - see
|
||||
// podcast_uniform_canonical_ids.go, which does exactly that for tables idColumns predates.
|
||||
func buildIDMap(ctx context.Context, tx *sql.Tx, columns []struct{ table, col string }) error {
|
||||
_, err := tx.ExecContext(ctx,
|
||||
"CREATE TEMP TABLE _id_map (old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL) WITHOUT ROWID")
|
||||
if err != nil {
|
||||
|
|
@ -140,7 +143,7 @@ func buildIDMap(ctx context.Context, tx *sql.Tx) error {
|
|||
return err
|
||||
}
|
||||
defer ins.Close()
|
||||
for _, tc := range idColumns {
|
||||
for _, tc := range columns {
|
||||
if err := collectColumn(ctx, tx, ins, tc.table, tc.col); err != nil {
|
||||
return fmt.Errorf("collecting %s.%s: %w", tc.table, tc.col, err)
|
||||
}
|
||||
|
|
|
|||
71
db/migrations/20260930000000_add_podcast.go
Normal file
71
db/migrations/20260930000000_add_podcast.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
// This file (and the 4 that follow it, add_podcast_downloaded_bytes/podcast20/podcast_tier3/
|
||||
// podcast_metadata) were originally timestamped 2026-04-27/28, matching when the podcast
|
||||
// feature was actually developed. They were renumbered to 2026-09-30 (after every migration
|
||||
// already on master as of this PR) before merging: goose.UpContext (as navidrome calls it, with
|
||||
// no WithAllowMissing) hard-errors and refuses to start if it finds a pending migration whose
|
||||
// version is lower than the DB's already-applied max version - which every one of these files
|
||||
// would have been, for any install that had already migrated past April on current master.
|
||||
// Keep new migrations timestamped at-or-after merge time, not authoring time.
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddPodcast, downAddPodcast)
|
||||
}
|
||||
|
||||
func upAddPodcast(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
CREATE TABLE podcast_channel (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
url VARCHAR(4096) NOT NULL,
|
||||
title VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
image_url VARCHAR(4096) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'new',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE podcast_episode (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
channel_id VARCHAR(255) NOT NULL REFERENCES podcast_channel(id) ON DELETE CASCADE,
|
||||
stream_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
guid VARCHAR(4096) NOT NULL DEFAULT '',
|
||||
title VARCHAR(1024) NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
publish_date DATETIME,
|
||||
duration INTEGER NOT NULL DEFAULT 0,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
bit_rate INTEGER NOT NULL DEFAULT 0,
|
||||
suffix VARCHAR(32) NOT NULL DEFAULT '',
|
||||
content_type VARCHAR(255) NOT NULL DEFAULT '',
|
||||
path VARCHAR(4096) NOT NULL DEFAULT '',
|
||||
enclosure_url VARCHAR(4096) NOT NULL DEFAULT '',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'new',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX podcast_episode_channel_id ON podcast_episode(channel_id);
|
||||
CREATE INDEX podcast_episode_publish_date ON podcast_episode(publish_date);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downAddPodcast(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
DROP INDEX IF EXISTS podcast_episode_publish_date;
|
||||
DROP INDEX IF EXISTS podcast_episode_channel_id;
|
||||
DROP TABLE IF EXISTS podcast_episode;
|
||||
DROP TABLE IF EXISTS podcast_channel;
|
||||
`)
|
||||
return err
|
||||
}
|
||||
21
db/migrations/20260930000001_add_podcast_downloaded_bytes.go
Normal file
21
db/migrations/20260930000001_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
|
||||
}
|
||||
76
db/migrations/20260930000002_add_podcast20.go
Normal file
76
db/migrations/20260930000002_add_podcast20.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddPodcast20, downAddPodcast20)
|
||||
}
|
||||
|
||||
func upAddPodcast20(ctx context.Context, tx *sql.Tx) error {
|
||||
sqls := []string{
|
||||
// podcast_channel — Tier 1
|
||||
`ALTER TABLE podcast_channel ADD COLUMN podcast_guid TEXT NOT NULL DEFAULT ''`,
|
||||
// podcast_channel — Tier 2
|
||||
`ALTER TABLE podcast_channel ADD COLUMN locked INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN locked_owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN medium TEXT NOT NULL DEFAULT 'podcast'`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN funding_url TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN funding_text TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN update_frequency TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN update_rrule TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN complete INTEGER NOT NULL DEFAULT 0`,
|
||||
// podcast_episode — Tier 1
|
||||
`ALTER TABLE podcast_episode ADD COLUMN season INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE podcast_episode ADD COLUMN season_name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_episode ADD COLUMN episode_number TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_episode ADD COLUMN episode_display TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_episode ADD COLUMN chapters_url TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_episode ADD COLUMN chapters_type TEXT NOT NULL DEFAULT ''`,
|
||||
// podcast_episode — Tier 2
|
||||
`ALTER TABLE podcast_episode ADD COLUMN soundbite_start REAL NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE podcast_episode ADD COLUMN soundbite_dur REAL NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE podcast_episode ADD COLUMN soundbite_title TEXT NOT NULL DEFAULT ''`,
|
||||
// new tables
|
||||
`CREATE TABLE podcast_transcript (
|
||||
id TEXT PRIMARY KEY,
|
||||
episode_id TEXT NOT NULL REFERENCES podcast_episode(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL DEFAULT '',
|
||||
language TEXT NOT NULL DEFAULT '',
|
||||
rel TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX podcast_transcript_episode_id ON podcast_transcript(episode_id)`,
|
||||
// channel_id/episode_id have no FK constraints: the put() helper serialises empty
|
||||
// strings as "" rather than NULL, which would violate a FK constraint. Cascade
|
||||
// delete is handled at the application layer instead.
|
||||
`CREATE TABLE podcast_person (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT,
|
||||
episode_id TEXT,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'host',
|
||||
group_name TEXT NOT NULL DEFAULT 'cast',
|
||||
img TEXT NOT NULL DEFAULT '',
|
||||
href TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX podcast_person_channel_id ON podcast_person(channel_id)`,
|
||||
`CREATE INDEX podcast_person_episode_id ON podcast_person(episode_id)`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downAddPodcast20(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
59
db/migrations/20260930000003_add_podcast_tier3.go
Normal file
59
db/migrations/20260930000003_add_podcast_tier3.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddPodcastTier3, downAddPodcastTier3)
|
||||
}
|
||||
|
||||
func upAddPodcastTier3(ctx context.Context, tx *sql.Tx) error {
|
||||
sqls := []string{
|
||||
// podcast:podping flag on channel
|
||||
`ALTER TABLE podcast_channel ADD COLUMN uses_podping INTEGER NOT NULL DEFAULT 0`,
|
||||
|
||||
// podcast:podroll — recommended feeds listed by a channel
|
||||
`CREATE TABLE podcast_podroll (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
feed_guid TEXT NOT NULL DEFAULT '',
|
||||
feed_url TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX podcast_podroll_channel_id ON podcast_podroll(channel_id)`,
|
||||
|
||||
// podcast:liveItem — at most one active live item per channel
|
||||
`CREATE TABLE podcast_live_item (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
guid TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
start_time DATETIME,
|
||||
end_time DATETIME,
|
||||
enclosure_url TEXT NOT NULL DEFAULT '',
|
||||
enclosure_type TEXT NOT NULL DEFAULT '',
|
||||
content_link_url TEXT NOT NULL DEFAULT '',
|
||||
content_link_text TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX podcast_live_item_channel_id ON podcast_live_item(channel_id)`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downAddPodcastTier3(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
61
db/migrations/20260930000004_add_podcast_metadata.go
Normal file
61
db/migrations/20260930000004_add_podcast_metadata.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upAddPodcastMetadata, downAddPodcastMetadata)
|
||||
}
|
||||
|
||||
func upAddPodcastMetadata(ctx context.Context, tx *sql.Tx) error {
|
||||
sqls := []string{
|
||||
// podcast_channel — location, license, publisher
|
||||
`ALTER TABLE podcast_channel ADD COLUMN location_name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN location_geo TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN location_osm TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN license TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN publisher_name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_channel ADD COLUMN publisher_url TEXT NOT NULL DEFAULT ''`,
|
||||
// podcast_episode — location, license
|
||||
`ALTER TABLE podcast_episode ADD COLUMN location_name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_episode ADD COLUMN location_geo TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_episode ADD COLUMN location_osm TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE podcast_episode ADD COLUMN license TEXT NOT NULL DEFAULT ''`,
|
||||
// channel_id/episode_id have no FK constraints: the put() helper serialises empty
|
||||
// strings as "" rather than NULL, which would violate a FK constraint. Cascade
|
||||
// delete is handled at the application layer instead.
|
||||
`CREATE TABLE podcast_funding (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
text TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX podcast_funding_channel_id ON podcast_funding(channel_id)`,
|
||||
`CREATE TABLE podcast_image (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL DEFAULT '',
|
||||
episode_id TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL,
|
||||
width INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX podcast_image_channel_id ON podcast_image(channel_id)`,
|
||||
`CREATE INDEX podcast_image_episode_id ON podcast_image(episode_id)`,
|
||||
}
|
||||
for _, s := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downAddPodcastMetadata(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upPodcastUniformCanonicalIds, downPodcastUniformCanonicalIds)
|
||||
}
|
||||
|
||||
// podcastIDColumns lists every Navidrome-id-bearing podcast_* column, the same inventory
|
||||
// uniform_canonical_ids (20260720015443) keeps for every other table - see this file's own
|
||||
// upPodcastUniformCanonicalIds doc comment for why podcast ids need their own, later migration
|
||||
// instead of just being added to that one's idColumns.
|
||||
var podcastIDColumns = []struct{ table, col string }{
|
||||
{"podcast_channel", "id"},
|
||||
{"podcast_episode", "id"}, {"podcast_episode", "channel_id"}, {"podcast_episode", "stream_id"},
|
||||
{"podcast_transcript", "id"}, {"podcast_transcript", "episode_id"},
|
||||
{"podcast_person", "id"}, {"podcast_person", "channel_id"}, {"podcast_person", "episode_id"},
|
||||
{"podcast_podroll", "id"}, {"podcast_podroll", "channel_id"},
|
||||
{"podcast_live_item", "id"}, {"podcast_live_item", "channel_id"},
|
||||
{"podcast_funding", "id"}, {"podcast_funding", "channel_id"},
|
||||
{"podcast_image", "id"}, {"podcast_image", "channel_id"}, {"podcast_image", "episode_id"},
|
||||
}
|
||||
|
||||
// upPodcastUniformCanonicalIds rewrites podcast_* ids to the same canonical 22-char base62
|
||||
// encoding uniform_canonical_ids (20260720015443) already applied to every other table.
|
||||
//
|
||||
// It has to be a separate, later migration rather than an addition to that one's idColumns,
|
||||
// for two independent reasons:
|
||||
// 1. The podcast_* tables don't exist yet when 20260720015443 runs - the add_podcast* migrations
|
||||
// that create them are timestamped after it (2026-09-02, see add_podcast.go's own comment on
|
||||
// why) - so a SELECT against them there would fail outright, even on a fresh install.
|
||||
// 2. Editing an already-applied migration's Go source has no runtime effect on any install that
|
||||
// already ran it: goose tracks migrations as applied-or-not by version, not by re-diffing
|
||||
// their source on every startup. An install that ran 20260720015443 before this feature
|
||||
// existed would never re-run it, no matter what idColumns says today.
|
||||
func upPodcastUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
|
||||
if err := buildIDMap(ctx, tx, podcastIDColumns); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, tc := range podcastIDColumns {
|
||||
if err := applyIDMap(ctx, tx, tc.table, tc.col); err != nil {
|
||||
return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err)
|
||||
}
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, "DROP TABLE _id_map")
|
||||
return err
|
||||
}
|
||||
|
||||
func downPodcastUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil // irreversible data migration
|
||||
}
|
||||
|
|
@ -37,6 +37,14 @@ type DataStore interface {
|
|||
Plugin() PluginRepository
|
||||
Artwork() ArtworkRepository
|
||||
ArtworkQueue() ArtworkQueueRepository
|
||||
PodcastChannel() PodcastChannelRepository
|
||||
PodcastEpisode() PodcastEpisodeRepository
|
||||
PodcastTranscript() PodcastTranscriptRepository
|
||||
PodcastPerson() PodcastPersonRepository
|
||||
PodcastPodroll() PodcastPodrollRepository
|
||||
PodcastLiveItem() PodcastLiveItemRepository
|
||||
PodcastFunding() PodcastFundingRepository
|
||||
PodcastImage() PodcastImageRepository
|
||||
|
||||
WithTx(block func(tx DataStore) error, scope ...string) error
|
||||
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
|
||||
|
|
|
|||
249
model/podcast.go
Normal file
249
model/podcast.go
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
// PodcastPodrollItem represents one entry in a podcast:podroll recommendation list.
|
||||
type PodcastPodrollItem struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId"`
|
||||
FeedGUID string `structs:"feed_guid" json:"feedGuid,omitempty"`
|
||||
FeedURL string `structs:"feed_url" json:"feedUrl,omitempty"`
|
||||
Title string `structs:"title" json:"title,omitempty"`
|
||||
SortOrder int `structs:"sort_order" json:"sortOrder"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
// PodcastLiveItem represents a podcast:liveItem stream associated with a channel.
|
||||
type PodcastLiveItem struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId"`
|
||||
GUID string `structs:"guid" json:"guid,omitempty"`
|
||||
Title string `structs:"title" json:"title,omitempty"`
|
||||
Status string `structs:"status" json:"status"`
|
||||
StartTime time.Time `structs:"start_time" json:"startTime,omitempty"`
|
||||
EndTime time.Time `structs:"end_time" json:"endTime,omitempty"`
|
||||
EnclosureURL string `structs:"enclosure_url" json:"enclosureUrl,omitempty"`
|
||||
EnclosureType string `structs:"enclosure_type" json:"enclosureType,omitempty"`
|
||||
ContentLinkURL string `structs:"content_link_url" json:"contentLinkUrl,omitempty"`
|
||||
ContentLinkText string `structs:"content_link_text" json:"contentLinkText,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PodcastPodrollItems is a slice of PodcastPodrollItem.
|
||||
type PodcastPodrollItems []PodcastPodrollItem
|
||||
|
||||
// PodcastPodrollRepository manages podcast:podroll entries for channels.
|
||||
type PodcastPodrollRepository interface {
|
||||
GetByChannel(ctx context.Context, channelID string) (PodcastPodrollItems, error)
|
||||
GetByChannels(ctx context.Context, channelIDs []string) (PodcastPodrollItems, error)
|
||||
SaveForChannel(ctx context.Context, channelID string, items []PodcastPodrollItem) error
|
||||
}
|
||||
|
||||
// PodcastLiveItemRepository manages podcast:liveItem entries (one per channel).
|
||||
type PodcastLiveItemRepository interface {
|
||||
GetByChannel(ctx context.Context, channelID string) (*PodcastLiveItem, error)
|
||||
Upsert(ctx context.Context, item *PodcastLiveItem) error
|
||||
DeleteByChannel(ctx context.Context, channelID string) error
|
||||
}
|
||||
|
||||
type PodcastStatus string
|
||||
|
||||
const (
|
||||
PodcastStatusNew PodcastStatus = "new"
|
||||
PodcastStatusDownloading PodcastStatus = "downloading"
|
||||
PodcastStatusCompleted PodcastStatus = "completed"
|
||||
PodcastStatusError PodcastStatus = "error"
|
||||
PodcastStatusSkipped PodcastStatus = "skipped"
|
||||
PodcastStatusDeleted PodcastStatus = "deleted"
|
||||
)
|
||||
|
||||
type PodcastChannel struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
URL string `structs:"url" json:"url"`
|
||||
Title string `structs:"title" json:"title"`
|
||||
Description string `structs:"description" json:"description"`
|
||||
ImageURL string `structs:"image_url" json:"imageUrl"`
|
||||
Status PodcastStatus `structs:"status" json:"status"`
|
||||
ErrorMessage string `structs:"error_message" json:"errorMessage"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
|
||||
// Podcasting 2.0 — Tier 1
|
||||
PodcastGUID string `structs:"podcast_guid" json:"podcastGuid,omitempty"`
|
||||
|
||||
// Podcasting 2.0 — Tier 2
|
||||
Locked bool `structs:"locked" json:"locked,omitempty"`
|
||||
LockedOwner string `structs:"locked_owner" json:"lockedOwner,omitempty"`
|
||||
Medium string `structs:"medium" json:"medium,omitempty"`
|
||||
FundingURL string `structs:"funding_url" json:"fundingUrl,omitempty"`
|
||||
FundingText string `structs:"funding_text" json:"fundingText,omitempty"`
|
||||
UpdateFrequency string `structs:"update_frequency" json:"updateFrequency,omitempty"`
|
||||
UpdateRRule string `structs:"update_rrule" json:"updateRRule,omitempty"`
|
||||
Complete bool `structs:"complete" json:"complete,omitempty"`
|
||||
LocationName string `structs:"location_name" json:"locationName,omitempty"`
|
||||
LocationGeo string `structs:"location_geo" json:"locationGeo,omitempty"`
|
||||
LocationOSM string `structs:"location_osm" json:"locationOsm,omitempty"`
|
||||
License string `structs:"license" json:"license,omitempty"`
|
||||
PublisherName string `structs:"publisher_name" json:"publisherName,omitempty"`
|
||||
PublisherURL string `structs:"publisher_url" json:"publisherUrl,omitempty"`
|
||||
|
||||
// Podcasting 2.0 — Tier 3
|
||||
UsesPodping bool `structs:"uses_podping" json:"usesPodping,omitempty"`
|
||||
Podroll PodcastPodrollItems `structs:"-" json:"podroll,omitempty"`
|
||||
LiveItem *PodcastLiveItem `structs:"-" json:"liveItem,omitempty"`
|
||||
|
||||
// loaded separately
|
||||
Episodes PodcastEpisodes `structs:"-" json:"episodes,omitempty"`
|
||||
Persons PodcastPersons `structs:"-" json:"persons,omitempty"`
|
||||
FundingItems PodcastFundingItems `structs:"-" json:"funding,omitempty"`
|
||||
Images PodcastImages `structs:"-" json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type PodcastEpisode struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId"`
|
||||
StreamID string `structs:"stream_id" json:"streamId"`
|
||||
GUID string `structs:"guid" json:"guid"`
|
||||
Title string `structs:"title" json:"title"`
|
||||
Description string `structs:"description" json:"description"`
|
||||
PublishDate time.Time `structs:"publish_date" json:"publishDate"`
|
||||
Duration int `structs:"duration" json:"duration"`
|
||||
Size int64 `structs:"size" json:"size"`
|
||||
BitRate int `structs:"bit_rate" json:"bitRate"`
|
||||
Suffix string `structs:"suffix" json:"suffix"`
|
||||
ContentType string `structs:"content_type" json:"contentType"`
|
||||
Path string `structs:"path" json:"path"`
|
||||
EnclosureURL string `structs:"enclosure_url" json:"enclosureUrl"`
|
||||
DownloadedBytes int64 `structs:"downloaded_bytes" json:"downloadedBytes"`
|
||||
Status PodcastStatus `structs:"status" json:"status"`
|
||||
ErrorMessage string `structs:"error_message" json:"errorMessage"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
|
||||
|
||||
// Podcasting 2.0 — Tier 1
|
||||
Season int `structs:"season" json:"season,omitempty"`
|
||||
SeasonName string `structs:"season_name" json:"seasonName,omitempty"`
|
||||
EpisodeNumber string `structs:"episode_number" json:"episodeNumber,omitempty"`
|
||||
EpisodeDisplay string `structs:"episode_display" json:"episodeDisplay,omitempty"`
|
||||
ChaptersURL string `structs:"chapters_url" json:"chaptersUrl,omitempty"`
|
||||
ChaptersType string `structs:"chapters_type" json:"chaptersType,omitempty"`
|
||||
|
||||
// Podcasting 2.0 — Tier 2
|
||||
SoundbiteStart float64 `structs:"soundbite_start" json:"soundbiteStart,omitempty"`
|
||||
SoundbiteDur float64 `structs:"soundbite_dur" json:"soundbiteDur,omitempty"`
|
||||
SoundbiteTitle string `structs:"soundbite_title" json:"soundbiteTitle,omitempty"`
|
||||
LocationName string `structs:"location_name" json:"locationName,omitempty"`
|
||||
LocationGeo string `structs:"location_geo" json:"locationGeo,omitempty"`
|
||||
LocationOSM string `structs:"location_osm" json:"locationOsm,omitempty"`
|
||||
License string `structs:"license" json:"license,omitempty"`
|
||||
|
||||
// loaded separately
|
||||
Transcripts PodcastTranscripts `structs:"-" json:"transcripts,omitempty"`
|
||||
Persons PodcastPersons `structs:"-" json:"persons,omitempty"`
|
||||
Images PodcastImages `structs:"-" json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type PodcastTranscript struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
EpisodeID string `structs:"episode_id" json:"episodeId"`
|
||||
URL string `structs:"url" json:"url"`
|
||||
MimeType string `structs:"mime_type" json:"type"`
|
||||
Language string `structs:"language" json:"language,omitempty"`
|
||||
Rel string `structs:"rel" json:"rel,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
type PodcastPerson struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId,omitempty"`
|
||||
EpisodeID string `structs:"episode_id" json:"episodeId,omitempty"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Role string `structs:"role" json:"role,omitempty"`
|
||||
Group string `structs:"group_name" db:"group_name" json:"group,omitempty"`
|
||||
Img string `structs:"img" json:"img,omitempty"`
|
||||
Href string `structs:"href" json:"href,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
type PodcastFundingItem struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId"`
|
||||
URL string `structs:"url" json:"url"`
|
||||
Text string `structs:"text" json:"text,omitempty"`
|
||||
SortOrder int `structs:"sort_order" json:"sortOrder"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
type PodcastImage struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
ChannelID string `structs:"channel_id" json:"channelId,omitempty"`
|
||||
EpisodeID string `structs:"episode_id" json:"episodeId,omitempty"`
|
||||
URL string `structs:"url" json:"url"`
|
||||
Width int `structs:"width" json:"width,omitempty"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
}
|
||||
|
||||
type PodcastChannels []PodcastChannel
|
||||
type PodcastEpisodes []PodcastEpisode
|
||||
type PodcastTranscripts []PodcastTranscript
|
||||
type PodcastPersons []PodcastPerson
|
||||
type PodcastFundingItems []PodcastFundingItem
|
||||
type PodcastImages []PodcastImage
|
||||
|
||||
type PodcastChannelRepository interface {
|
||||
rest.Repository[PodcastChannel]
|
||||
rest.Persistable[PodcastChannel]
|
||||
Get(ctx context.Context, id string) (*PodcastChannel, error)
|
||||
GetAll(ctx context.Context, withEpisodes bool) (PodcastChannels, error)
|
||||
ExistsByURL(ctx context.Context, url string) (bool, error)
|
||||
Create(ctx context.Context, channel *PodcastChannel) error
|
||||
UpdateChannel(ctx context.Context, channel *PodcastChannel) error
|
||||
Delete(ctx context.Context, ids ...string) error
|
||||
}
|
||||
|
||||
type PodcastEpisodeRepository interface {
|
||||
Get(ctx context.Context, id string) (*PodcastEpisode, error)
|
||||
GetNewest(ctx context.Context, count int) (PodcastEpisodes, error)
|
||||
GetByChannel(ctx context.Context, channelID string) (PodcastEpisodes, error)
|
||||
GetByChannels(ctx context.Context, channelIDs []string) (PodcastEpisodes, error)
|
||||
GetByGUID(ctx context.Context, channelID, guid string) (*PodcastEpisode, error)
|
||||
Create(ctx context.Context, ep *PodcastEpisode) error
|
||||
Update(ctx context.Context, ep *PodcastEpisode) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type PodcastTranscriptRepository interface {
|
||||
GetByEpisode(ctx context.Context, episodeID string) (PodcastTranscripts, error)
|
||||
GetByEpisodes(ctx context.Context, episodeIDs []string) (PodcastTranscripts, error)
|
||||
Save(ctx context.Context, transcripts []PodcastTranscript) error
|
||||
DeleteByEpisode(ctx context.Context, episodeID string) error
|
||||
}
|
||||
|
||||
type PodcastPersonRepository interface {
|
||||
GetByChannel(ctx context.Context, channelID string) (PodcastPersons, error)
|
||||
GetByEpisode(ctx context.Context, episodeID string) (PodcastPersons, error)
|
||||
GetByEpisodes(ctx context.Context, episodeIDs []string) (PodcastPersons, error)
|
||||
SaveForChannel(ctx context.Context, channelID string, persons []PodcastPerson) error
|
||||
SaveForEpisode(ctx context.Context, episodeID string, persons []PodcastPerson) error
|
||||
}
|
||||
|
||||
type PodcastFundingRepository interface {
|
||||
GetByChannel(ctx context.Context, channelID string) (PodcastFundingItems, error)
|
||||
GetByChannels(ctx context.Context, channelIDs []string) (PodcastFundingItems, error)
|
||||
SaveForChannel(ctx context.Context, channelID string, items []PodcastFundingItem) error
|
||||
}
|
||||
|
||||
type PodcastImageRepository interface {
|
||||
GetByChannel(ctx context.Context, channelID string) (PodcastImages, error)
|
||||
GetByChannels(ctx context.Context, channelIDs []string) (PodcastImages, error)
|
||||
GetByEpisode(ctx context.Context, episodeID string) (PodcastImages, error)
|
||||
GetByEpisodes(ctx context.Context, episodeIDs []string) (PodcastImages, error)
|
||||
SaveForChannel(ctx context.Context, channelID string, images []PodcastImage) error
|
||||
SaveForEpisode(ctx context.Context, episodeID string, images []PodcastImage) error
|
||||
}
|
||||
|
|
@ -15,55 +15,71 @@ import (
|
|||
)
|
||||
|
||||
type SQLStore struct {
|
||||
db dbx.Builder
|
||||
library func() model.LibraryRepository
|
||||
folder func() model.FolderRepository
|
||||
album func() model.AlbumRepository
|
||||
artist func() model.ArtistRepository
|
||||
mediaFile func() model.MediaFileRepository
|
||||
genre func() model.GenreRepository
|
||||
tag func() model.TagRepository
|
||||
playlist func() model.PlaylistRepository
|
||||
playQueue func() model.PlayQueueRepository
|
||||
transcoding func() model.TranscodingRepository
|
||||
player func() model.PlayerRepository
|
||||
radio func() model.RadioRepository
|
||||
share func() model.ShareRepository
|
||||
property func() model.PropertyRepository
|
||||
user func() model.UserRepository
|
||||
userProps func() model.UserPropsRepository
|
||||
scrobbleBuf func() model.ScrobbleBufferRepository
|
||||
scrobble func() model.ScrobbleRepository
|
||||
plugin func() model.PluginRepository
|
||||
artwork func() model.ArtworkRepository
|
||||
artworkQueue func() model.ArtworkQueueRepository
|
||||
db dbx.Builder
|
||||
library func() model.LibraryRepository
|
||||
folder func() model.FolderRepository
|
||||
album func() model.AlbumRepository
|
||||
artist func() model.ArtistRepository
|
||||
mediaFile func() model.MediaFileRepository
|
||||
genre func() model.GenreRepository
|
||||
tag func() model.TagRepository
|
||||
playlist func() model.PlaylistRepository
|
||||
playQueue func() model.PlayQueueRepository
|
||||
transcoding func() model.TranscodingRepository
|
||||
player func() model.PlayerRepository
|
||||
radio func() model.RadioRepository
|
||||
share func() model.ShareRepository
|
||||
property func() model.PropertyRepository
|
||||
user func() model.UserRepository
|
||||
userProps func() model.UserPropsRepository
|
||||
scrobbleBuf func() model.ScrobbleBufferRepository
|
||||
scrobble func() model.ScrobbleRepository
|
||||
plugin func() model.PluginRepository
|
||||
artwork func() model.ArtworkRepository
|
||||
artworkQueue func() model.ArtworkQueueRepository
|
||||
podcastChannel func() model.PodcastChannelRepository
|
||||
podcastEpisode func() model.PodcastEpisodeRepository
|
||||
podcastTranscript func() model.PodcastTranscriptRepository
|
||||
podcastPerson func() model.PodcastPersonRepository
|
||||
podcastPodroll func() model.PodcastPodrollRepository
|
||||
podcastLiveItem func() model.PodcastLiveItemRepository
|
||||
podcastFunding func() model.PodcastFundingRepository
|
||||
podcastImage func() model.PodcastImageRepository
|
||||
}
|
||||
|
||||
// Repositories are built on first use, so a transaction store only pays for the ones its block touches.
|
||||
func newSQLStore(db dbx.Builder) *SQLStore {
|
||||
return &SQLStore{
|
||||
db: db,
|
||||
library: sync.OnceValue(func() model.LibraryRepository { return NewLibraryRepository(db) }),
|
||||
folder: sync.OnceValue(func() model.FolderRepository { return newFolderRepository(db) }),
|
||||
album: sync.OnceValue(func() model.AlbumRepository { return NewAlbumRepository(db) }),
|
||||
artist: sync.OnceValue(func() model.ArtistRepository { return NewArtistRepository(db) }),
|
||||
mediaFile: sync.OnceValue(func() model.MediaFileRepository { return NewMediaFileRepository(db) }),
|
||||
genre: sync.OnceValue(func() model.GenreRepository { return NewGenreRepository(db) }),
|
||||
tag: sync.OnceValue(func() model.TagRepository { return NewTagRepository(db) }),
|
||||
playlist: sync.OnceValue(func() model.PlaylistRepository { return NewPlaylistRepository(db) }),
|
||||
playQueue: sync.OnceValue(func() model.PlayQueueRepository { return NewPlayQueueRepository(db) }),
|
||||
transcoding: sync.OnceValue(func() model.TranscodingRepository { return NewTranscodingRepository(db) }),
|
||||
player: sync.OnceValue(func() model.PlayerRepository { return NewPlayerRepository(db) }),
|
||||
radio: sync.OnceValue(func() model.RadioRepository { return NewRadioRepository(db) }),
|
||||
share: sync.OnceValue(func() model.ShareRepository { return NewShareRepository(db) }),
|
||||
property: sync.OnceValue(func() model.PropertyRepository { return NewPropertyRepository(db) }),
|
||||
user: sync.OnceValue(func() model.UserRepository { return NewUserRepository(db) }),
|
||||
userProps: sync.OnceValue(func() model.UserPropsRepository { return NewUserPropsRepository(db) }),
|
||||
scrobbleBuf: sync.OnceValue(func() model.ScrobbleBufferRepository { return NewScrobbleBufferRepository(db) }),
|
||||
scrobble: sync.OnceValue(func() model.ScrobbleRepository { return NewScrobbleRepository(db) }),
|
||||
plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }),
|
||||
artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }),
|
||||
artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }),
|
||||
db: db,
|
||||
library: sync.OnceValue(func() model.LibraryRepository { return NewLibraryRepository(db) }),
|
||||
folder: sync.OnceValue(func() model.FolderRepository { return newFolderRepository(db) }),
|
||||
album: sync.OnceValue(func() model.AlbumRepository { return NewAlbumRepository(db) }),
|
||||
artist: sync.OnceValue(func() model.ArtistRepository { return NewArtistRepository(db) }),
|
||||
mediaFile: sync.OnceValue(func() model.MediaFileRepository { return NewMediaFileRepository(db) }),
|
||||
genre: sync.OnceValue(func() model.GenreRepository { return NewGenreRepository(db) }),
|
||||
tag: sync.OnceValue(func() model.TagRepository { return NewTagRepository(db) }),
|
||||
playlist: sync.OnceValue(func() model.PlaylistRepository { return NewPlaylistRepository(db) }),
|
||||
playQueue: sync.OnceValue(func() model.PlayQueueRepository { return NewPlayQueueRepository(db) }),
|
||||
transcoding: sync.OnceValue(func() model.TranscodingRepository { return NewTranscodingRepository(db) }),
|
||||
player: sync.OnceValue(func() model.PlayerRepository { return NewPlayerRepository(db) }),
|
||||
radio: sync.OnceValue(func() model.RadioRepository { return NewRadioRepository(db) }),
|
||||
share: sync.OnceValue(func() model.ShareRepository { return NewShareRepository(db) }),
|
||||
property: sync.OnceValue(func() model.PropertyRepository { return NewPropertyRepository(db) }),
|
||||
user: sync.OnceValue(func() model.UserRepository { return NewUserRepository(db) }),
|
||||
userProps: sync.OnceValue(func() model.UserPropsRepository { return NewUserPropsRepository(db) }),
|
||||
scrobbleBuf: sync.OnceValue(func() model.ScrobbleBufferRepository { return NewScrobbleBufferRepository(db) }),
|
||||
scrobble: sync.OnceValue(func() model.ScrobbleRepository { return NewScrobbleRepository(db) }),
|
||||
plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }),
|
||||
artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }),
|
||||
artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }),
|
||||
podcastChannel: sync.OnceValue(func() model.PodcastChannelRepository { return NewPodcastChannelRepository(db) }),
|
||||
podcastEpisode: sync.OnceValue(func() model.PodcastEpisodeRepository { return NewPodcastEpisodeRepository(db) }),
|
||||
podcastTranscript: sync.OnceValue(func() model.PodcastTranscriptRepository { return NewPodcastTranscriptRepository(db) }),
|
||||
podcastPerson: sync.OnceValue(func() model.PodcastPersonRepository { return NewPodcastPersonRepository(db) }),
|
||||
podcastPodroll: sync.OnceValue(func() model.PodcastPodrollRepository { return NewPodcastPodrollRepository(db) }),
|
||||
podcastLiveItem: sync.OnceValue(func() model.PodcastLiveItemRepository { return NewPodcastLiveItemRepository(db) }),
|
||||
podcastFunding: sync.OnceValue(func() model.PodcastFundingRepository { return NewPodcastFundingRepository(db) }),
|
||||
podcastImage: sync.OnceValue(func() model.PodcastImageRepository { return NewPodcastImageRepository(db) }),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +171,38 @@ func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository {
|
|||
return s.artworkQueue()
|
||||
}
|
||||
|
||||
func (s *SQLStore) PodcastChannel() model.PodcastChannelRepository {
|
||||
return s.podcastChannel()
|
||||
}
|
||||
|
||||
func (s *SQLStore) PodcastEpisode() model.PodcastEpisodeRepository {
|
||||
return s.podcastEpisode()
|
||||
}
|
||||
|
||||
func (s *SQLStore) PodcastTranscript() model.PodcastTranscriptRepository {
|
||||
return s.podcastTranscript()
|
||||
}
|
||||
|
||||
func (s *SQLStore) PodcastPerson() model.PodcastPersonRepository {
|
||||
return s.podcastPerson()
|
||||
}
|
||||
|
||||
func (s *SQLStore) PodcastPodroll() model.PodcastPodrollRepository {
|
||||
return s.podcastPodroll()
|
||||
}
|
||||
|
||||
func (s *SQLStore) PodcastLiveItem() model.PodcastLiveItemRepository {
|
||||
return s.podcastLiveItem()
|
||||
}
|
||||
|
||||
func (s *SQLStore) PodcastFunding() model.PodcastFundingRepository {
|
||||
return s.podcastFunding()
|
||||
}
|
||||
|
||||
func (s *SQLStore) PodcastImage() model.PodcastImageRepository {
|
||||
return s.podcastImage()
|
||||
}
|
||||
|
||||
func scopeLabel(scope []string) string {
|
||||
if len(scope) > 0 {
|
||||
return scope[0]
|
||||
|
|
|
|||
|
|
@ -145,6 +145,41 @@ var (
|
|||
testRadios = model.Radios{radioWithoutHomePage, radioWithHomePage}
|
||||
)
|
||||
|
||||
var (
|
||||
podcastChannel1 = model.PodcastChannel{
|
||||
ID: "pc-1",
|
||||
URL: "https://example.com/feed1.xml",
|
||||
Title: "Test Podcast",
|
||||
Description: "A test podcast",
|
||||
Status: model.PodcastStatusCompleted,
|
||||
}
|
||||
podcastChannel2 = model.PodcastChannel{
|
||||
ID: "pc-2",
|
||||
URL: "https://example.com/feed2.xml",
|
||||
Title: "Another Podcast",
|
||||
Status: model.PodcastStatusNew,
|
||||
}
|
||||
testPodcastChannels = model.PodcastChannels{podcastChannel1, podcastChannel2}
|
||||
|
||||
podcastEpisode1 = model.PodcastEpisode{
|
||||
ID: "pe-1",
|
||||
ChannelID: "pc-1",
|
||||
GUID: "guid-001",
|
||||
Title: "Episode 1",
|
||||
Status: model.PodcastStatusCompleted,
|
||||
PublishDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
podcastEpisode2 = model.PodcastEpisode{
|
||||
ID: "pe-2",
|
||||
ChannelID: "pc-1",
|
||||
GUID: "guid-002",
|
||||
Title: "Episode 2",
|
||||
Status: model.PodcastStatusNew,
|
||||
PublishDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
testPodcastEpisodes = model.PodcastEpisodes{podcastEpisode1, podcastEpisode2}
|
||||
)
|
||||
|
||||
var (
|
||||
plsBest model.Playlist
|
||||
plsCool model.Playlist
|
||||
|
|
@ -274,6 +309,22 @@ var _ = BeforeSuite(func() {
|
|||
}
|
||||
}
|
||||
|
||||
pcr := NewPodcastChannelRepository(conn)
|
||||
for i := range testPodcastChannels {
|
||||
c := testPodcastChannels[i]
|
||||
if err := pcr.Create(ctx, &c); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
per := NewPodcastEpisodeRepository(conn)
|
||||
for i := range testPodcastEpisodes {
|
||||
e := testPodcastEpisodes[i]
|
||||
if err := per.Create(ctx, &e); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
plsBest = model.Playlist{
|
||||
Name: "Best",
|
||||
Comment: "No Comments",
|
||||
|
|
|
|||
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(db dbx.Builder) model.PodcastChannelRepository {
|
||||
r := &podcastChannelRepository{}
|
||||
r.db = db
|
||||
r.registerModel(&model.PodcastChannel{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) isPermitted(ctx context.Context) bool {
|
||||
return loggedUser(ctx).IsAdmin
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) Get(ctx context.Context, chanID string) (*model.PodcastChannel, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"id": chanID})
|
||||
res := model.PodcastChannel{}
|
||||
if err := r.queryOne(ctx, sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) GetAll(ctx context.Context, withEpisodes bool) (model.PodcastChannels, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").OrderBy("title")
|
||||
var channels model.PodcastChannels
|
||||
if err := r.queryAll(ctx, sel, &channels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if withEpisodes && len(channels) > 0 {
|
||||
ids := make([]string, len(channels))
|
||||
for i, ch := range channels {
|
||||
ids[i] = ch.ID
|
||||
}
|
||||
epRepo := NewPodcastEpisodeRepository(r.db)
|
||||
allEps, err := epRepo.GetByChannels(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
epsByChannel := make(map[string]model.PodcastEpisodes, len(channels))
|
||||
for _, ep := range allEps {
|
||||
epsByChannel[ep.ChannelID] = append(epsByChannel[ep.ChannelID], ep)
|
||||
}
|
||||
for i := range channels {
|
||||
channels[i].Episodes = epsByChannel[channels[i].ID]
|
||||
}
|
||||
}
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) ExistsByURL(ctx context.Context, url string) (bool, error) {
|
||||
sel := r.newSelect(ctx).Columns("count(*)").Where(Eq{"url": url})
|
||||
count, err := r.count(ctx, sel)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) Create(ctx context.Context, channel *model.PodcastChannel) error {
|
||||
if !r.isPermitted(ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
now := time.Now()
|
||||
channel.CreatedAt = now
|
||||
channel.UpdatedAt = now
|
||||
if channel.ID == "" {
|
||||
channel.ID = id.NewRandom()
|
||||
}
|
||||
_, err := r.put(ctx, channel.ID, channel)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) UpdateChannel(ctx context.Context, channel *model.PodcastChannel) error {
|
||||
if !r.isPermitted(ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
channel.UpdatedAt = time.Now()
|
||||
_, err := r.put(ctx, channel.ID, channel)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) Delete(ctx context.Context, ids ...string) error {
|
||||
if !r.isPermitted(ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.delete(ctx, Eq{"id": ids})
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) Read(ctx context.Context, chanID string) (*model.PodcastChannel, error) {
|
||||
return r.Get(ctx, chanID)
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.PodcastChannel, error) {
|
||||
sel := r.newSelect(ctx, r.parseRestOptions(ctx, options...)).Columns("*")
|
||||
var channels model.PodcastChannels
|
||||
err := r.queryAll(ctx, sel, &channels)
|
||||
return channels, err
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) Save(ctx context.Context, ch *model.PodcastChannel) (string, error) {
|
||||
if !r.isPermitted(ctx) {
|
||||
return "", rest.ErrPermissionDenied
|
||||
}
|
||||
err := r.Create(ctx, ch)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return "", rest.ErrNotFound
|
||||
}
|
||||
return ch.ID, err
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) Update(ctx context.Context, id string, entity model.PodcastChannel, cols ...string) error {
|
||||
ch := &entity
|
||||
ch.ID = id
|
||||
if !r.isPermitted(ctx) {
|
||||
return rest.ErrPermissionDenied
|
||||
}
|
||||
return r.UpdateChannel(ctx, ch)
|
||||
}
|
||||
|
||||
func (r *podcastChannelRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) {
|
||||
sql := r.newSelect(ctx, r.parseRestOptions(ctx, options...))
|
||||
return r.count(ctx, sql)
|
||||
}
|
||||
|
||||
var _ model.PodcastChannelRepository = (*podcastChannelRepository)(nil)
|
||||
135
persistence/podcast_channel_repository_test.go
Normal file
135
persistence/podcast_channel_repository_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PodcastChannelRepository", func() {
|
||||
var repo model.PodcastChannelRepository
|
||||
var adminCtx, userCtx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
adminCtx = request.WithUser(ctx, adminUser)
|
||||
userCtx = request.WithUser(ctx, regularUser)
|
||||
repo = NewPodcastChannelRepository(GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing channel", func() {
|
||||
ch, err := repo.Get(adminCtx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ch.ID).To(Equal("pc-1"))
|
||||
Expect(ch.Title).To(Equal("Test Podcast"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound for unknown id", func() {
|
||||
_, err := repo.Get(adminCtx, "no-such-id")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAll", func() {
|
||||
It("returns all channels without episodes", func() {
|
||||
channels, err := repo.GetAll(adminCtx, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(channels)).To(BeNumerically(">=", 2))
|
||||
for _, ch := range channels {
|
||||
Expect(ch.Episodes).To(BeEmpty())
|
||||
}
|
||||
})
|
||||
|
||||
It("returns channels with episodes when withEpisodes=true", func() {
|
||||
channels, err := repo.GetAll(adminCtx, true)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
var ch1 *model.PodcastChannel
|
||||
for i := range channels {
|
||||
if channels[i].ID == "pc-1" {
|
||||
ch1 = &channels[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(ch1).ToNot(BeNil())
|
||||
Expect(ch1.Episodes).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Create", func() {
|
||||
It("creates a new channel and assigns an ID", func() {
|
||||
ch := &model.PodcastChannel{
|
||||
URL: "https://new.example.com/feed.xml",
|
||||
Title: "New Podcast",
|
||||
Status: model.PodcastStatusNew,
|
||||
}
|
||||
err := repo.Create(adminCtx, ch)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ch.ID).ToNot(BeEmpty())
|
||||
|
||||
saved, err := repo.Get(adminCtx, ch.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(saved.Title).To(Equal("New Podcast"))
|
||||
|
||||
// cleanup
|
||||
_ = repo.Delete(adminCtx, ch.ID)
|
||||
})
|
||||
|
||||
It("denies non-admin users", func() {
|
||||
err := repo.Create(userCtx, &model.PodcastChannel{URL: "https://x.com/feed.xml"})
|
||||
Expect(err).To(MatchError(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
It("updates an existing channel", func() {
|
||||
ch := &model.PodcastChannel{
|
||||
URL: "https://update.example.com/feed.xml",
|
||||
Title: "Before Update",
|
||||
Status: model.PodcastStatusNew,
|
||||
}
|
||||
_ = repo.Create(adminCtx, ch)
|
||||
|
||||
ch.Title = "After Update"
|
||||
err := repo.UpdateChannel(adminCtx, ch)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
saved, _ := repo.Get(adminCtx, ch.ID)
|
||||
Expect(saved.Title).To(Equal("After Update"))
|
||||
|
||||
// cleanup
|
||||
_ = repo.Delete(adminCtx, ch.ID)
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("deletes an existing channel", func() {
|
||||
ch := &model.PodcastChannel{URL: "https://del.example.com/feed.xml", Status: model.PodcastStatusNew}
|
||||
_ = repo.Create(adminCtx, ch)
|
||||
|
||||
err := repo.Delete(adminCtx, ch.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = repo.Get(adminCtx, ch.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("denies non-admin users", func() {
|
||||
err := repo.Delete(userCtx, "pc-1")
|
||||
Expect(err).To(MatchError(rest.ErrPermissionDenied))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Regular user read access", func() {
|
||||
It("allows regular users to read channels", func() {
|
||||
channels, err := repo.GetAll(userCtx, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(channels).ToNot(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
87
persistence/podcast_episode_repository.go
Normal file
87
persistence/podcast_episode_repository.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type podcastEpisodeRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPodcastEpisodeRepository(db dbx.Builder) model.PodcastEpisodeRepository {
|
||||
r := &podcastEpisodeRepository{}
|
||||
r.db = db
|
||||
r.registerModel(&model.PodcastEpisode{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *podcastEpisodeRepository) Get(ctx context.Context, epID string) (*model.PodcastEpisode, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"id": epID})
|
||||
res := model.PodcastEpisode{}
|
||||
if err := r.queryOne(ctx, sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (r *podcastEpisodeRepository) GetNewest(ctx context.Context, count int) (model.PodcastEpisodes, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").OrderBy("publish_date DESC").Limit(uint64(count))
|
||||
var eps model.PodcastEpisodes
|
||||
err := r.queryAll(ctx, sel, &eps)
|
||||
return eps, err
|
||||
}
|
||||
|
||||
func (r *podcastEpisodeRepository) GetByChannel(ctx context.Context, channelID string) (model.PodcastEpisodes, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID}).OrderBy("publish_date DESC")
|
||||
var eps model.PodcastEpisodes
|
||||
err := r.queryAll(ctx, sel, &eps)
|
||||
return eps, err
|
||||
}
|
||||
|
||||
func (r *podcastEpisodeRepository) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastEpisodes, error) {
|
||||
if len(channelIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelIDs}).OrderBy("channel_id, publish_date DESC")
|
||||
var eps model.PodcastEpisodes
|
||||
err := r.queryAll(ctx, sel, &eps)
|
||||
return eps, err
|
||||
}
|
||||
|
||||
func (r *podcastEpisodeRepository) GetByGUID(ctx context.Context, channelID, guid string) (*model.PodcastEpisode, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID, "guid": guid})
|
||||
res := model.PodcastEpisode{}
|
||||
if err := r.queryOne(ctx, sel, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (r *podcastEpisodeRepository) Create(ctx context.Context, ep *model.PodcastEpisode) error {
|
||||
now := time.Now()
|
||||
ep.CreatedAt = now
|
||||
ep.UpdatedAt = now
|
||||
if ep.ID == "" {
|
||||
ep.ID = id.NewRandom()
|
||||
}
|
||||
_, err := r.put(ctx, ep.ID, ep)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *podcastEpisodeRepository) Update(ctx context.Context, ep *model.PodcastEpisode) error {
|
||||
ep.UpdatedAt = time.Now()
|
||||
_, err := r.put(ctx, ep.ID, ep)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *podcastEpisodeRepository) Delete(ctx context.Context, epID string) error {
|
||||
return r.delete(ctx, Eq{"id": epID})
|
||||
}
|
||||
|
||||
var _ model.PodcastEpisodeRepository = (*podcastEpisodeRepository)(nil)
|
||||
133
persistence/podcast_episode_repository_test.go
Normal file
133
persistence/podcast_episode_repository_test.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PodcastEpisodeRepository", func() {
|
||||
var ctx context.Context
|
||||
var repo model.PodcastEpisodeRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
repo = NewPodcastEpisodeRepository(GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Get", func() {
|
||||
It("returns an existing episode", func() {
|
||||
ep, err := repo.Get(ctx, "pe-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ep.Title).To(Equal("Episode 1"))
|
||||
Expect(ep.ChannelID).To(Equal("pc-1"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound for unknown id", func() {
|
||||
_, err := repo.Get(ctx, "no-such-id")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetNewest", func() {
|
||||
It("returns episodes ordered by publish_date DESC", func() {
|
||||
eps, err := repo.GetNewest(ctx, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(eps)).To(BeNumerically(">=", 2))
|
||||
Expect(eps[0].PublishDate.After(eps[1].PublishDate)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("respects the count limit", func() {
|
||||
eps, err := repo.GetNewest(ctx, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(eps).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetByChannel", func() {
|
||||
It("returns only episodes belonging to the channel", func() {
|
||||
eps, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(eps).To(HaveLen(2))
|
||||
for _, ep := range eps {
|
||||
Expect(ep.ChannelID).To(Equal("pc-1"))
|
||||
}
|
||||
})
|
||||
|
||||
It("returns empty slice for channel with no episodes", func() {
|
||||
eps, err := repo.GetByChannel(ctx, "pc-2")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(eps).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetByGUID", func() {
|
||||
It("returns the episode matching channel+guid", func() {
|
||||
ep, err := repo.GetByGUID(ctx, "pc-1", "guid-001")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ep.Title).To(Equal("Episode 1"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound for unknown guid", func() {
|
||||
_, err := repo.GetByGUID(ctx, "pc-1", "no-such-guid")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when channel does not match", func() {
|
||||
_, err := repo.GetByGUID(ctx, "pc-2", "guid-001")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Create and Delete", func() {
|
||||
It("creates an episode and hard deletes it", func() {
|
||||
ep := &model.PodcastEpisode{
|
||||
ChannelID: "pc-1",
|
||||
GUID: "guid-temp",
|
||||
Title: "Temp Episode",
|
||||
Status: model.PodcastStatusNew,
|
||||
PublishDate: time.Now(),
|
||||
}
|
||||
err := repo.Create(ctx, ep)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(ep.ID).ToNot(BeEmpty())
|
||||
|
||||
err = repo.Delete(ctx, ep.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = repo.Get(ctx, ep.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Update", func() {
|
||||
It("updates episode fields", func() {
|
||||
ep := &model.PodcastEpisode{
|
||||
ChannelID: "pc-1",
|
||||
GUID: "guid-upd",
|
||||
Title: "Before",
|
||||
Status: model.PodcastStatusNew,
|
||||
PublishDate: time.Now(),
|
||||
}
|
||||
_ = repo.Create(ctx, ep)
|
||||
|
||||
ep.Status = model.PodcastStatusCompleted
|
||||
ep.Path = "/podcasts/pc-1/ep.mp3"
|
||||
err := repo.Update(ctx, ep)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
saved, _ := repo.Get(ctx, ep.ID)
|
||||
Expect(saved.Status).To(Equal(model.PodcastStatusCompleted))
|
||||
Expect(saved.Path).To(Equal("/podcasts/pc-1/ep.mp3"))
|
||||
|
||||
// cleanup
|
||||
_ = repo.Delete(ctx, ep.ID)
|
||||
})
|
||||
})
|
||||
})
|
||||
58
persistence/podcast_funding_repository.go
Normal file
58
persistence/podcast_funding_repository.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type podcastFundingRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPodcastFundingRepository(db dbx.Builder) model.PodcastFundingRepository {
|
||||
r := &podcastFundingRepository{}
|
||||
r.db = db
|
||||
r.tableName = "podcast_funding"
|
||||
r.registerModel(&model.PodcastFundingItem{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *podcastFundingRepository) GetByChannel(ctx context.Context, channelID string) (model.PodcastFundingItems, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID})
|
||||
var result model.PodcastFundingItems
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastFundingRepository) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastFundingItems, error) {
|
||||
if len(channelIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelIDs})
|
||||
var result model.PodcastFundingItems
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastFundingRepository) SaveForChannel(ctx context.Context, channelID string, items []model.PodcastFundingItem) error {
|
||||
if err := r.delete(ctx, Eq{"channel_id": channelID}); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range items {
|
||||
items[i].ID = id.NewRandom()
|
||||
items[i].ChannelID = channelID
|
||||
items[i].CreatedAt = now
|
||||
if _, err := r.put(ctx, items[i].ID, &items[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ model.PodcastFundingRepository = (*podcastFundingRepository)(nil)
|
||||
168
persistence/podcast_funding_repository_test.go
Normal file
168
persistence/podcast_funding_repository_test.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PodcastFundingRepository", func() {
|
||||
var ctx context.Context
|
||||
var repo model.PodcastFundingRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
repo = NewPodcastFundingRepository(GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("SaveForChannel and GetByChannel", func() {
|
||||
It("saves and retrieves funding items", func() {
|
||||
items := []model.PodcastFundingItem{
|
||||
{URL: "https://patreon.com/show", Text: "Support on Patreon"},
|
||||
{URL: "https://ko-fi.com/show", Text: "Buy me a coffee"},
|
||||
}
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", items)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("preserves sort_order in insertion order", func() {
|
||||
items := []model.PodcastFundingItem{
|
||||
{URL: "https://first.example.com", Text: "First"},
|
||||
{URL: "https://second.example.com", Text: "Second"},
|
||||
{URL: "https://third.example.com", Text: "Third"},
|
||||
}
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", items)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(3))
|
||||
Expect(result[0].Text).To(Equal("First"))
|
||||
Expect(result[1].Text).To(Equal("Second"))
|
||||
Expect(result[2].Text).To(Equal("Third"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("replaces existing items on re-save", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
|
||||
{URL: "https://old.example.com", Text: "Old"},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
|
||||
{URL: "https://new.example.com", Text: "New"},
|
||||
})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Text).To(Equal("New"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("clears items when saved with nil slice", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
|
||||
{URL: "https://example.com", Text: "Something"},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not affect items of other channels", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
|
||||
{URL: "https://a.example.com", Text: "Channel A"},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastFundingItem{
|
||||
{URL: "https://b.example.com", Text: "Channel B"},
|
||||
})).To(Succeed())
|
||||
|
||||
resultA, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
resultB, _ := repo.GetByChannel(ctx, "pc-2")
|
||||
Expect(resultA).To(HaveLen(1))
|
||||
Expect(resultB).To(HaveLen(1))
|
||||
Expect(resultA[0].Text).To(Equal("Channel A"))
|
||||
Expect(resultB[0].Text).To(Equal("Channel B"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns empty list for unknown channel", func() {
|
||||
result, err := repo.GetByChannel(ctx, "no-such-channel")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("assigns non-empty ID automatically", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
|
||||
{URL: "https://example.com", Text: "Test"},
|
||||
})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result[0].ID).ToNot(BeEmpty())
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("sets ChannelID on returned items", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
|
||||
{URL: "https://example.com", Text: "Test"},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(result[0].ChannelID).To(Equal("pc-1"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetByChannels — bulk query", func() {
|
||||
It("returns items for multiple channels", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
|
||||
{URL: "https://a.example.com", Text: "Feed A"},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastFundingItem{
|
||||
{URL: "https://b.example.com", Text: "Feed B"},
|
||||
})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannels(ctx, []string{"pc-1", "pc-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
texts := []string{result[0].Text, result[1].Text}
|
||||
Expect(texts).To(ConsistOf("Feed A", "Feed B"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returned items carry ChannelID for grouping", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastFundingItem{
|
||||
{URL: "https://a.example.com", Text: "A"},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByChannels(ctx, []string{"pc-1"})
|
||||
Expect(result[0].ChannelID).To(Equal("pc-1"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns nil for empty id slice", func() {
|
||||
result, err := repo.GetByChannels(ctx, []string{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
93
persistence/podcast_image_repository.go
Normal file
93
persistence/podcast_image_repository.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type podcastImageRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPodcastImageRepository(db dbx.Builder) model.PodcastImageRepository {
|
||||
r := &podcastImageRepository{}
|
||||
r.db = db
|
||||
r.tableName = "podcast_image"
|
||||
r.registerModel(&model.PodcastImage{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *podcastImageRepository) GetByChannel(ctx context.Context, channelID string) (model.PodcastImages, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID})
|
||||
var result model.PodcastImages
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastImageRepository) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastImages, error) {
|
||||
if len(channelIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelIDs})
|
||||
var result model.PodcastImages
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastImageRepository) GetByEpisode(ctx context.Context, episodeID string) (model.PodcastImages, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"episode_id": episodeID})
|
||||
var result model.PodcastImages
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastImageRepository) GetByEpisodes(ctx context.Context, episodeIDs []string) (model.PodcastImages, error) {
|
||||
if len(episodeIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"episode_id": episodeIDs})
|
||||
var result model.PodcastImages
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastImageRepository) SaveForChannel(ctx context.Context, channelID string, images []model.PodcastImage) error {
|
||||
if err := r.delete(ctx, Eq{"channel_id": channelID}); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range images {
|
||||
images[i].ID = id.NewRandom()
|
||||
images[i].ChannelID = channelID
|
||||
images[i].EpisodeID = ""
|
||||
images[i].CreatedAt = now
|
||||
if _, err := r.put(ctx, images[i].ID, &images[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *podcastImageRepository) SaveForEpisode(ctx context.Context, episodeID string, images []model.PodcastImage) error {
|
||||
if err := r.delete(ctx, Eq{"episode_id": episodeID}); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range images {
|
||||
images[i].ID = id.NewRandom()
|
||||
images[i].EpisodeID = episodeID
|
||||
images[i].ChannelID = ""
|
||||
images[i].CreatedAt = now
|
||||
if _, err := r.put(ctx, images[i].ID, &images[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ model.PodcastImageRepository = (*podcastImageRepository)(nil)
|
||||
240
persistence/podcast_image_repository_test.go
Normal file
240
persistence/podcast_image_repository_test.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PodcastImageRepository", func() {
|
||||
var ctx context.Context
|
||||
var repo model.PodcastImageRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
repo = NewPodcastImageRepository(GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("SaveForChannel and GetByChannel", func() {
|
||||
It("saves and retrieves channel images", func() {
|
||||
images := []model.PodcastImage{
|
||||
{URL: "https://example.com/img-3000.jpg", Width: 3000},
|
||||
{URL: "https://example.com/img-300.jpg", Width: 300},
|
||||
}
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", images)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("persists URL and Width correctly", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/img-1500.jpg", Width: 1500},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(result[0].URL).To(Equal("https://example.com/img-1500.jpg"))
|
||||
Expect(result[0].Width).To(Equal(1500))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("sets ChannelID and empty EpisodeID on returned items", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/img.jpg", Width: 600},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(result[0].ChannelID).To(Equal("pc-1"))
|
||||
Expect(result[0].EpisodeID).To(BeEmpty())
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("assigns non-empty ID automatically", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/img.jpg"},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(result[0].ID).ToNot(BeEmpty())
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("replaces existing images on re-save", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/old.jpg", Width: 100},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/new.jpg", Width: 200},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].URL).To(Equal("https://example.com/new.jpg"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("clears images when saved with nil slice", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/img.jpg"},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not return episode images for channel queries", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/channel.jpg", Width: 3000},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/episode.jpg", Width: 600},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].URL).To(Equal("https://example.com/channel.jpg"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns empty list for unknown channel", func() {
|
||||
result, err := repo.GetByChannel(ctx, "no-such-channel")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SaveForEpisode and GetByEpisode", func() {
|
||||
It("saves and retrieves episode images", func() {
|
||||
images := []model.PodcastImage{
|
||||
{URL: "https://example.com/ep-600.jpg", Width: 600},
|
||||
{URL: "https://example.com/ep-150.jpg", Width: 150},
|
||||
}
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", images)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByEpisode(ctx, "ep-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("sets EpisodeID and empty ChannelID on returned items", func() {
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/ep.jpg", Width: 600},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByEpisode(ctx, "ep-1")
|
||||
Expect(result[0].EpisodeID).To(Equal("ep-1"))
|
||||
Expect(result[0].ChannelID).To(BeEmpty())
|
||||
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("does not return channel images for episode queries", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/channel.jpg", Width: 3000},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/episode.jpg", Width: 600},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByEpisode(ctx, "ep-1")
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].URL).To(Equal("https://example.com/episode.jpg"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns empty list for unknown episode", func() {
|
||||
result, err := repo.GetByEpisode(ctx, "no-such-episode")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetByChannels — bulk query", func() {
|
||||
It("returns images for multiple channels", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
|
||||
{URL: "https://a.example.com/img.jpg", Width: 3000},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastImage{
|
||||
{URL: "https://b.example.com/img.jpg", Width: 1500},
|
||||
})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannels(ctx, []string{"pc-1", "pc-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returned items carry ChannelID for grouping", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/img.jpg", Width: 600},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByChannels(ctx, []string{"pc-1"})
|
||||
Expect(result[0].ChannelID).To(Equal("pc-1"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns nil for empty id slice", func() {
|
||||
result, err := repo.GetByChannels(ctx, []string{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetByEpisodes — bulk query", func() {
|
||||
It("returns images for multiple episodes", func() {
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", []model.PodcastImage{
|
||||
{URL: "https://ep1.example.com/img.jpg", Width: 600},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-2", []model.PodcastImage{
|
||||
{URL: "https://ep2.example.com/img.jpg", Width: 300},
|
||||
})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByEpisodes(ctx, []string{"ep-1", "ep-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-2", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returned items carry EpisodeID for grouping", func() {
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", []model.PodcastImage{
|
||||
{URL: "https://example.com/ep.jpg", Width: 600},
|
||||
})).To(Succeed())
|
||||
|
||||
result, _ := repo.GetByEpisodes(ctx, []string{"ep-1"})
|
||||
Expect(result[0].EpisodeID).To(Equal("ep-1"))
|
||||
|
||||
Expect(repo.SaveForEpisode(ctx, "ep-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns nil for empty id slice", func() {
|
||||
result, err := repo.GetByEpisodes(ctx, []string{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
58
persistence/podcast_live_item_repository.go
Normal file
58
persistence/podcast_live_item_repository.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type podcastLiveItemRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPodcastLiveItemRepository(db dbx.Builder) model.PodcastLiveItemRepository {
|
||||
r := &podcastLiveItemRepository{}
|
||||
r.db = db
|
||||
// Must set tableName before registerModel to avoid auto-derived name mismatch.
|
||||
r.tableName = "podcast_live_item"
|
||||
r.registerModel(&model.PodcastLiveItem{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *podcastLiveItemRepository) GetByChannel(ctx context.Context, channelID string) (*model.PodcastLiveItem, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID})
|
||||
item := model.PodcastLiveItem{}
|
||||
if err := r.queryOne(ctx, sel, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *podcastLiveItemRepository) Upsert(ctx context.Context, item *model.PodcastLiveItem) error {
|
||||
existing, err := r.GetByChannel(ctx, item.ChannelID)
|
||||
if err == model.ErrNotFound {
|
||||
item.ID = id.NewRandom()
|
||||
item.CreatedAt = time.Now()
|
||||
item.UpdatedAt = time.Now()
|
||||
_, err = r.put(ctx, item.ID, item)
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.ID = existing.ID
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
item.UpdatedAt = time.Now()
|
||||
_, err = r.put(ctx, item.ID, item)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *podcastLiveItemRepository) DeleteByChannel(ctx context.Context, channelID string) error {
|
||||
return r.delete(ctx, Eq{"channel_id": channelID})
|
||||
}
|
||||
|
||||
var _ model.PodcastLiveItemRepository = (*podcastLiveItemRepository)(nil)
|
||||
151
persistence/podcast_live_item_repository_test.go
Normal file
151
persistence/podcast_live_item_repository_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PodcastLiveItemRepository", func() {
|
||||
var ctx context.Context
|
||||
var repo model.PodcastLiveItemRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
repo = NewPodcastLiveItemRepository(GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Upsert and GetByChannel", func() {
|
||||
It("creates a new live item when none exists", func() {
|
||||
item := &model.PodcastLiveItem{
|
||||
ChannelID: "pc-1",
|
||||
GUID: "live-guid-001",
|
||||
Title: "Live Show",
|
||||
Status: "live",
|
||||
StartTime: time.Date(2024, 4, 27, 8, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2024, 4, 27, 9, 0, 0, 0, time.UTC),
|
||||
EnclosureURL: "https://stream.example.com/live.m3u8",
|
||||
EnclosureType: "application/x-mpegURL",
|
||||
ContentLinkURL: "https://youtube.com/live",
|
||||
ContentLinkText: "Watch Live",
|
||||
}
|
||||
Expect(repo.Upsert(ctx, item)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
Expect(result.ChannelID).To(Equal("pc-1"))
|
||||
Expect(result.GUID).To(Equal("live-guid-001"))
|
||||
Expect(result.Title).To(Equal("Live Show"))
|
||||
Expect(result.Status).To(Equal("live"))
|
||||
Expect(result.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8"))
|
||||
Expect(result.ContentLinkURL).To(Equal("https://youtube.com/live"))
|
||||
Expect(result.ContentLinkText).To(Equal("Watch Live"))
|
||||
|
||||
Expect(repo.DeleteByChannel(ctx, "pc-1")).To(Succeed())
|
||||
})
|
||||
|
||||
It("assigns ID and timestamps automatically on create", func() {
|
||||
item := &model.PodcastLiveItem{
|
||||
ChannelID: "pc-1",
|
||||
Status: "live",
|
||||
}
|
||||
Expect(repo.Upsert(ctx, item)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.ID).ToNot(BeEmpty())
|
||||
Expect(result.CreatedAt.IsZero()).To(BeFalse())
|
||||
Expect(result.UpdatedAt.IsZero()).To(BeFalse())
|
||||
|
||||
Expect(repo.DeleteByChannel(ctx, "pc-1")).To(Succeed())
|
||||
})
|
||||
|
||||
It("updates existing live item (latest wins)", func() {
|
||||
first := &model.PodcastLiveItem{
|
||||
ChannelID: "pc-1",
|
||||
GUID: "live-guid-001",
|
||||
Title: "Original Title",
|
||||
Status: "pending",
|
||||
}
|
||||
Expect(repo.Upsert(ctx, first)).To(Succeed())
|
||||
|
||||
second := &model.PodcastLiveItem{
|
||||
ChannelID: "pc-1",
|
||||
GUID: "live-guid-001",
|
||||
Title: "Updated Title",
|
||||
Status: "live",
|
||||
}
|
||||
Expect(repo.Upsert(ctx, second)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result.Title).To(Equal("Updated Title"))
|
||||
Expect(result.Status).To(Equal("live"))
|
||||
|
||||
Expect(repo.DeleteByChannel(ctx, "pc-1")).To(Succeed())
|
||||
})
|
||||
|
||||
It("preserves created_at on update", func() {
|
||||
item := &model.PodcastLiveItem{
|
||||
ChannelID: "pc-1",
|
||||
Status: "pending",
|
||||
}
|
||||
Expect(repo.Upsert(ctx, item)).To(Succeed())
|
||||
|
||||
original, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
originalCreatedAt := original.CreatedAt
|
||||
|
||||
item2 := &model.PodcastLiveItem{
|
||||
ChannelID: "pc-1",
|
||||
Status: "live",
|
||||
}
|
||||
Expect(repo.Upsert(ctx, item2)).To(Succeed())
|
||||
|
||||
updated, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(updated.CreatedAt.UTC().Truncate(time.Second)).
|
||||
To(Equal(originalCreatedAt.UTC().Truncate(time.Second)))
|
||||
|
||||
Expect(repo.DeleteByChannel(ctx, "pc-1")).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound for unknown channel", func() {
|
||||
_, err := repo.GetByChannel(ctx, "no-such-channel")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("handles zero-value start/end times", func() {
|
||||
item := &model.PodcastLiveItem{
|
||||
ChannelID: "pc-1",
|
||||
Status: "live",
|
||||
}
|
||||
Expect(repo.Upsert(ctx, item)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).ToNot(BeNil())
|
||||
|
||||
Expect(repo.DeleteByChannel(ctx, "pc-1")).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteByChannel", func() {
|
||||
It("removes live item for the given channel", func() {
|
||||
Expect(repo.Upsert(ctx, &model.PodcastLiveItem{ChannelID: "pc-1", Status: "live"})).To(Succeed())
|
||||
Expect(repo.DeleteByChannel(ctx, "pc-1")).To(Succeed())
|
||||
|
||||
_, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).To(Equal(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("does not error when no item exists", func() {
|
||||
Expect(repo.DeleteByChannel(ctx, "no-such-channel")).To(Succeed())
|
||||
})
|
||||
})
|
||||
})
|
||||
82
persistence/podcast_person_repository.go
Normal file
82
persistence/podcast_person_repository.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type podcastPersonRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPodcastPersonRepository(db dbx.Builder) model.PodcastPersonRepository {
|
||||
r := &podcastPersonRepository{}
|
||||
r.db = db
|
||||
r.registerModel(&model.PodcastPerson{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *podcastPersonRepository) GetByChannel(ctx context.Context, channelID string) (model.PodcastPersons, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID})
|
||||
var result model.PodcastPersons
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastPersonRepository) GetByEpisode(ctx context.Context, episodeID string) (model.PodcastPersons, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"episode_id": episodeID})
|
||||
var result model.PodcastPersons
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastPersonRepository) GetByEpisodes(ctx context.Context, episodeIDs []string) (model.PodcastPersons, error) {
|
||||
if len(episodeIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"episode_id": episodeIDs})
|
||||
var result model.PodcastPersons
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastPersonRepository) SaveForChannel(ctx context.Context, channelID string, persons []model.PodcastPerson) error {
|
||||
if err := r.delete(ctx, Eq{"channel_id": channelID}); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range persons {
|
||||
persons[i].ID = id.NewRandom()
|
||||
persons[i].ChannelID = channelID
|
||||
persons[i].EpisodeID = ""
|
||||
persons[i].CreatedAt = now
|
||||
if _, err := r.put(ctx, persons[i].ID, &persons[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *podcastPersonRepository) SaveForEpisode(ctx context.Context, episodeID string, persons []model.PodcastPerson) error {
|
||||
if err := r.delete(ctx, Eq{"episode_id": episodeID}); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range persons {
|
||||
persons[i].ID = id.NewRandom()
|
||||
persons[i].EpisodeID = episodeID
|
||||
persons[i].ChannelID = ""
|
||||
persons[i].CreatedAt = now
|
||||
if _, err := r.put(ctx, persons[i].ID, &persons[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ model.PodcastPersonRepository = (*podcastPersonRepository)(nil)
|
||||
173
persistence/podcast_person_repository_test.go
Normal file
173
persistence/podcast_person_repository_test.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PodcastPersonRepository", func() {
|
||||
var ctx context.Context
|
||||
var repo model.PodcastPersonRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
repo = NewPodcastPersonRepository(GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("SaveForChannel and GetByChannel", func() {
|
||||
It("saves and retrieves channel-level persons", func() {
|
||||
persons := []model.PodcastPerson{
|
||||
{Name: "Jane Host", Role: "host", Group: "cast", Img: "https://example.com/jane.jpg", Href: "https://example.com/jane"},
|
||||
{Name: "Bob Producer", Role: "producer", Group: "crew"},
|
||||
}
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", persons)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
||||
var names []string
|
||||
for _, p := range result {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
Expect(names).To(ConsistOf("Jane Host", "Bob Producer"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("persists all person fields", func() {
|
||||
persons := []model.PodcastPerson{
|
||||
{Name: "Jane Host", Role: "host", Group: "cast", Img: "https://example.com/jane.jpg", Href: "https://example.com/jane"},
|
||||
}
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", persons)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result[0].Name).To(Equal("Jane Host"))
|
||||
Expect(result[0].Role).To(Equal("host"))
|
||||
Expect(result[0].Group).To(Equal("cast"))
|
||||
Expect(result[0].Img).To(Equal("https://example.com/jane.jpg"))
|
||||
Expect(result[0].Href).To(Equal("https://example.com/jane"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("replaces existing persons on re-save", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPerson{{Name: "Old Host", Role: "host", Group: "cast"}})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPerson{{Name: "New Host", Role: "host", Group: "cast"}})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Name).To(Equal("New Host"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("clears persons when saved with nil slice", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPerson{{Name: "Jane Host", Role: "host", Group: "cast"}})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not affect persons of other channels", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPerson{{Name: "Host A", Role: "host", Group: "cast"}})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastPerson{{Name: "Host B", Role: "host", Group: "cast"}})).To(Succeed())
|
||||
|
||||
resultA, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
resultB, _ := repo.GetByChannel(ctx, "pc-2")
|
||||
Expect(resultA).To(HaveLen(1))
|
||||
Expect(resultB).To(HaveLen(1))
|
||||
Expect(resultA[0].Name).To(Equal("Host A"))
|
||||
Expect(resultB[0].Name).To(Equal("Host B"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns empty list for unknown channel", func() {
|
||||
result, err := repo.GetByChannel(ctx, "no-such-channel")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SaveForEpisode and GetByEpisode", func() {
|
||||
It("saves and retrieves episode-level persons", func() {
|
||||
persons := []model.PodcastPerson{
|
||||
{Name: "John Guest", Role: "guest", Group: "cast"},
|
||||
}
|
||||
Expect(repo.SaveForEpisode(ctx, "pe-1", persons)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByEpisode(ctx, "pe-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Name).To(Equal("John Guest"))
|
||||
Expect(result[0].Role).To(Equal("guest"))
|
||||
|
||||
Expect(repo.SaveForEpisode(ctx, "pe-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("replaces existing episode persons on re-save", func() {
|
||||
Expect(repo.SaveForEpisode(ctx, "pe-1", []model.PodcastPerson{{Name: "Old Guest", Role: "guest", Group: "cast"}})).To(Succeed())
|
||||
Expect(repo.SaveForEpisode(ctx, "pe-1", []model.PodcastPerson{{Name: "New Guest", Role: "guest", Group: "cast"}})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByEpisode(ctx, "pe-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Name).To(Equal("New Guest"))
|
||||
|
||||
Expect(repo.SaveForEpisode(ctx, "pe-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns empty list for unknown episode", func() {
|
||||
result, err := repo.GetByEpisode(ctx, "no-such-episode")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetByEpisodes — bulk query", func() {
|
||||
It("returns persons for multiple episodes in one query", func() {
|
||||
Expect(repo.SaveForEpisode(ctx, "pe-1", []model.PodcastPerson{{Name: "Guest A", Role: "guest", Group: "cast"}})).To(Succeed())
|
||||
Expect(repo.SaveForEpisode(ctx, "pe-2", []model.PodcastPerson{{Name: "Guest B", Role: "guest", Group: "cast"}})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByEpisodes(ctx, []string{"pe-1", "pe-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
||||
names := []string{result[0].Name, result[1].Name}
|
||||
Expect(names).To(ConsistOf("Guest A", "Guest B"))
|
||||
|
||||
Expect(repo.SaveForEpisode(ctx, "pe-1", nil)).To(Succeed())
|
||||
Expect(repo.SaveForEpisode(ctx, "pe-2", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns empty list for empty id slice", func() {
|
||||
result, err := repo.GetByEpisodes(ctx, []string{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("auto ID generation", func() {
|
||||
It("assigns an ID automatically on SaveForChannel", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPerson{{Name: "Jane", Role: "host", Group: "cast"}})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result[0].ID).ToNot(BeEmpty())
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
})
|
||||
})
|
||||
58
persistence/podcast_podroll_repository.go
Normal file
58
persistence/podcast_podroll_repository.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type podcastPodrollRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPodcastPodrollRepository(db dbx.Builder) model.PodcastPodrollRepository {
|
||||
r := &podcastPodrollRepository{}
|
||||
r.db = db
|
||||
// Must set tableName before registerModel to avoid auto-derived name mismatch.
|
||||
r.tableName = "podcast_podroll"
|
||||
r.registerModel(&model.PodcastPodrollItem{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *podcastPodrollRepository) GetByChannel(ctx context.Context, channelID string) (model.PodcastPodrollItems, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelID}).OrderBy("sort_order")
|
||||
var result model.PodcastPodrollItems
|
||||
return result, r.queryAll(ctx, sel, &result)
|
||||
}
|
||||
|
||||
func (r *podcastPodrollRepository) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastPodrollItems, error) {
|
||||
if len(channelIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"channel_id": channelIDs}).OrderBy("channel_id, sort_order")
|
||||
var result model.PodcastPodrollItems
|
||||
return result, r.queryAll(ctx, sel, &result)
|
||||
}
|
||||
|
||||
func (r *podcastPodrollRepository) SaveForChannel(ctx context.Context, channelID string, items []model.PodcastPodrollItem) error {
|
||||
if err := r.delete(ctx, Eq{"channel_id": channelID}); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range items {
|
||||
items[i].ID = id.NewRandom()
|
||||
items[i].ChannelID = channelID
|
||||
items[i].SortOrder = i
|
||||
items[i].CreatedAt = now
|
||||
if _, err := r.put(ctx, items[i].ID, &items[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ model.PodcastPodrollRepository = (*podcastPodrollRepository)(nil)
|
||||
146
persistence/podcast_podroll_repository_test.go
Normal file
146
persistence/podcast_podroll_repository_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PodcastPodrollRepository", func() {
|
||||
var ctx context.Context
|
||||
var repo model.PodcastPodrollRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
repo = NewPodcastPodrollRepository(GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("SaveForChannel and GetByChannel", func() {
|
||||
It("saves and retrieves podroll items", func() {
|
||||
items := []model.PodcastPodrollItem{
|
||||
{FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"},
|
||||
{FeedGUID: "guid-b", FeedURL: "https://b.example.com/feed.xml", Title: "Show B"},
|
||||
}
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", items)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("preserves sort_order in insertion order", func() {
|
||||
items := []model.PodcastPodrollItem{
|
||||
{FeedURL: "https://first.example.com/feed.xml", Title: "First"},
|
||||
{FeedURL: "https://second.example.com/feed.xml", Title: "Second"},
|
||||
{FeedURL: "https://third.example.com/feed.xml", Title: "Third"},
|
||||
}
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", items)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(3))
|
||||
Expect(result[0].Title).To(Equal("First"))
|
||||
Expect(result[1].Title).To(Equal("Second"))
|
||||
Expect(result[2].Title).To(Equal("Third"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("replaces existing items on re-save", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
|
||||
{FeedURL: "https://old.example.com/feed.xml", Title: "Old Show"},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
|
||||
{FeedURL: "https://new.example.com/feed.xml", Title: "New Show"},
|
||||
})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].Title).To(Equal("New Show"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("clears items when saved with nil slice", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
|
||||
{FeedURL: "https://example.com/feed.xml", Title: "Some Show"},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not affect items of other channels", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
|
||||
{FeedURL: "https://a.example.com/feed.xml", Title: "Channel A Feed"},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastPodrollItem{
|
||||
{FeedURL: "https://b.example.com/feed.xml", Title: "Channel B Feed"},
|
||||
})).To(Succeed())
|
||||
|
||||
resultA, _ := repo.GetByChannel(ctx, "pc-1")
|
||||
resultB, _ := repo.GetByChannel(ctx, "pc-2")
|
||||
Expect(resultA).To(HaveLen(1))
|
||||
Expect(resultB).To(HaveLen(1))
|
||||
Expect(resultA[0].Title).To(Equal("Channel A Feed"))
|
||||
Expect(resultB[0].Title).To(Equal("Channel B Feed"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns empty list for unknown channel", func() {
|
||||
result, err := repo.GetByChannel(ctx, "no-such-channel")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("assigns ID automatically", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
|
||||
{FeedURL: "https://example.com/feed.xml"},
|
||||
})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannel(ctx, "pc-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result[0].ID).ToNot(BeEmpty())
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetByChannels — bulk query", func() {
|
||||
It("returns items for multiple channels", func() {
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", []model.PodcastPodrollItem{
|
||||
{FeedURL: "https://a.example.com/feed.xml", Title: "Feed A"},
|
||||
})).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", []model.PodcastPodrollItem{
|
||||
{FeedURL: "https://b.example.com/feed.xml", Title: "Feed B"},
|
||||
})).To(Succeed())
|
||||
|
||||
result, err := repo.GetByChannels(ctx, []string{"pc-1", "pc-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
titles := []string{result[0].Title, result[1].Title}
|
||||
Expect(titles).To(ConsistOf("Feed A", "Feed B"))
|
||||
|
||||
Expect(repo.SaveForChannel(ctx, "pc-1", nil)).To(Succeed())
|
||||
Expect(repo.SaveForChannel(ctx, "pc-2", nil)).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns nil for empty id slice", func() {
|
||||
result, err := repo.GetByChannels(ctx, []string{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
58
persistence/podcast_transcript_repository.go
Normal file
58
persistence/podcast_transcript_repository.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type podcastTranscriptRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewPodcastTranscriptRepository(db dbx.Builder) model.PodcastTranscriptRepository {
|
||||
r := &podcastTranscriptRepository{}
|
||||
r.db = db
|
||||
r.registerModel(&model.PodcastTranscript{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *podcastTranscriptRepository) GetByEpisode(ctx context.Context, episodeID string) (model.PodcastTranscripts, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"episode_id": episodeID})
|
||||
var result model.PodcastTranscripts
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastTranscriptRepository) GetByEpisodes(ctx context.Context, episodeIDs []string) (model.PodcastTranscripts, error) {
|
||||
if len(episodeIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
sel := r.newSelect(ctx).Columns("*").Where(Eq{"episode_id": episodeIDs})
|
||||
var result model.PodcastTranscripts
|
||||
err := r.queryAll(ctx, sel, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *podcastTranscriptRepository) Save(ctx context.Context, transcripts []model.PodcastTranscript) error {
|
||||
for i := range transcripts {
|
||||
if transcripts[i].ID == "" {
|
||||
transcripts[i].ID = id.NewRandom()
|
||||
}
|
||||
transcripts[i].CreatedAt = time.Now()
|
||||
if _, err := r.put(ctx, transcripts[i].ID, &transcripts[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *podcastTranscriptRepository) DeleteByEpisode(ctx context.Context, episodeID string) error {
|
||||
return r.delete(ctx, Eq{"episode_id": episodeID})
|
||||
}
|
||||
|
||||
var _ model.PodcastTranscriptRepository = (*podcastTranscriptRepository)(nil)
|
||||
145
persistence/podcast_transcript_repository_test.go
Normal file
145
persistence/podcast_transcript_repository_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("PodcastTranscriptRepository", func() {
|
||||
var ctx context.Context
|
||||
var repo model.PodcastTranscriptRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = log.NewContext(context.TODO())
|
||||
ctx = request.WithUser(ctx, adminUser)
|
||||
repo = NewPodcastTranscriptRepository(GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Save and GetByEpisode", func() {
|
||||
It("returns saved transcript by episode id", func() {
|
||||
transcripts := []model.PodcastTranscript{
|
||||
{EpisodeID: "pe-1", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"},
|
||||
}
|
||||
Expect(repo.Save(ctx, transcripts)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByEpisode(ctx, "pe-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(1))
|
||||
Expect(result[0].URL).To(Equal("https://example.com/t.vtt"))
|
||||
Expect(result[0].MimeType).To(Equal("text/vtt"))
|
||||
Expect(result[0].Language).To(Equal("en"))
|
||||
Expect(result[0].Rel).To(Equal("captions"))
|
||||
|
||||
Expect(repo.DeleteByEpisode(ctx, "pe-1")).To(Succeed())
|
||||
})
|
||||
|
||||
It("saves multiple transcripts for one episode", func() {
|
||||
transcripts := []model.PodcastTranscript{
|
||||
{EpisodeID: "pe-1", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"},
|
||||
{EpisodeID: "pe-1", URL: "https://example.com/t.srt", MimeType: "application/x-subrip", Language: "en"},
|
||||
}
|
||||
Expect(repo.Save(ctx, transcripts)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByEpisode(ctx, "pe-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
||||
var mimeTypes []string
|
||||
for _, t := range result {
|
||||
mimeTypes = append(mimeTypes, t.MimeType)
|
||||
}
|
||||
Expect(mimeTypes).To(ConsistOf("text/vtt", "application/x-subrip"))
|
||||
|
||||
Expect(repo.DeleteByEpisode(ctx, "pe-1")).To(Succeed())
|
||||
})
|
||||
|
||||
It("stores empty rel when rel attribute is omitted", func() {
|
||||
transcripts := []model.PodcastTranscript{
|
||||
{EpisodeID: "pe-1", URL: "https://example.com/t.txt", MimeType: "text/plain"},
|
||||
}
|
||||
Expect(repo.Save(ctx, transcripts)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByEpisode(ctx, "pe-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result[0].Rel).To(BeEmpty())
|
||||
|
||||
Expect(repo.DeleteByEpisode(ctx, "pe-1")).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns empty list for unknown episode", func() {
|
||||
result, err := repo.GetByEpisode(ctx, "no-such-episode")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetByEpisodes — bulk query", func() {
|
||||
It("returns transcripts for multiple episodes in one query", func() {
|
||||
transcripts := []model.PodcastTranscript{
|
||||
{EpisodeID: "pe-1", URL: "https://example.com/t1.vtt", MimeType: "text/vtt"},
|
||||
{EpisodeID: "pe-2", URL: "https://example.com/t2.srt", MimeType: "application/x-subrip"},
|
||||
}
|
||||
Expect(repo.Save(ctx, transcripts)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByEpisodes(ctx, []string{"pe-1", "pe-2"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(HaveLen(2))
|
||||
|
||||
episodeIDs := []string{result[0].EpisodeID, result[1].EpisodeID}
|
||||
Expect(episodeIDs).To(ConsistOf("pe-1", "pe-2"))
|
||||
|
||||
Expect(repo.DeleteByEpisode(ctx, "pe-1")).To(Succeed())
|
||||
Expect(repo.DeleteByEpisode(ctx, "pe-2")).To(Succeed())
|
||||
})
|
||||
|
||||
It("returns empty list for empty id slice", func() {
|
||||
result, err := repo.GetByEpisodes(ctx, []string{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteByEpisode", func() {
|
||||
It("deletes only transcripts for the specified episode", func() {
|
||||
transcripts := []model.PodcastTranscript{
|
||||
{EpisodeID: "pe-1", URL: "https://example.com/t1.vtt", MimeType: "text/vtt"},
|
||||
{EpisodeID: "pe-2", URL: "https://example.com/t2.vtt", MimeType: "text/vtt"},
|
||||
}
|
||||
Expect(repo.Save(ctx, transcripts)).To(Succeed())
|
||||
|
||||
Expect(repo.DeleteByEpisode(ctx, "pe-1")).To(Succeed())
|
||||
|
||||
result1, _ := repo.GetByEpisode(ctx, "pe-1")
|
||||
Expect(result1).To(BeEmpty())
|
||||
|
||||
result2, _ := repo.GetByEpisode(ctx, "pe-2")
|
||||
Expect(result2).To(HaveLen(1))
|
||||
|
||||
Expect(repo.DeleteByEpisode(ctx, "pe-2")).To(Succeed())
|
||||
})
|
||||
|
||||
It("succeeds when deleting transcripts for a non-existent episode", func() {
|
||||
Expect(repo.DeleteByEpisode(ctx, "no-such-episode")).To(Succeed())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Save — auto ID generation", func() {
|
||||
It("assigns an ID automatically when none is provided", func() {
|
||||
transcripts := []model.PodcastTranscript{
|
||||
{EpisodeID: "pe-1", URL: "https://example.com/auto.vtt", MimeType: "text/vtt"},
|
||||
}
|
||||
Expect(repo.Save(ctx, transcripts)).To(Succeed())
|
||||
|
||||
result, err := repo.GetByEpisode(ctx, "pe-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result[0].ID).ToNot(BeEmpty())
|
||||
|
||||
Expect(repo.DeleteByEpisode(ctx, "pe-1")).To(Succeed())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -36,7 +36,11 @@
|
|||
"tags": "추가 태그",
|
||||
"mappedTags": "매핑된 태그",
|
||||
"rawTags": "원시 태그",
|
||||
"missing": "누락"
|
||||
"missing": "누락",
|
||||
"composer": "작곡가",
|
||||
"albumGain": "앨범 게인",
|
||||
"trackGain": "트랙 게인",
|
||||
"disc": "디스크 %{discNumber}"
|
||||
},
|
||||
"actions": {
|
||||
"addToQueue": "나중에 재생",
|
||||
|
|
@ -45,7 +49,9 @@
|
|||
"shuffleAll": "모든 노래 셔플",
|
||||
"download": "다운로드",
|
||||
"playNext": "다음 재생",
|
||||
"info": "정보 얻기"
|
||||
"info": "정보 얻기",
|
||||
"showInPlaylist": "재생목록에서 보기",
|
||||
"instantMix": "인스턴트 믹스"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
|
|
@ -131,7 +137,8 @@
|
|||
"actions": {
|
||||
"topSongs": "인기곡",
|
||||
"shuffle": "셔플",
|
||||
"radio": "라디오"
|
||||
"radio": "라디오",
|
||||
"podcast": "팟캐스트"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
|
|
@ -237,6 +244,43 @@
|
|||
"playNow": "지금 재생"
|
||||
}
|
||||
},
|
||||
"podcast": {
|
||||
"name": "팟캐스트 |||| 팟캐스트",
|
||||
"fields": {
|
||||
"title": "제목",
|
||||
"url": "피드 URL",
|
||||
"description": "설명",
|
||||
"status": "상태",
|
||||
"publishDate": "게시됨",
|
||||
"duration": "시간",
|
||||
"episodeCount": "에피소드"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "전체 갱신",
|
||||
"download": "다운로드",
|
||||
"addChannel": "채널 추가",
|
||||
"fetchFeed": "피드 정보 가져오기",
|
||||
"copyUrl": "피드 URL 복사"
|
||||
},
|
||||
"notifications": {
|
||||
"refreshStarted": "팟캐스트 갱신 시작됨",
|
||||
"downloadStarted": "다운로드 시작됨",
|
||||
"channelAdded": "채널 추가됨",
|
||||
"urlCopied": "피드 URL이 클립보드에 복사됨",
|
||||
"urlCopyFailed": "피드 URL을 복사할 수 없습니다",
|
||||
"alreadyExists": "이미 등록된 채널입니다.",
|
||||
"channelDeleted": "채널 삭제됨",
|
||||
"episodeDeleted": "에피소드 삭제됨"
|
||||
},
|
||||
"status": {
|
||||
"new": "새 항목",
|
||||
"downloading": "다운로드 중",
|
||||
"completed": "다운로드됨",
|
||||
"error": "오류",
|
||||
"skipped": "건너뜀",
|
||||
"deleted": "삭제됨"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "공유 |||| 공유되는 것들",
|
||||
"fields": {
|
||||
|
|
@ -301,15 +345,20 @@
|
|||
},
|
||||
"actions": {
|
||||
"scan": "라이브러리 스캔",
|
||||
"manageUsers": "자용자 접속 관리",
|
||||
"quickScan": "빠른 스캔",
|
||||
"fullScan": "전체 스캔",
|
||||
"manageUsers": "사용자 접속 관리",
|
||||
"viewDetails": "상세 보기"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "라이브러리가 성공적으로 생성됨",
|
||||
"updated": "라이브러리가 성공적으로 업데이트됨",
|
||||
"deleted": "라이브러리가 성공적으로 삭제됨",
|
||||
"scanStarted": "라이브러리 스캔 스작됨",
|
||||
"scanCompleted": "라이브러리 스캔 완료됨"
|
||||
"scanStarted": "라이브러리 스캔 시작됨",
|
||||
"scanCompleted": "라이브러리 스캔 완료됨",
|
||||
"quickScanStarted": "빠른 스캔 시작됨",
|
||||
"fullScanStarted": "전체 스캔 시작됨",
|
||||
"scanError": "스캔 시작 오류. 로그를 확인하세요"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "라이브러리 이름이 필요함",
|
||||
|
|
@ -324,6 +373,82 @@
|
|||
"scanInProgress": "스캔 진행 중...",
|
||||
"noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "플러그인 |||| 플러그인들",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "이름",
|
||||
"description": "설명",
|
||||
"version": "버전",
|
||||
"author": "작성자",
|
||||
"website": "웹사이트",
|
||||
"permissions": "권한",
|
||||
"enabled": "활성화",
|
||||
"status": "상태",
|
||||
"path": "경로",
|
||||
"lastError": "오류",
|
||||
"hasError": "오류",
|
||||
"updatedAt": "업데이트됨",
|
||||
"createdAt": "설치됨",
|
||||
"configKey": "키",
|
||||
"configValue": "값",
|
||||
"allUsers": "모든 사용자 허용",
|
||||
"selectedUsers": "선택된 사용자",
|
||||
"allLibraries": "모든 라이브러리 허용",
|
||||
"selectedLibraries": "선택된 라이브러리",
|
||||
"allowWriteAccess": "쓰기 접근 허용"
|
||||
},
|
||||
"sections": {
|
||||
"status": "상태",
|
||||
"info": "플러그인 정보",
|
||||
"configuration": "구성",
|
||||
"manifest": "매니페스트",
|
||||
"usersPermission": "사용자 권한",
|
||||
"libraryPermission": "라이브러리 권한"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "활성화",
|
||||
"disabled": "비활성화"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "활성화",
|
||||
"disable": "비활성화",
|
||||
"disabledDueToError": "활성화하기 전에 오류를 수정하세요",
|
||||
"disabledUsersRequired": "활성화하기 전에 사용자를 선택하세요",
|
||||
"disabledLibrariesRequired": "활성화하기 전에 라이브러리를 선택하세요",
|
||||
"addConfig": "구성 추가",
|
||||
"rescan": "재스캔"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "플러그인 활성화됨",
|
||||
"disabled": "플러그인 비활성화됨",
|
||||
"updated": "플러그인 업데이트됨",
|
||||
"error": "플러그인 업데이트 오류"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "구성은 유효한 JSON이어야 함"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "키-값 쌍을 사용하여 플러그인을 구성하세요. 구성이 필요 없는 경우 비워두세요.",
|
||||
"configValidationError": "구성 유효성 검사 실패:",
|
||||
"schemaRenderError": "구성 양식을 렌더링할 수 없습니다. 플러그인의 스키마가 유효하지 않을 수 있습니다.",
|
||||
"clickPermissions": "권한을 클릭하면 자세한 내용을 볼 수 있음",
|
||||
"noConfig": "설정된 구성 없음",
|
||||
"allUsersHelp": "활성화하면 플러그인이 미래에 생성될 사용자를 포함한 모든 사용자에 접근할 수 있습니다.",
|
||||
"noUsers": "선택된 사용자 없음",
|
||||
"permissionReason": "이유",
|
||||
"usersRequired": "이 플러그인은 사용자 정보에 접근해야 합니다. 플러그인이 접근할 수 있는 사용자를 선택하거나 '모든 사용자 허용'을 활성화하세요.",
|
||||
"allLibrariesHelp": "활성화하면 플러그인이 미래에 생성될 라이브러리를 포함한 모든 라이브러리에 접근할 수 있습니다.",
|
||||
"noLibraries": "선택된 라이브러리 없음",
|
||||
"librariesRequired": "이 플러그인은 라이브러리 정보에 접근해야 합니다. 플러그인이 접근할 수 있는 라이브러리를 선택하거나 '모든 라이브러리 허용'을 활성화하세요.",
|
||||
"allowWriteAccessHelp": "활성화하면 플러그인이 라이브러리 디렉터리의 파일을 수정할 수 있습니다. 기본적으로 플러그인은 읽기 전용 접근 권한을 가집니다.",
|
||||
"requiredHosts": "필수 호스트"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "키",
|
||||
"configValue": "값"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
|
|
@ -507,7 +632,14 @@
|
|||
"shareSuccess": "URL이 클립보드에 복사되었음: %{url}",
|
||||
"shareFailure": "URL %{url}을 클립보드에 복사하는 중 오류가 발생하였음",
|
||||
"downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드",
|
||||
"downloadOriginalFormat": "오리지널 형식으로 다운로드"
|
||||
"downloadOriginalFormat": "오리지널 형식으로 다운로드",
|
||||
"uploadCover": "커버 업로드",
|
||||
"removeCover": "커버 제거",
|
||||
"coverUploaded": "커버 아트 업데이트됨",
|
||||
"coverRemoved": "커버 아트 제거됨",
|
||||
"coverUploadError": "커버 아트 업로드 오류",
|
||||
"coverRemoveError": "커버 아트 제거 오류",
|
||||
"startingInstantMix": "인스턴트 믹스 로딩 중..."
|
||||
},
|
||||
"menu": {
|
||||
"library": "라이브러리",
|
||||
|
|
@ -590,6 +722,7 @@
|
|||
"currentValue": "현재 값",
|
||||
"configurationFile": "구성 파일",
|
||||
"exportToml": "구성 내보내기 (TOML)",
|
||||
"downloadToml": "구성 다운로드 (TOML)",
|
||||
"exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성",
|
||||
"exportFailed": "구성 복사 실패",
|
||||
"devFlagsHeader": "개발 플래그 (변경/삭제 가능)",
|
||||
|
|
@ -604,6 +737,7 @@
|
|||
"serverUptime": "서버 가동 시간",
|
||||
"serverDown": "오프라인",
|
||||
"scanType": "유형",
|
||||
"selectiveScan": "선택적",
|
||||
"status": "스캔 오류",
|
||||
"elapsedTime": "경과 시간"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
rx(r, "/player", api.ds.Player(), true)
|
||||
rx(r, "/transcoding", api.ds.Transcoding(), conf.Server.EnableTranscodingConfig)
|
||||
api.addRadioRoute(r)
|
||||
api.addPodcastRoute(r)
|
||||
rx(r, "/tag", api.ds.Tag(), false)
|
||||
rx(r, "/scrobble", api.ds.Scrobble(), false)
|
||||
if conf.Server.EnableSharing {
|
||||
|
|
|
|||
41
server/nativeapi/podcasts.go
Normal file
41
server/nativeapi/podcasts.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
)
|
||||
|
||||
func (api *Router) addPodcastRoute(r chi.Router) {
|
||||
repo := api.ds.PodcastChannel()
|
||||
r.Route("/podcast", func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(repo))
|
||||
r.With(adminOnlyMiddleware).Get("/preview", api.podcastPreview)
|
||||
r.Route("/{id}", func(r chi.Router) {
|
||||
r.Use(server.URLParamsMiddleware)
|
||||
r.Get("/", rest.Get(repo))
|
||||
r.Delete("/", rest.Delete(repo))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (api *Router) podcastPreview(w http.ResponseWriter, r *http.Request) {
|
||||
feedURL := r.URL.Query().Get("url")
|
||||
if feedURL == "" {
|
||||
http.Error(w, "url is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
feed, err := podcasts.ParseFeedPreview(feedURL)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
exists, _ := api.ds.PodcastChannel().ExistsByURL(r.Context(), feedURL)
|
||||
feed.AlreadyExists = exists
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(feed)
|
||||
}
|
||||
|
|
@ -25,7 +25,7 @@ var _ = Describe("Album Lists", func() {
|
|||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
mockRepo = ds.Album().(*tests.MockAlbumRepo)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
lyricssvc "github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
podcastsvc "github.com/navidrome/navidrome/core/podcasts"
|
||||
playlistsvc "github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
sonicsvc "github.com/navidrome/navidrome/core/sonic"
|
||||
|
|
@ -53,6 +54,7 @@ type Router struct {
|
|||
metrics metrics.Metrics
|
||||
lyrics lyricssvc.Lyrics
|
||||
transcodeDecision stream.TranscodeDecider
|
||||
podcasts podcastsvc.Podcasts
|
||||
sonic *sonicsvc.Sonic
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +62,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea
|
|||
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
||||
playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||
metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider,
|
||||
podcasts podcastsvc.Podcasts,
|
||||
sonic *sonicsvc.Sonic,
|
||||
) *Router {
|
||||
r := &Router{
|
||||
|
|
@ -78,6 +81,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea
|
|||
metrics: metrics,
|
||||
lyrics: lyrics,
|
||||
transcodeDecision: transcodeDecision,
|
||||
podcasts: podcasts,
|
||||
sonic: sonic,
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
|
|
@ -226,9 +230,17 @@ func (api *Router) routes() http.Handler {
|
|||
h501(r, "jukeboxControl")
|
||||
}
|
||||
|
||||
// Not Implemented (yet?)
|
||||
h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel",
|
||||
"deletePodcastEpisode", "downloadPodcastEpisode")
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(getPlayer(api.players))
|
||||
h(r, "getPodcasts", api.GetPodcasts)
|
||||
h(r, "getNewestPodcasts", api.GetNewestPodcasts)
|
||||
h(r, "createPodcastChannel", api.CreatePodcastChannel)
|
||||
h(r, "refreshPodcasts", api.RefreshPodcasts)
|
||||
h(r, "deletePodcastChannel", api.DeletePodcastChannel)
|
||||
h(r, "deletePodcastEpisode", api.DeletePodcastEpisode)
|
||||
h(r, "downloadPodcastEpisode", api.DownloadPodcastEpisode)
|
||||
h(r, "getPodcastEpisode", api.GetPodcastEpisode)
|
||||
})
|
||||
h501(r, "createUser", "updateUser", "deleteUser", "changePassword")
|
||||
|
||||
// Deprecated/Won't implement/Out of scope endpoints
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ var _ = Describe("Bookmarks", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"})
|
||||
mfRepo = ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "visible"}})
|
||||
|
|
|
|||
|
|
@ -20,10 +20,12 @@ import (
|
|||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/external"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/storage/storagetest"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
|
|
@ -430,6 +432,7 @@ func setupTestDB() {
|
|||
metrics.NewNoopInstance(),
|
||||
lyrics.NewLyrics(ds, nil),
|
||||
decider,
|
||||
podcasts.NewPodcastService(context.Background(), ds, ffmpeg.New(), events.NoopBroker()),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ func buildArtworkRouter(art artwork.Artwork) *subsonic.Router {
|
|||
ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s,
|
||||
events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)),
|
||||
scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds),
|
||||
playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil,
|
||||
playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, nil,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import (
|
|||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/matcher"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
|
|
@ -51,6 +53,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router {
|
|||
metrics.NewNoopInstance(),
|
||||
lyrics.NewLyrics(ds, nil),
|
||||
decider,
|
||||
podcasts.NewPodcastService(context.Background(), ds, ffmpeg.New(), events.NoopBroker()),
|
||||
sonicSvc,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ var _ = Describe("GetLyricsBySongId", func() {
|
|||
ds = &tests.MockDataStore{
|
||||
MockedMediaFile: mockRepo,
|
||||
}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil, nil)
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ var _ = Describe("MediaAnnotationController", func() {
|
|||
ds = &tests.MockDataStore{}
|
||||
playTracker = &fakePlayTracker{}
|
||||
eventBroker = &fakeEventBroker{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
|||
MockedRadio: radioRepo,
|
||||
}
|
||||
artwork = &fakeArtwork{data: "image data"}
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil)
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
|
|||
{Name: "playbackReport", Versions: []int32{1}},
|
||||
{Name: "topSongsByArtistId", Versions: []int32{1}},
|
||||
{Name: "apiKeyAuthentication", Versions: []int32{1}},
|
||||
{Name: "getPodcastEpisode", Versions: []int32{1}},
|
||||
{Name: "podcastChapters", Versions: []int32{1}},
|
||||
{Name: "podcastTranscripts", Versions: []int32{1}},
|
||||
{Name: "podcastSeason", Versions: []int32{1}},
|
||||
{Name: "podcastPerson", Versions: []int32{1}},
|
||||
{Name: "podcastFunding", Versions: []int32{1}},
|
||||
{Name: "podcastMedium", Versions: []int32{1}},
|
||||
{Name: "podcastPodroll", Versions: []int32{1}},
|
||||
{Name: "podcastLiveItem", Versions: []int32{1}},
|
||||
{Name: "podcastPodping", Versions: []int32{1}},
|
||||
}
|
||||
if api.sonic != nil && api.sonic.HasProvider() {
|
||||
extensions = append(extensions, responses.OpenSubsonicExtension{
|
||||
|
|
|
|||
|
|
@ -41,10 +41,10 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||
|
||||
Context("without sonic similarity plugin", func() {
|
||||
BeforeEach(func() {
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
It("should return the base 8 OpenSubsonicExtensions without sonicSimilarity", func() {
|
||||
It("should return the base 18 OpenSubsonicExtensions without sonicSimilarity", func() {
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
// Make sure the endpoint is public, by not passing any authentication
|
||||
|
|
@ -55,7 +55,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(8),
|
||||
HaveLen(18),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
|
||||
|
|
@ -64,6 +64,16 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||
ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastChapters", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastTranscripts", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastSeason", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPerson", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastFunding", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastMedium", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodroll", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastLiveItem", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodping", Versions: []int32{1}}),
|
||||
))
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo(
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
|
||||
|
|
@ -74,10 +84,10 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||
Context("with sonic similarity plugin", func() {
|
||||
BeforeEach(func() {
|
||||
sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil)
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService)
|
||||
})
|
||||
|
||||
It("should return 9 extensions including sonicSimilarity", func() {
|
||||
It("should return 19 extensions including all podcast 2.0 and sonicSimilarity", func() {
|
||||
router.ServeHTTP(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
|
@ -87,7 +97,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(9),
|
||||
HaveLen(19),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}),
|
||||
|
|
@ -97,6 +107,16 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
|||
ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "getPodcastEpisode", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastChapters", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastTranscripts", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastSeason", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPerson", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastFunding", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastMedium", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodroll", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastLiveItem", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "podcastPodping", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ var _ = Describe("buildPlaylist", func() {
|
|||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
|
|
@ -331,7 +331,7 @@ var _ = Describe("UpdatePlaylist", func() {
|
|||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
playlists = &fakePlaylists{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
It("clears the comment when parameter is empty", func() {
|
||||
|
|
|
|||
388
server/subsonic/podcasts.go
Normal file
388
server/subsonic/podcasts.go
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
func (api *Router) GetPodcasts(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id := p.StringOr("id", "")
|
||||
includeEpisodes := p.BoolOr("includeEpisodes", true)
|
||||
|
||||
ctx := r.Context()
|
||||
chRepo := api.ds.PodcastChannel()
|
||||
|
||||
var channels model.PodcastChannels
|
||||
var err error
|
||||
|
||||
if id != "" {
|
||||
ch, e := chRepo.Get(ctx, id)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
channels = model.PodcastChannels{*ch}
|
||||
} else {
|
||||
channels, err = chRepo.GetAll(ctx, includeEpisodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if includeEpisodes && id != "" {
|
||||
eps, e := api.ds.PodcastEpisode().GetByChannel(ctx, id)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
channels[0].Episodes = eps
|
||||
}
|
||||
|
||||
// Collect channel IDs for bulk queries.
|
||||
channelIDs := make([]string, len(channels))
|
||||
for i, ch := range channels {
|
||||
channelIDs[i] = ch.ID
|
||||
}
|
||||
|
||||
// Load channel persons
|
||||
personRepo := api.ds.PodcastPerson()
|
||||
channelPersons := make(map[string]model.PodcastPersons)
|
||||
for _, ch := range channels {
|
||||
persons, err := personRepo.GetByChannel(ctx, ch.ID)
|
||||
if err == nil {
|
||||
channelPersons[ch.ID] = persons
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk-load podcast:podroll items
|
||||
podrollRepo := api.ds.PodcastPodroll()
|
||||
allPodrolls, _ := podrollRepo.GetByChannels(ctx, channelIDs)
|
||||
podrollMap := make(map[string]model.PodcastPodrollItems)
|
||||
for _, pr := range allPodrolls {
|
||||
podrollMap[pr.ChannelID] = append(podrollMap[pr.ChannelID], pr)
|
||||
}
|
||||
|
||||
// Load podcast:liveItem per channel
|
||||
liveItemRepo := api.ds.PodcastLiveItem()
|
||||
liveItemMap := make(map[string]*model.PodcastLiveItem)
|
||||
for _, chID := range channelIDs {
|
||||
if li, err := liveItemRepo.GetByChannel(ctx, chID); err == nil {
|
||||
liveItemMap[chID] = li
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk-load podcast:funding items
|
||||
fundingRepo := api.ds.PodcastFunding()
|
||||
allFunding, _ := fundingRepo.GetByChannels(ctx, channelIDs)
|
||||
fundingMap := make(map[string]model.PodcastFundingItems)
|
||||
for _, f := range allFunding {
|
||||
fundingMap[f.ChannelID] = append(fundingMap[f.ChannelID], f)
|
||||
}
|
||||
|
||||
// Bulk-load podcast:image (channel level)
|
||||
imageRepo := api.ds.PodcastImage()
|
||||
allChannelImages, _ := imageRepo.GetByChannels(ctx, channelIDs)
|
||||
channelImageMap := make(map[string]model.PodcastImages)
|
||||
for _, img := range allChannelImages {
|
||||
channelImageMap[img.ChannelID] = append(channelImageMap[img.ChannelID], img)
|
||||
}
|
||||
|
||||
// Bulk-load episode transcripts, persons, and images when including episodes
|
||||
var epTranscripts map[string]model.PodcastTranscripts
|
||||
var epPersons map[string]model.PodcastPersons
|
||||
var epImages map[string]model.PodcastImages
|
||||
if includeEpisodes {
|
||||
var epIDs []string
|
||||
for _, ch := range channels {
|
||||
for _, ep := range ch.Episodes {
|
||||
epIDs = append(epIDs, ep.ID)
|
||||
}
|
||||
}
|
||||
if len(epIDs) > 0 {
|
||||
transcriptRepo := api.ds.PodcastTranscript()
|
||||
allTranscripts, err := transcriptRepo.GetByEpisodes(ctx, epIDs)
|
||||
if err == nil {
|
||||
epTranscripts = make(map[string]model.PodcastTranscripts)
|
||||
for _, t := range allTranscripts {
|
||||
epTranscripts[t.EpisodeID] = append(epTranscripts[t.EpisodeID], t)
|
||||
}
|
||||
}
|
||||
allPersons, err := personRepo.GetByEpisodes(ctx, epIDs)
|
||||
if err == nil {
|
||||
epPersons = make(map[string]model.PodcastPersons)
|
||||
for _, p := range allPersons {
|
||||
epPersons[p.EpisodeID] = append(epPersons[p.EpisodeID], p)
|
||||
}
|
||||
}
|
||||
allEpImages, err := imageRepo.GetByEpisodes(ctx, epIDs)
|
||||
if err == nil {
|
||||
epImages = make(map[string]model.PodcastImages)
|
||||
for _, img := range allEpImages {
|
||||
epImages[img.EpisodeID] = append(epImages[img.EpisodeID], img)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := newResponse()
|
||||
resp.Podcasts = &responses.Podcasts{}
|
||||
for _, ch := range channels {
|
||||
rch := responses.PodcastChannel{
|
||||
ID: ch.ID,
|
||||
URL: ch.URL,
|
||||
Title: ch.Title,
|
||||
Description: ch.Description,
|
||||
OriginalImageUrl: ch.ImageURL,
|
||||
Status: string(ch.Status),
|
||||
ErrorMessage: ch.ErrorMessage,
|
||||
// Podcasting 2.0 Tier 1 & 2
|
||||
PodcastGuid: ch.PodcastGUID,
|
||||
Locked: ch.Locked,
|
||||
Medium: ch.Medium,
|
||||
UpdateFrequency: ch.UpdateFrequency,
|
||||
Complete: ch.Complete,
|
||||
LocationName: ch.LocationName,
|
||||
LocationGeo: ch.LocationGeo,
|
||||
LocationOSM: ch.LocationOSM,
|
||||
License: ch.License,
|
||||
PublisherName: ch.PublisherName,
|
||||
PublisherURL: ch.PublisherURL,
|
||||
// Podcasting 2.0 Tier 3
|
||||
UsesPodping: ch.UsesPodping,
|
||||
}
|
||||
for _, p := range channelPersons[ch.ID] {
|
||||
rch.Person = append(rch.Person, responses.PodcastPersonResp{
|
||||
Name: p.Name,
|
||||
Role: p.Role,
|
||||
Group: p.Group,
|
||||
Img: p.Img,
|
||||
Href: p.Href,
|
||||
})
|
||||
}
|
||||
for _, f := range fundingMap[ch.ID] {
|
||||
rch.Funding = append(rch.Funding, responses.PodcastFundingResp{
|
||||
URL: f.URL,
|
||||
Text: f.Text,
|
||||
})
|
||||
}
|
||||
for _, img := range channelImageMap[ch.ID] {
|
||||
rch.Images = append(rch.Images, responses.PodcastImageResp{
|
||||
URL: img.URL,
|
||||
Width: img.Width,
|
||||
})
|
||||
}
|
||||
for _, pr := range podrollMap[ch.ID] {
|
||||
rch.Podroll = append(rch.Podroll, responses.PodcastPodrollResp{
|
||||
FeedGUID: pr.FeedGUID,
|
||||
FeedURL: pr.FeedURL,
|
||||
Title: pr.Title,
|
||||
})
|
||||
}
|
||||
if li := liveItemMap[ch.ID]; li != nil {
|
||||
liveResp := &responses.PodcastLiveItemResp{
|
||||
Status: li.Status,
|
||||
Title: li.Title,
|
||||
GUID: li.GUID,
|
||||
EnclosureURL: li.EnclosureURL,
|
||||
EnclosureType: li.EnclosureType,
|
||||
ContentLinkURL: li.ContentLinkURL,
|
||||
ContentLinkText: li.ContentLinkText,
|
||||
}
|
||||
if !li.StartTime.IsZero() {
|
||||
liveResp.StartTime = li.StartTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !li.EndTime.IsZero() {
|
||||
liveResp.EndTime = li.EndTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
rch.LiveItem = liveResp
|
||||
}
|
||||
if includeEpisodes {
|
||||
for _, ep := range ch.Episodes {
|
||||
ep.Transcripts = epTranscripts[ep.ID]
|
||||
ep.Persons = epPersons[ep.ID]
|
||||
ep.Images = epImages[ep.ID]
|
||||
rch.Episode = append(rch.Episode, buildPodcastEpisode(ep))
|
||||
}
|
||||
}
|
||||
resp.Podcasts.Channel = append(resp.Podcasts.Channel, rch)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (api *Router) GetNewestPodcasts(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
count := p.IntOr("count", 20)
|
||||
|
||||
eps, err := api.ds.PodcastEpisode().GetNewest(r.Context(), count)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := newResponse()
|
||||
resp.NewestPodcasts = &responses.NewestPodcasts{}
|
||||
for _, ep := range eps {
|
||||
resp.NewestPodcasts.Episode = append(resp.NewestPodcasts.Episode, buildPodcastEpisode(ep))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (api *Router) CreatePodcastChannel(r *http.Request) (*responses.Subsonic, error) {
|
||||
if err := requireAdmin(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := req.Params(r)
|
||||
feedURL, err := p.String("url")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := api.podcasts.AddChannel(r.Context(), feedURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) RefreshPodcasts(r *http.Request) (*responses.Subsonic, error) {
|
||||
if err := requireAdmin(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := api.podcasts.RefreshChannels(r.Context()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) DeletePodcastChannel(r *http.Request) (*responses.Subsonic, error) {
|
||||
if err := requireAdmin(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := api.podcasts.DeleteChannel(r.Context(), id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) DeletePodcastEpisode(r *http.Request) (*responses.Subsonic, error) {
|
||||
if err := requireAdmin(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := api.podcasts.DeleteEpisode(r.Context(), id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) DownloadPodcastEpisode(r *http.Request) (*responses.Subsonic, error) {
|
||||
if err := requireAdmin(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := api.podcasts.DownloadEpisode(r.Context(), id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) GetPodcastEpisode(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
id, err := p.String("id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx := r.Context()
|
||||
ep, err := api.ds.PodcastEpisode().Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ep.Transcripts, _ = api.ds.PodcastTranscript().GetByEpisode(ctx, ep.ID)
|
||||
ep.Persons, _ = api.ds.PodcastPerson().GetByEpisode(ctx, ep.ID)
|
||||
ep.Images, _ = api.ds.PodcastImage().GetByEpisode(ctx, ep.ID)
|
||||
|
||||
resp := newResponse()
|
||||
re := buildPodcastEpisode(*ep)
|
||||
resp.PodcastEpisode = &re
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func buildPodcastEpisode(ep model.PodcastEpisode) responses.PodcastEpisode {
|
||||
re := responses.PodcastEpisode{
|
||||
ID: ep.ID,
|
||||
StreamId: ep.StreamID,
|
||||
ChannelId: ep.ChannelID,
|
||||
Title: ep.Title,
|
||||
Description: ep.Description,
|
||||
Status: string(ep.Status),
|
||||
ErrorMessage: ep.ErrorMessage,
|
||||
Duration: ep.Duration,
|
||||
Size: ep.Size,
|
||||
Suffix: ep.Suffix,
|
||||
ContentType: ep.ContentType,
|
||||
BitRate: ep.BitRate,
|
||||
DownloadedBytes: ep.DownloadedBytes,
|
||||
// Podcasting 2.0
|
||||
Season: ep.Season,
|
||||
SeasonName: ep.SeasonName,
|
||||
EpisodeNumber: ep.EpisodeNumber,
|
||||
EpisodeDisplay: ep.EpisodeDisplay,
|
||||
ChaptersUrl: ep.ChaptersURL,
|
||||
SoundbiteStart: ep.SoundbiteStart,
|
||||
SoundbiteDur: ep.SoundbiteDur,
|
||||
LocationName: ep.LocationName,
|
||||
LocationGeo: ep.LocationGeo,
|
||||
LocationOSM: ep.LocationOSM,
|
||||
License: ep.License,
|
||||
}
|
||||
if !ep.PublishDate.IsZero() {
|
||||
re.PublishDate = ep.PublishDate.UTC().Format(time.RFC3339)
|
||||
}
|
||||
for _, t := range ep.Transcripts {
|
||||
re.Transcript = append(re.Transcript, responses.PodcastTranscriptResp{
|
||||
URL: t.URL,
|
||||
Type: t.MimeType,
|
||||
Language: t.Language,
|
||||
Rel: t.Rel,
|
||||
})
|
||||
}
|
||||
for _, p := range ep.Persons {
|
||||
re.Person = append(re.Person, responses.PodcastPersonResp{
|
||||
Name: p.Name,
|
||||
Role: p.Role,
|
||||
Group: p.Group,
|
||||
Img: p.Img,
|
||||
Href: p.Href,
|
||||
})
|
||||
}
|
||||
for _, img := range ep.Images {
|
||||
re.Images = append(re.Images, responses.PodcastImageResp{
|
||||
URL: img.URL,
|
||||
Width: img.Width,
|
||||
})
|
||||
}
|
||||
return re
|
||||
}
|
||||
|
||||
func requireAdmin(r *http.Request) error {
|
||||
user, ok := request.UserFrom(r.Context())
|
||||
if !ok || !user.IsAdmin {
|
||||
return newError(responses.ErrorAuthorizationFail)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
594
server/subsonic/podcasts_test.go
Normal file
594
server/subsonic/podcasts_test.go
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/podcasts"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
// mockPodcastService is a local mock for the Podcasts service interface.
|
||||
type mockPodcastService struct {
|
||||
podcasts.Podcasts
|
||||
addChannelURL string
|
||||
refreshCalled bool
|
||||
deleteChannelID string
|
||||
deleteEpisodeID string
|
||||
downloadEpisodeID string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockPodcastService) AddChannel(_ context.Context, rssURL string) error {
|
||||
m.addChannelURL = rssURL
|
||||
return m.err
|
||||
}
|
||||
func (m *mockPodcastService) RefreshChannels(_ context.Context) error {
|
||||
m.refreshCalled = true
|
||||
return m.err
|
||||
}
|
||||
func (m *mockPodcastService) DeleteChannel(_ context.Context, id string) error {
|
||||
m.deleteChannelID = id
|
||||
return m.err
|
||||
}
|
||||
func (m *mockPodcastService) DeleteEpisode(_ context.Context, id string) error {
|
||||
m.deleteEpisodeID = id
|
||||
return m.err
|
||||
}
|
||||
func (m *mockPodcastService) DownloadEpisode(_ context.Context, id string) error {
|
||||
m.downloadEpisodeID = id
|
||||
return m.err
|
||||
}
|
||||
|
||||
var _ = Describe("Podcasts", func() {
|
||||
var api *Router
|
||||
var ds *tests.MockDataStore
|
||||
var channelRepo *tests.MockPodcastChannelRepo
|
||||
var episodeRepo *tests.MockPodcastEpisodeRepo
|
||||
var svc *mockPodcastService
|
||||
var adminCtx, userCtx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
channelRepo = tests.CreateMockPodcastChannelRepo()
|
||||
episodeRepo = tests.CreateMockPodcastEpisodeRepo()
|
||||
svc = &mockPodcastService{}
|
||||
ds.MockedPodcastChannel = channelRepo
|
||||
ds.MockedPodcastEpisode = episodeRepo
|
||||
|
||||
api = &Router{ds: ds, podcasts: svc}
|
||||
adminCtx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true})
|
||||
userCtx = request.WithUser(context.Background(), model.User{ID: "user", IsAdmin: false})
|
||||
})
|
||||
|
||||
Describe("GetPodcasts", func() {
|
||||
BeforeEach(func() {
|
||||
channelRepo.Data = map[string]*model.PodcastChannel{
|
||||
"ch-1": {ID: "ch-1", Title: "Podcast 1", Status: model.PodcastStatusCompleted},
|
||||
"ch-2": {ID: "ch-2", Title: "Podcast 2", Status: model.PodcastStatusNew},
|
||||
}
|
||||
})
|
||||
|
||||
It("returns all channels", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(adminCtx)
|
||||
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Podcasts.Channel).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("returns channel fields correctly", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(adminCtx)
|
||||
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ids := []string{resp.Podcasts.Channel[0].ID, resp.Podcasts.Channel[1].ID}
|
||||
Expect(ids).To(ContainElements("ch-1", "ch-2"))
|
||||
})
|
||||
|
||||
It("filters by id when provided", func() {
|
||||
r := newGetRequest("id=ch-1")
|
||||
r = r.WithContext(adminCtx)
|
||||
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Podcasts.Channel).To(HaveLen(1))
|
||||
Expect(resp.Podcasts.Channel[0].ID).To(Equal("ch-1"))
|
||||
})
|
||||
|
||||
It("is accessible by regular users", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
_, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetNewestPodcasts", func() {
|
||||
BeforeEach(func() {
|
||||
now := time.Now()
|
||||
episodeRepo.Data = map[string]*model.PodcastEpisode{
|
||||
"ep-1": {ID: "ep-1", Title: "Ep1", ChannelID: "ch-1", StreamID: "stream-ep-1", PublishDate: now.Add(-time.Hour), Status: model.PodcastStatusCompleted},
|
||||
"ep-2": {ID: "ep-2", Title: "Ep2", ChannelID: "ch-1", StreamID: "stream-ep-2", PublishDate: now, Status: model.PodcastStatusNew},
|
||||
}
|
||||
})
|
||||
|
||||
It("returns episodes in podcastEpisode format", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getNewestPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
resp, err := api.GetNewestPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.NewestPodcasts.Episode).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("includes streamId so clients can play the episode", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getNewestPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
resp, err := api.GetNewestPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, ep := range resp.NewestPodcasts.Episode {
|
||||
Expect(ep.StreamId).To(Equal("stream-" + ep.ID))
|
||||
}
|
||||
})
|
||||
|
||||
It("respects count parameter", func() {
|
||||
r := newGetRequest("count=1")
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
resp, err := api.GetNewestPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.NewestPodcasts.Episode).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("defaults count to 20", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getNewestPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
_, err := api.GetNewestPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CreatePodcastChannel", func() {
|
||||
It("calls service with URL", func() {
|
||||
r := newGetRequest("url=https://example.com/feed.xml")
|
||||
r = r.WithContext(adminCtx)
|
||||
|
||||
_, err := api.CreatePodcastChannel(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc.addChannelURL).To(Equal("https://example.com/feed.xml"))
|
||||
})
|
||||
|
||||
It("denies non-admin users", func() {
|
||||
r := newGetRequest("url=https://example.com/feed.xml")
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
_, err := api.CreatePodcastChannel(r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when url param is missing", func() {
|
||||
r := newGetRequest()
|
||||
r = r.WithContext(adminCtx)
|
||||
|
||||
_, err := api.CreatePodcastChannel(r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RefreshPodcasts", func() {
|
||||
It("calls service", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/refreshPodcasts", nil)
|
||||
r = r.WithContext(adminCtx)
|
||||
|
||||
_, err := api.RefreshPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc.refreshCalled).To(BeTrue())
|
||||
})
|
||||
|
||||
It("denies non-admin users", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/refreshPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
_, err := api.RefreshPodcasts(r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeletePodcastChannel", func() {
|
||||
It("calls service with id", func() {
|
||||
r := newGetRequest("id=ch-1")
|
||||
r = r.WithContext(adminCtx)
|
||||
|
||||
_, err := api.DeletePodcastChannel(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc.deleteChannelID).To(Equal("ch-1"))
|
||||
})
|
||||
|
||||
It("denies non-admin users", func() {
|
||||
r := newGetRequest("id=ch-1")
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
_, err := api.DeletePodcastChannel(r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeletePodcastEpisode", func() {
|
||||
It("calls service with id", func() {
|
||||
r := newGetRequest("id=ep-1")
|
||||
r = r.WithContext(adminCtx)
|
||||
|
||||
_, err := api.DeletePodcastEpisode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc.deleteEpisodeID).To(Equal("ep-1"))
|
||||
})
|
||||
|
||||
It("denies non-admin users", func() {
|
||||
r := newGetRequest("id=ep-1")
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
_, err := api.DeletePodcastEpisode(r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DownloadPodcastEpisode", func() {
|
||||
It("calls service with id", func() {
|
||||
r := newGetRequest("id=ep-1")
|
||||
r = r.WithContext(adminCtx)
|
||||
|
||||
_, err := api.DownloadPodcastEpisode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc.downloadEpisodeID).To(Equal("ep-1"))
|
||||
})
|
||||
|
||||
It("denies non-admin users", func() {
|
||||
r := newGetRequest("id=ep-1")
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
_, err := api.DownloadPodcastEpisode(r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetPodcastEpisode", func() {
|
||||
BeforeEach(func() {
|
||||
episodeRepo.Data["ep-1"] = &model.PodcastEpisode{
|
||||
ID: "ep-1",
|
||||
Title: "Test Episode",
|
||||
Status: model.PodcastStatusCompleted,
|
||||
}
|
||||
})
|
||||
|
||||
It("returns single episode", func() {
|
||||
r := newGetRequest("id=ep-1")
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
resp, err := api.GetPodcastEpisode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.PodcastEpisode).ToNot(BeNil())
|
||||
Expect(resp.PodcastEpisode.ID).To(Equal("ep-1"))
|
||||
})
|
||||
|
||||
It("returns error for unknown id", func() {
|
||||
r := newGetRequest("id=no-such-id")
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
_, err := api.GetPodcastEpisode(r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("is accessible by regular users", func() {
|
||||
r := newGetRequest("id=ep-1")
|
||||
r = r.WithContext(userCtx)
|
||||
|
||||
_, err := api.GetPodcastEpisode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetPodcasts — Podcasting 2.0 channel fields", func() {
|
||||
BeforeEach(func() {
|
||||
channelRepo.Data = map[string]*model.PodcastChannel{
|
||||
"ch-p20": {
|
||||
ID: "ch-p20",
|
||||
Title: "P2.0 Podcast",
|
||||
Status: model.PodcastStatusCompleted,
|
||||
PodcastGUID: "917393e3-1b1e-5cef-ace4-edaa54e1f810",
|
||||
Locked: true,
|
||||
Medium: "podcast",
|
||||
FundingURL: "https://example.com/donate",
|
||||
FundingText: "Support us!",
|
||||
UpdateFrequency: "Weekly",
|
||||
UpdateRRule: "FREQ=WEEKLY",
|
||||
Complete: false,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("includes podcastGuid in response", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Podcasts.Channel[0].PodcastGuid).To(Equal("917393e3-1b1e-5cef-ace4-edaa54e1f810"))
|
||||
})
|
||||
|
||||
It("includes locked flag in response", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Podcasts.Channel[0].Locked).To(BeTrue())
|
||||
})
|
||||
|
||||
It("includes medium in response", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Podcasts.Channel[0].Medium).To(Equal("podcast"))
|
||||
})
|
||||
|
||||
It("includes funding items in response", func() {
|
||||
fundingRepo := tests.CreateMockPodcastFundingRepo()
|
||||
_ = fundingRepo.SaveForChannel(context.Background(), "ch-p20", []model.PodcastFundingItem{
|
||||
{URL: "https://example.com/donate", Text: "Support us!"},
|
||||
})
|
||||
ds.MockedPodcastFunding = fundingRepo
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ch := resp.Podcasts.Channel[0]
|
||||
Expect(ch.Funding).To(HaveLen(1))
|
||||
Expect(ch.Funding[0].URL).To(Equal("https://example.com/donate"))
|
||||
Expect(ch.Funding[0].Text).To(Equal("Support us!"))
|
||||
})
|
||||
|
||||
It("includes updateFrequency in response", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Podcasts.Channel[0].UpdateFrequency).To(Equal("Weekly"))
|
||||
})
|
||||
|
||||
It("includes channel person list from PersonRepo", func() {
|
||||
personRepo := tests.CreateMockPodcastPersonRepo()
|
||||
_ = personRepo.SaveForChannel(context.Background(), "ch-p20", []model.PodcastPerson{
|
||||
{Name: "Jane Host", Role: "host", Group: "cast"},
|
||||
})
|
||||
ds.MockedPodcastPerson = personRepo
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Podcasts.Channel[0].Person).To(HaveLen(1))
|
||||
Expect(resp.Podcasts.Channel[0].Person[0].Name).To(Equal("Jane Host"))
|
||||
Expect(resp.Podcasts.Channel[0].Person[0].Role).To(Equal("host"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetPodcastEpisode — Podcasting 2.0 episode fields", func() {
|
||||
BeforeEach(func() {
|
||||
episodeRepo.Data["ep-p20"] = &model.PodcastEpisode{
|
||||
ID: "ep-p20",
|
||||
Title: "P2.0 Episode",
|
||||
Status: model.PodcastStatusCompleted,
|
||||
Season: 2,
|
||||
SeasonName: "Season Two",
|
||||
EpisodeNumber: "5",
|
||||
EpisodeDisplay: "Ep.5",
|
||||
ChaptersURL: "https://example.com/chapters.json",
|
||||
ChaptersType: "application/json+chapters",
|
||||
SoundbiteStart: 73.5,
|
||||
SoundbiteDur: 60.0,
|
||||
SoundbiteTitle: "Best moment",
|
||||
}
|
||||
})
|
||||
|
||||
It("includes season number and name in response", func() {
|
||||
r := newGetRequest("id=ep-p20")
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcastEpisode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.PodcastEpisode.Season).To(Equal(2))
|
||||
Expect(resp.PodcastEpisode.SeasonName).To(Equal("Season Two"))
|
||||
})
|
||||
|
||||
It("includes episode number and display label in response", func() {
|
||||
r := newGetRequest("id=ep-p20")
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcastEpisode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.PodcastEpisode.EpisodeNumber).To(Equal("5"))
|
||||
Expect(resp.PodcastEpisode.EpisodeDisplay).To(Equal("Ep.5"))
|
||||
})
|
||||
|
||||
It("includes chaptersUrl in response", func() {
|
||||
r := newGetRequest("id=ep-p20")
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcastEpisode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.PodcastEpisode.ChaptersUrl).To(Equal("https://example.com/chapters.json"))
|
||||
})
|
||||
|
||||
It("includes soundbite fields in response", func() {
|
||||
r := newGetRequest("id=ep-p20")
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcastEpisode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.PodcastEpisode.SoundbiteStart).To(BeNumerically("~", 73.5, 0.001))
|
||||
Expect(resp.PodcastEpisode.SoundbiteDur).To(BeNumerically("~", 60.0, 0.001))
|
||||
})
|
||||
|
||||
It("includes transcript array from TranscriptRepo in response", func() {
|
||||
transcriptRepo := tests.CreateMockPodcastTranscriptRepo()
|
||||
_ = transcriptRepo.Save(context.Background(), []model.PodcastTranscript{
|
||||
{EpisodeID: "ep-p20", URL: "https://example.com/t.vtt", MimeType: "text/vtt", Language: "en", Rel: "captions"},
|
||||
{EpisodeID: "ep-p20", URL: "https://example.com/t.srt", MimeType: "application/x-subrip", Language: "en"},
|
||||
})
|
||||
ds.MockedPodcastTranscript = transcriptRepo
|
||||
|
||||
r := newGetRequest("id=ep-p20")
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcastEpisode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.PodcastEpisode.Transcript).To(HaveLen(2))
|
||||
// map iteration order is non-deterministic; use ConsistOf for order-independent check
|
||||
types := []string{
|
||||
resp.PodcastEpisode.Transcript[0].Type,
|
||||
resp.PodcastEpisode.Transcript[1].Type,
|
||||
}
|
||||
Expect(types).To(ConsistOf("text/vtt", "application/x-subrip"))
|
||||
var vttRel string
|
||||
for _, t := range resp.PodcastEpisode.Transcript {
|
||||
if t.Type == "text/vtt" {
|
||||
vttRel = t.Rel
|
||||
}
|
||||
}
|
||||
Expect(vttRel).To(Equal("captions"))
|
||||
})
|
||||
|
||||
It("includes person array from PersonRepo in response", func() {
|
||||
personRepo := tests.CreateMockPodcastPersonRepo()
|
||||
_ = personRepo.SaveForEpisode(context.Background(), "ep-p20", []model.PodcastPerson{
|
||||
{Name: "Jane Host", Role: "host", Group: "cast"},
|
||||
})
|
||||
ds.MockedPodcastPerson = personRepo
|
||||
|
||||
r := newGetRequest("id=ep-p20")
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcastEpisode(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.PodcastEpisode.Person).To(HaveLen(1))
|
||||
Expect(resp.PodcastEpisode.Person[0].Name).To(Equal("Jane Host"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetPodcasts — Tier 3 fields", func() {
|
||||
BeforeEach(func() {
|
||||
channelRepo.Data = map[string]*model.PodcastChannel{
|
||||
"ch-t3": {
|
||||
ID: "ch-t3",
|
||||
Title: "Tier3 Podcast",
|
||||
Status: model.PodcastStatusCompleted,
|
||||
UsesPodping: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("includes usesPodping in channel response", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Podcasts.Channel[0].UsesPodping).To(BeTrue())
|
||||
})
|
||||
|
||||
It("includes podroll items in channel response", func() {
|
||||
podrollRepo := tests.CreateMockPodcastPodrollRepo()
|
||||
_ = podrollRepo.SaveForChannel(context.Background(), "ch-t3", []model.PodcastPodrollItem{
|
||||
{FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"},
|
||||
{FeedGUID: "guid-b", FeedURL: "https://b.example.com/feed.xml"},
|
||||
})
|
||||
ds.MockedPodcastPodroll = podrollRepo
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ch := resp.Podcasts.Channel[0]
|
||||
Expect(ch.Podroll).To(HaveLen(2))
|
||||
feedURLs := []string{ch.Podroll[0].FeedURL, ch.Podroll[1].FeedURL}
|
||||
Expect(feedURLs).To(ConsistOf("https://a.example.com/feed.xml", "https://b.example.com/feed.xml"))
|
||||
})
|
||||
|
||||
It("includes podroll title and feedGuid", func() {
|
||||
podrollRepo := tests.CreateMockPodcastPodrollRepo()
|
||||
_ = podrollRepo.SaveForChannel(context.Background(), "ch-t3", []model.PodcastPodrollItem{
|
||||
{FeedGUID: "guid-a", FeedURL: "https://a.example.com/feed.xml", Title: "Show A"},
|
||||
})
|
||||
ds.MockedPodcastPodroll = podrollRepo
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
item := resp.Podcasts.Channel[0].Podroll[0]
|
||||
Expect(item.FeedGUID).To(Equal("guid-a"))
|
||||
Expect(item.Title).To(Equal("Show A"))
|
||||
})
|
||||
|
||||
It("includes liveItem in channel response", func() {
|
||||
liveItemRepo := tests.CreateMockPodcastLiveItemRepo()
|
||||
_ = liveItemRepo.Upsert(context.Background(), &model.PodcastLiveItem{
|
||||
ChannelID: "ch-t3",
|
||||
GUID: "live-guid-001",
|
||||
Title: "Live Show",
|
||||
Status: "live",
|
||||
EnclosureURL: "https://stream.example.com/live.m3u8",
|
||||
EnclosureType: "application/x-mpegURL",
|
||||
ContentLinkURL: "https://youtube.com/live",
|
||||
ContentLinkText: "Watch Live",
|
||||
})
|
||||
ds.MockedPodcastLiveItem = liveItemRepo
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
ch := resp.Podcasts.Channel[0]
|
||||
Expect(ch.LiveItem).ToNot(BeNil())
|
||||
Expect(ch.LiveItem.Status).To(Equal("live"))
|
||||
Expect(ch.LiveItem.GUID).To(Equal("live-guid-001"))
|
||||
Expect(ch.LiveItem.Title).To(Equal("Live Show"))
|
||||
Expect(ch.LiveItem.EnclosureURL).To(Equal("https://stream.example.com/live.m3u8"))
|
||||
Expect(ch.LiveItem.ContentLinkURL).To(Equal("https://youtube.com/live"))
|
||||
Expect(ch.LiveItem.ContentLinkText).To(Equal("Watch Live"))
|
||||
})
|
||||
|
||||
It("omits liveItem when none exists", func() {
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.Podcasts.Channel[0].LiveItem).To(BeNil())
|
||||
})
|
||||
|
||||
It("formats liveItem startTime and endTime as RFC3339", func() {
|
||||
liveItemRepo := tests.CreateMockPodcastLiveItemRepo()
|
||||
_ = liveItemRepo.Upsert(context.Background(), &model.PodcastLiveItem{
|
||||
ChannelID: "ch-t3",
|
||||
Status: "live",
|
||||
StartTime: time.Date(2024, 4, 27, 8, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2024, 4, 27, 9, 0, 0, 0, time.UTC),
|
||||
})
|
||||
ds.MockedPodcastLiveItem = liveItemRepo
|
||||
|
||||
r := httptest.NewRequest("GET", "/rest/getPodcasts", nil)
|
||||
r = r.WithContext(userCtx)
|
||||
resp, err := api.GetPodcasts(r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
li := resp.Podcasts.Channel[0].LiveItem
|
||||
Expect(li).ToNot(BeNil())
|
||||
Expect(li.StartTime).To(Equal("2024-04-27T08:00:00Z"))
|
||||
Expect(li.EndTime).To(Equal("2024-04-27T09:00:00Z"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -54,6 +54,10 @@ type Subsonic struct {
|
|||
|
||||
InternetRadioStations *InternetRadioStations `xml:"internetRadioStations,omitempty" json:"internetRadioStations,omitempty"`
|
||||
|
||||
Podcasts *Podcasts `xml:"podcasts,omitempty" json:"podcasts,omitempty"`
|
||||
NewestPodcasts *NewestPodcasts `xml:"newestPodcasts,omitempty" json:"newestPodcasts,omitempty"`
|
||||
PodcastEpisode *PodcastEpisode `xml:"podcastEpisode,omitempty" json:"podcastEpisode,omitempty"`
|
||||
|
||||
JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus,omitempty" json:"jukeboxStatus,omitempty"`
|
||||
JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"`
|
||||
|
||||
|
|
@ -690,6 +694,127 @@ type TranscodeDecision struct {
|
|||
TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"`
|
||||
}
|
||||
|
||||
// Podcast types
|
||||
|
||||
type Podcasts struct {
|
||||
Channel []PodcastChannel `xml:"channel,omitempty" json:"channel,omitempty"`
|
||||
}
|
||||
|
||||
type NewestPodcasts struct {
|
||||
Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"`
|
||||
}
|
||||
|
||||
type PodcastFundingResp struct {
|
||||
URL string `xml:"url,attr,omitempty" json:"url,omitempty"`
|
||||
Text string `xml:"text,attr,omitempty" json:"text,omitempty"`
|
||||
}
|
||||
|
||||
type PodcastImageResp struct {
|
||||
URL string `xml:"url,attr,omitempty" json:"url,omitempty"`
|
||||
Width int `xml:"width,attr,omitempty" json:"width,omitempty"`
|
||||
}
|
||||
|
||||
type PodcastChannel struct {
|
||||
ID string `xml:"id,attr" json:"id"`
|
||||
URL string `xml:"url,attr" json:"url"`
|
||||
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
|
||||
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
|
||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
||||
OriginalImageUrl string `xml:"originalImageUrl,attr,omitempty" json:"originalImageUrl,omitempty"`
|
||||
Status string `xml:"status,attr" json:"status"`
|
||||
ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"`
|
||||
Episode []PodcastEpisode `xml:"episode,omitempty" json:"episode,omitempty"`
|
||||
|
||||
// Podcasting 2.0 Tier 1 & 2
|
||||
PodcastGuid string `xml:"podcastGuid,attr,omitempty" json:"podcastGuid,omitempty"`
|
||||
Locked bool `xml:"locked,attr,omitempty" json:"locked,omitempty"`
|
||||
Medium string `xml:"medium,attr,omitempty" json:"medium,omitempty"`
|
||||
UpdateFrequency string `xml:"updateFrequency,attr,omitempty" json:"updateFrequency,omitempty"`
|
||||
Complete bool `xml:"complete,attr,omitempty" json:"complete,omitempty"`
|
||||
LocationName string `xml:"locationName,attr,omitempty" json:"locationName,omitempty"`
|
||||
LocationGeo string `xml:"locationGeo,attr,omitempty" json:"locationGeo,omitempty"`
|
||||
LocationOSM string `xml:"locationOsm,attr,omitempty" json:"locationOsm,omitempty"`
|
||||
License string `xml:"license,attr,omitempty" json:"license,omitempty"`
|
||||
PublisherName string `xml:"publisherName,attr,omitempty" json:"publisherName,omitempty"`
|
||||
PublisherURL string `xml:"publisherUrl,attr,omitempty" json:"publisherUrl,omitempty"`
|
||||
Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"`
|
||||
Funding []PodcastFundingResp `xml:"funding,omitempty" json:"funding,omitempty"`
|
||||
Images []PodcastImageResp `xml:"image,omitempty" json:"images,omitempty"`
|
||||
|
||||
// Podcasting 2.0 Tier 3
|
||||
UsesPodping bool `xml:"usesPodping,attr,omitempty" json:"usesPodping,omitempty"`
|
||||
Podroll []PodcastPodrollResp `xml:"podroll,omitempty" json:"podroll,omitempty"`
|
||||
LiveItem *PodcastLiveItemResp `xml:"liveItem,omitempty" json:"liveItem,omitempty"`
|
||||
}
|
||||
|
||||
// PodcastPodrollResp represents a single recommended feed in a podcast:podroll.
|
||||
type PodcastPodrollResp struct {
|
||||
FeedGUID string `xml:"feedGuid,attr,omitempty" json:"feedGuid,omitempty"`
|
||||
FeedURL string `xml:"feedUrl,attr,omitempty" json:"feedUrl,omitempty"`
|
||||
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// PodcastLiveItemResp represents a podcast:liveItem in the API response.
|
||||
type PodcastLiveItemResp struct {
|
||||
Status string `xml:"status,attr" json:"status"`
|
||||
StartTime string `xml:"startTime,attr,omitempty" json:"startTime,omitempty"`
|
||||
EndTime string `xml:"endTime,attr,omitempty" json:"endTime,omitempty"`
|
||||
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
|
||||
GUID string `xml:"guid,attr,omitempty" json:"guid,omitempty"`
|
||||
EnclosureURL string `xml:"enclosureUrl,attr,omitempty" json:"enclosureUrl,omitempty"`
|
||||
EnclosureType string `xml:"enclosureType,attr,omitempty" json:"enclosureType,omitempty"`
|
||||
ContentLinkURL string `xml:"contentLinkUrl,attr,omitempty" json:"contentLinkUrl,omitempty"`
|
||||
ContentLinkText string `xml:"contentLinkText,attr,omitempty" json:"contentLinkText,omitempty"`
|
||||
}
|
||||
|
||||
type PodcastEpisode struct {
|
||||
ID string `xml:"id,attr" json:"id"`
|
||||
StreamId string `xml:"streamId,attr,omitempty" json:"streamId,omitempty"`
|
||||
ChannelId string `xml:"channelId,attr,omitempty" json:"channelId,omitempty"`
|
||||
Title string `xml:"title,attr,omitempty" json:"title,omitempty"`
|
||||
Description string `xml:"description,attr,omitempty" json:"description,omitempty"`
|
||||
PublishDate string `xml:"publishDate,attr,omitempty" json:"publishDate,omitempty"`
|
||||
Status string `xml:"status,attr" json:"status"`
|
||||
ErrorMessage string `xml:"errorMessage,attr,omitempty" json:"errorMessage,omitempty"`
|
||||
Duration int `xml:"duration,attr,omitempty" json:"duration,omitempty"`
|
||||
Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"`
|
||||
Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"`
|
||||
ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"`
|
||||
BitRate int `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"`
|
||||
DownloadedBytes int64 `xml:"downloadedBytes,attr,omitempty" json:"downloadedBytes,omitempty"`
|
||||
|
||||
// Podcasting 2.0
|
||||
Season int `xml:"season,attr,omitempty" json:"season,omitempty"`
|
||||
SeasonName string `xml:"seasonName,attr,omitempty" json:"seasonName,omitempty"`
|
||||
EpisodeNumber string `xml:"episode,attr,omitempty" json:"episode,omitempty"`
|
||||
EpisodeDisplay string `xml:"episodeDisplay,attr,omitempty" json:"episodeDisplay,omitempty"`
|
||||
ChaptersUrl string `xml:"chaptersUrl,attr,omitempty" json:"chaptersUrl,omitempty"`
|
||||
SoundbiteStart float64 `xml:"soundbiteStart,attr,omitempty" json:"soundbiteStart,omitempty"`
|
||||
SoundbiteDur float64 `xml:"soundbiteDur,attr,omitempty" json:"soundbiteDur,omitempty"`
|
||||
LocationName string `xml:"locationName,attr,omitempty" json:"locationName,omitempty"`
|
||||
LocationGeo string `xml:"locationGeo,attr,omitempty" json:"locationGeo,omitempty"`
|
||||
LocationOSM string `xml:"locationOsm,attr,omitempty" json:"locationOsm,omitempty"`
|
||||
License string `xml:"license,attr,omitempty" json:"license,omitempty"`
|
||||
Transcript []PodcastTranscriptResp `xml:"transcript,omitempty" json:"transcript,omitempty"`
|
||||
Person []PodcastPersonResp `xml:"person,omitempty" json:"person,omitempty"`
|
||||
Images []PodcastImageResp `xml:"image,omitempty" json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type PodcastTranscriptResp struct {
|
||||
URL string `xml:"url,attr" json:"url"`
|
||||
Type string `xml:"type,attr" json:"type"`
|
||||
Language string `xml:"language,attr,omitempty" json:"language,omitempty"`
|
||||
Rel string `xml:"rel,attr,omitempty" json:"rel,omitempty"`
|
||||
}
|
||||
|
||||
type PodcastPersonResp struct {
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Role string `xml:"role,attr,omitempty" json:"role,omitempty"`
|
||||
Group string `xml:"group,attr,omitempty" json:"group,omitempty"`
|
||||
Img string `xml:"img,attr,omitempty" json:"img,omitempty"`
|
||||
Href string `xml:"href,attr,omitempty" json:"href,omitempty"`
|
||||
}
|
||||
|
||||
// StreamDetails describes audio stream properties for transcoding decisions
|
||||
type StreamDetails struct {
|
||||
Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"`
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ var _ = Describe("Search", func() {
|
|||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// Get references to the mock repositories so we can inspect their Options
|
||||
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ var _ = Describe("Transcode endpoints", func() {
|
|||
mockMFRepo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo}
|
||||
mockTD = &mockTranscodeDecision{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
|
|
@ -508,7 +508,7 @@ var _ = Describe("Transcode endpoints", func() {
|
|||
|
||||
It("builds correct StreamRequest for direct play", func() {
|
||||
fakeStreamer := &fakeMediaStreamer{}
|
||||
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
||||
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil)
|
||||
mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}})
|
||||
mockTD.resolvedReq = stream.Request{}
|
||||
|
||||
|
|
@ -525,7 +525,7 @@ var _ = Describe("Transcode endpoints", func() {
|
|||
|
||||
It("builds correct StreamRequest for transcoding", func() {
|
||||
fakeStreamer := &fakeMediaStreamer{}
|
||||
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil)
|
||||
router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil, nil)
|
||||
mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}})
|
||||
mockTD.resolvedReq = stream.Request{
|
||||
Format: "mp3",
|
||||
|
|
|
|||
|
|
@ -8,30 +8,38 @@ import (
|
|||
)
|
||||
|
||||
type MockDataStore struct {
|
||||
RealDS model.DataStore
|
||||
MockedLibrary model.LibraryRepository
|
||||
MockedFolder model.FolderRepository
|
||||
MockedGenre model.GenreRepository
|
||||
MockedAlbum model.AlbumRepository
|
||||
MockedArtist model.ArtistRepository
|
||||
MockedMediaFile model.MediaFileRepository
|
||||
MockedTag model.TagRepository
|
||||
MockedUser model.UserRepository
|
||||
MockedProperty model.PropertyRepository
|
||||
MockedPlayer model.PlayerRepository
|
||||
MockedPlaylist model.PlaylistRepository
|
||||
MockedPlayQueue model.PlayQueueRepository
|
||||
MockedShare model.ShareRepository
|
||||
MockedTranscoding model.TranscodingRepository
|
||||
MockedUserProps model.UserPropsRepository
|
||||
MockedScrobbleBuffer model.ScrobbleBufferRepository
|
||||
MockedScrobble model.ScrobbleRepository
|
||||
MockedRadio model.RadioRepository
|
||||
MockedPlugin model.PluginRepository
|
||||
MockedArtwork model.ArtworkRepository
|
||||
MockedArtworkQueue model.ArtworkQueueRepository
|
||||
scrobbleBufferMu sync.Mutex
|
||||
repoMu sync.Mutex
|
||||
RealDS model.DataStore
|
||||
MockedLibrary model.LibraryRepository
|
||||
MockedFolder model.FolderRepository
|
||||
MockedGenre model.GenreRepository
|
||||
MockedAlbum model.AlbumRepository
|
||||
MockedArtist model.ArtistRepository
|
||||
MockedMediaFile model.MediaFileRepository
|
||||
MockedTag model.TagRepository
|
||||
MockedUser model.UserRepository
|
||||
MockedProperty model.PropertyRepository
|
||||
MockedPlayer model.PlayerRepository
|
||||
MockedPlaylist model.PlaylistRepository
|
||||
MockedPlayQueue model.PlayQueueRepository
|
||||
MockedShare model.ShareRepository
|
||||
MockedTranscoding model.TranscodingRepository
|
||||
MockedUserProps model.UserPropsRepository
|
||||
MockedScrobbleBuffer model.ScrobbleBufferRepository
|
||||
MockedScrobble model.ScrobbleRepository
|
||||
MockedRadio model.RadioRepository
|
||||
MockedPlugin model.PluginRepository
|
||||
MockedArtwork model.ArtworkRepository
|
||||
MockedArtworkQueue model.ArtworkQueueRepository
|
||||
MockedPodcastChannel model.PodcastChannelRepository
|
||||
MockedPodcastEpisode model.PodcastEpisodeRepository
|
||||
MockedPodcastTranscript model.PodcastTranscriptRepository
|
||||
MockedPodcastPerson model.PodcastPersonRepository
|
||||
MockedPodcastPodroll model.PodcastPodrollRepository
|
||||
MockedPodcastLiveItem model.PodcastLiveItemRepository
|
||||
MockedPodcastFunding model.PodcastFundingRepository
|
||||
MockedPodcastImage model.PodcastImageRepository
|
||||
scrobbleBufferMu sync.Mutex
|
||||
repoMu sync.Mutex
|
||||
|
||||
// GC tracking
|
||||
GCCalled bool
|
||||
|
|
@ -321,6 +329,110 @@ func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository {
|
|||
return db.MockedArtworkQueue
|
||||
}
|
||||
|
||||
func (db *MockDataStore) PodcastChannel() model.PodcastChannelRepository {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedPodcastChannel != nil {
|
||||
return db.MockedPodcastChannel
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.PodcastChannel()
|
||||
}
|
||||
db.MockedPodcastChannel = CreateMockPodcastChannelRepo()
|
||||
return db.MockedPodcastChannel
|
||||
}
|
||||
|
||||
func (db *MockDataStore) PodcastEpisode() model.PodcastEpisodeRepository {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedPodcastEpisode != nil {
|
||||
return db.MockedPodcastEpisode
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.PodcastEpisode()
|
||||
}
|
||||
db.MockedPodcastEpisode = CreateMockPodcastEpisodeRepo()
|
||||
return db.MockedPodcastEpisode
|
||||
}
|
||||
|
||||
func (db *MockDataStore) PodcastTranscript() model.PodcastTranscriptRepository {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedPodcastTranscript != nil {
|
||||
return db.MockedPodcastTranscript
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.PodcastTranscript()
|
||||
}
|
||||
db.MockedPodcastTranscript = CreateMockPodcastTranscriptRepo()
|
||||
return db.MockedPodcastTranscript
|
||||
}
|
||||
|
||||
func (db *MockDataStore) PodcastPerson() model.PodcastPersonRepository {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedPodcastPerson != nil {
|
||||
return db.MockedPodcastPerson
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.PodcastPerson()
|
||||
}
|
||||
db.MockedPodcastPerson = CreateMockPodcastPersonRepo()
|
||||
return db.MockedPodcastPerson
|
||||
}
|
||||
|
||||
func (db *MockDataStore) PodcastPodroll() model.PodcastPodrollRepository {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedPodcastPodroll != nil {
|
||||
return db.MockedPodcastPodroll
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.PodcastPodroll()
|
||||
}
|
||||
db.MockedPodcastPodroll = CreateMockPodcastPodrollRepo()
|
||||
return db.MockedPodcastPodroll
|
||||
}
|
||||
|
||||
func (db *MockDataStore) PodcastLiveItem() model.PodcastLiveItemRepository {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedPodcastLiveItem != nil {
|
||||
return db.MockedPodcastLiveItem
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.PodcastLiveItem()
|
||||
}
|
||||
db.MockedPodcastLiveItem = CreateMockPodcastLiveItemRepo()
|
||||
return db.MockedPodcastLiveItem
|
||||
}
|
||||
|
||||
func (db *MockDataStore) PodcastFunding() model.PodcastFundingRepository {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedPodcastFunding != nil {
|
||||
return db.MockedPodcastFunding
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.PodcastFunding()
|
||||
}
|
||||
db.MockedPodcastFunding = CreateMockPodcastFundingRepo()
|
||||
return db.MockedPodcastFunding
|
||||
}
|
||||
|
||||
func (db *MockDataStore) PodcastImage() model.PodcastImageRepository {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedPodcastImage != nil {
|
||||
return db.MockedPodcastImage
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.PodcastImage()
|
||||
}
|
||||
db.MockedPodcastImage = CreateMockPodcastImageRepo()
|
||||
return db.MockedPodcastImage
|
||||
}
|
||||
|
||||
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
|
||||
return block(db)
|
||||
}
|
||||
|
|
|
|||
84
tests/mock_podcast_channel_repo.go
Normal file
84
tests/mock_podcast_channel_repo.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
type MockPodcastChannelRepo struct {
|
||||
model.PodcastChannelRepository
|
||||
Data map[string]*model.PodcastChannel
|
||||
Err bool
|
||||
}
|
||||
|
||||
func CreateMockPodcastChannelRepo() *MockPodcastChannelRepo {
|
||||
return &MockPodcastChannelRepo{Data: map[string]*model.PodcastChannel{}}
|
||||
}
|
||||
|
||||
func (m *MockPodcastChannelRepo) Get(ctx context.Context, chanID string) (*model.PodcastChannel, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
if c, ok := m.Data[chanID]; ok {
|
||||
return c, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockPodcastChannelRepo) GetAll(ctx context.Context, withEpisodes bool) (model.PodcastChannels, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
result := make(model.PodcastChannels, 0, len(m.Data))
|
||||
for _, c := range m.Data {
|
||||
result = append(result, *c)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastChannelRepo) Create(ctx context.Context, channel *model.PodcastChannel) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
if channel.ID == "" {
|
||||
channel.ID = id.NewRandom()
|
||||
}
|
||||
m.Data[channel.ID] = channel
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastChannelRepo) UpdateChannel(ctx context.Context, channel *model.PodcastChannel) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
m.Data[channel.ID] = channel
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastChannelRepo) ExistsByURL(ctx context.Context, url string) (bool, error) {
|
||||
if m.Err {
|
||||
return false, errors.New("error")
|
||||
}
|
||||
for _, c := range m.Data {
|
||||
if c.URL == url {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastChannelRepo) Delete(ctx context.Context, ids ...string) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
for _, chanID := range ids {
|
||||
if _, ok := m.Data[chanID]; !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
delete(m.Data, chanID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
116
tests/mock_podcast_episode_repo.go
Normal file
116
tests/mock_podcast_episode_repo.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
type MockPodcastEpisodeRepo struct {
|
||||
model.PodcastEpisodeRepository
|
||||
Data map[string]*model.PodcastEpisode
|
||||
Err bool
|
||||
}
|
||||
|
||||
func CreateMockPodcastEpisodeRepo() *MockPodcastEpisodeRepo {
|
||||
return &MockPodcastEpisodeRepo{Data: map[string]*model.PodcastEpisode{}}
|
||||
}
|
||||
|
||||
func (m *MockPodcastEpisodeRepo) Get(ctx context.Context, epID string) (*model.PodcastEpisode, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
if ep, ok := m.Data[epID]; ok {
|
||||
return ep, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockPodcastEpisodeRepo) GetNewest(ctx context.Context, count int) (model.PodcastEpisodes, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
all := make(model.PodcastEpisodes, 0, len(m.Data))
|
||||
for _, ep := range m.Data {
|
||||
all = append(all, *ep)
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
return all[i].PublishDate.After(all[j].PublishDate)
|
||||
})
|
||||
if count > 0 && len(all) > count {
|
||||
all = all[:count]
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastEpisodeRepo) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastEpisodes, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
ids := make(map[string]bool, len(channelIDs))
|
||||
for _, id := range channelIDs {
|
||||
ids[id] = true
|
||||
}
|
||||
var result model.PodcastEpisodes
|
||||
for _, ep := range m.Data {
|
||||
if ids[ep.ChannelID] {
|
||||
result = append(result, *ep)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastEpisodeRepo) GetByChannel(ctx context.Context, channelID string) (model.PodcastEpisodes, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
result := model.PodcastEpisodes{}
|
||||
for _, ep := range m.Data {
|
||||
if ep.ChannelID == channelID {
|
||||
result = append(result, *ep)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastEpisodeRepo) GetByGUID(ctx context.Context, channelID, guid string) (*model.PodcastEpisode, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
for _, ep := range m.Data {
|
||||
if ep.ChannelID == channelID && ep.GUID == guid {
|
||||
return ep, nil
|
||||
}
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockPodcastEpisodeRepo) Create(ctx context.Context, ep *model.PodcastEpisode) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
if ep.ID == "" {
|
||||
ep.ID = id.NewRandom()
|
||||
}
|
||||
m.Data[ep.ID] = ep
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastEpisodeRepo) Update(ctx context.Context, ep *model.PodcastEpisode) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
m.Data[ep.ID] = ep
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastEpisodeRepo) Delete(ctx context.Context, epID string) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
delete(m.Data, epID)
|
||||
return nil
|
||||
}
|
||||
67
tests/mock_podcast_funding_repo.go
Normal file
67
tests/mock_podcast_funding_repo.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
type MockPodcastFundingRepo struct {
|
||||
model.PodcastFundingRepository
|
||||
Data map[string]*model.PodcastFundingItem
|
||||
Err bool
|
||||
}
|
||||
|
||||
func CreateMockPodcastFundingRepo() *MockPodcastFundingRepo {
|
||||
return &MockPodcastFundingRepo{Data: map[string]*model.PodcastFundingItem{}}
|
||||
}
|
||||
|
||||
func (m *MockPodcastFundingRepo) GetByChannel(ctx context.Context, channelID string) (model.PodcastFundingItems, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
var result model.PodcastFundingItems
|
||||
for _, f := range m.Data {
|
||||
if f.ChannelID == channelID {
|
||||
result = append(result, *f)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastFundingRepo) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastFundingItems, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
ids := make(map[string]bool, len(channelIDs))
|
||||
for _, id := range channelIDs {
|
||||
ids[id] = true
|
||||
}
|
||||
var result model.PodcastFundingItems
|
||||
for _, f := range m.Data {
|
||||
if ids[f.ChannelID] {
|
||||
result = append(result, *f)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastFundingRepo) SaveForChannel(ctx context.Context, channelID string, items []model.PodcastFundingItem) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
for k, f := range m.Data {
|
||||
if f.ChannelID == channelID {
|
||||
delete(m.Data, k)
|
||||
}
|
||||
}
|
||||
for i := range items {
|
||||
items[i].ID = id.NewRandom()
|
||||
items[i].ChannelID = channelID
|
||||
f := items[i]
|
||||
m.Data[f.ID] = &f
|
||||
}
|
||||
return nil
|
||||
}
|
||||
115
tests/mock_podcast_image_repo.go
Normal file
115
tests/mock_podcast_image_repo.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
type MockPodcastImageRepo struct {
|
||||
model.PodcastImageRepository
|
||||
Data map[string]*model.PodcastImage
|
||||
Err bool
|
||||
}
|
||||
|
||||
func CreateMockPodcastImageRepo() *MockPodcastImageRepo {
|
||||
return &MockPodcastImageRepo{Data: map[string]*model.PodcastImage{}}
|
||||
}
|
||||
|
||||
func (m *MockPodcastImageRepo) GetByChannel(ctx context.Context, channelID string) (model.PodcastImages, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
var result model.PodcastImages
|
||||
for _, img := range m.Data {
|
||||
if img.ChannelID == channelID {
|
||||
result = append(result, *img)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastImageRepo) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastImages, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
ids := make(map[string]bool, len(channelIDs))
|
||||
for _, id := range channelIDs {
|
||||
ids[id] = true
|
||||
}
|
||||
var result model.PodcastImages
|
||||
for _, img := range m.Data {
|
||||
if ids[img.ChannelID] {
|
||||
result = append(result, *img)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastImageRepo) GetByEpisode(ctx context.Context, episodeID string) (model.PodcastImages, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
var result model.PodcastImages
|
||||
for _, img := range m.Data {
|
||||
if img.EpisodeID == episodeID {
|
||||
result = append(result, *img)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastImageRepo) GetByEpisodes(ctx context.Context, episodeIDs []string) (model.PodcastImages, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
ids := make(map[string]bool, len(episodeIDs))
|
||||
for _, id := range episodeIDs {
|
||||
ids[id] = true
|
||||
}
|
||||
var result model.PodcastImages
|
||||
for _, img := range m.Data {
|
||||
if ids[img.EpisodeID] {
|
||||
result = append(result, *img)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastImageRepo) SaveForChannel(ctx context.Context, channelID string, images []model.PodcastImage) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
for k, img := range m.Data {
|
||||
if img.ChannelID == channelID {
|
||||
delete(m.Data, k)
|
||||
}
|
||||
}
|
||||
for i := range images {
|
||||
images[i].ID = id.NewRandom()
|
||||
images[i].ChannelID = channelID
|
||||
img := images[i]
|
||||
m.Data[img.ID] = &img
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastImageRepo) SaveForEpisode(ctx context.Context, episodeID string, images []model.PodcastImage) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
for k, img := range m.Data {
|
||||
if img.EpisodeID == episodeID {
|
||||
delete(m.Data, k)
|
||||
}
|
||||
}
|
||||
for i := range images {
|
||||
images[i].ID = id.NewRandom()
|
||||
images[i].EpisodeID = episodeID
|
||||
img := images[i]
|
||||
m.Data[img.ID] = &img
|
||||
}
|
||||
return nil
|
||||
}
|
||||
54
tests/mock_podcast_live_item_repo.go
Normal file
54
tests/mock_podcast_live_item_repo.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
// MockPodcastLiveItemRepo is a manual in-memory mock for model.PodcastLiveItemRepository.
|
||||
type MockPodcastLiveItemRepo struct {
|
||||
model.PodcastLiveItemRepository
|
||||
Data map[string]*model.PodcastLiveItem // keyed by channelID
|
||||
Err bool
|
||||
}
|
||||
|
||||
// CreateMockPodcastLiveItemRepo returns an initialized MockPodcastLiveItemRepo.
|
||||
func CreateMockPodcastLiveItemRepo() *MockPodcastLiveItemRepo {
|
||||
return &MockPodcastLiveItemRepo{Data: map[string]*model.PodcastLiveItem{}}
|
||||
}
|
||||
|
||||
func (m *MockPodcastLiveItemRepo) GetByChannel(ctx context.Context, channelID string) (*model.PodcastLiveItem, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
if item, ok := m.Data[channelID]; ok {
|
||||
return item, nil
|
||||
}
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *MockPodcastLiveItemRepo) Upsert(ctx context.Context, item *model.PodcastLiveItem) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
if _, ok := m.Data[item.ChannelID]; !ok {
|
||||
item.ID = id.NewRandom()
|
||||
item.CreatedAt = time.Now()
|
||||
}
|
||||
item.UpdatedAt = time.Now()
|
||||
cp := *item
|
||||
m.Data[item.ChannelID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastLiveItemRepo) DeleteByChannel(ctx context.Context, channelID string) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
delete(m.Data, channelID)
|
||||
return nil
|
||||
}
|
||||
98
tests/mock_podcast_person_repo.go
Normal file
98
tests/mock_podcast_person_repo.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
type MockPodcastPersonRepo struct {
|
||||
model.PodcastPersonRepository
|
||||
Data map[string]*model.PodcastPerson
|
||||
Err bool
|
||||
}
|
||||
|
||||
func CreateMockPodcastPersonRepo() *MockPodcastPersonRepo {
|
||||
return &MockPodcastPersonRepo{Data: map[string]*model.PodcastPerson{}}
|
||||
}
|
||||
|
||||
func (m *MockPodcastPersonRepo) GetByChannel(ctx context.Context, channelID string) (model.PodcastPersons, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
var result model.PodcastPersons
|
||||
for _, p := range m.Data {
|
||||
if p.ChannelID == channelID {
|
||||
result = append(result, *p)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastPersonRepo) GetByEpisode(ctx context.Context, episodeID string) (model.PodcastPersons, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
var result model.PodcastPersons
|
||||
for _, p := range m.Data {
|
||||
if p.EpisodeID == episodeID {
|
||||
result = append(result, *p)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastPersonRepo) GetByEpisodes(ctx context.Context, episodeIDs []string) (model.PodcastPersons, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
ids := make(map[string]bool, len(episodeIDs))
|
||||
for _, id := range episodeIDs {
|
||||
ids[id] = true
|
||||
}
|
||||
var result model.PodcastPersons
|
||||
for _, p := range m.Data {
|
||||
if ids[p.EpisodeID] {
|
||||
result = append(result, *p)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastPersonRepo) SaveForChannel(ctx context.Context, channelID string, persons []model.PodcastPerson) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
for k, p := range m.Data {
|
||||
if p.ChannelID == channelID {
|
||||
delete(m.Data, k)
|
||||
}
|
||||
}
|
||||
for i := range persons {
|
||||
persons[i].ID = id.NewRandom()
|
||||
persons[i].ChannelID = channelID
|
||||
p := persons[i]
|
||||
m.Data[p.ID] = &p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastPersonRepo) SaveForEpisode(ctx context.Context, episodeID string, persons []model.PodcastPerson) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
for k, p := range m.Data {
|
||||
if p.EpisodeID == episodeID {
|
||||
delete(m.Data, k)
|
||||
}
|
||||
}
|
||||
for i := range persons {
|
||||
persons[i].ID = id.NewRandom()
|
||||
persons[i].EpisodeID = episodeID
|
||||
p := persons[i]
|
||||
m.Data[p.ID] = &p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
71
tests/mock_podcast_podroll_repo.go
Normal file
71
tests/mock_podcast_podroll_repo.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
// MockPodcastPodrollRepo is a manual in-memory mock for model.PodcastPodrollRepository.
|
||||
type MockPodcastPodrollRepo struct {
|
||||
model.PodcastPodrollRepository
|
||||
Data map[string]*model.PodcastPodrollItem // keyed by item ID
|
||||
Err bool
|
||||
}
|
||||
|
||||
// CreateMockPodcastPodrollRepo returns an initialized MockPodcastPodrollRepo.
|
||||
func CreateMockPodcastPodrollRepo() *MockPodcastPodrollRepo {
|
||||
return &MockPodcastPodrollRepo{Data: map[string]*model.PodcastPodrollItem{}}
|
||||
}
|
||||
|
||||
func (m *MockPodcastPodrollRepo) GetByChannel(ctx context.Context, channelID string) (model.PodcastPodrollItems, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
var result model.PodcastPodrollItems
|
||||
for _, item := range m.Data {
|
||||
if item.ChannelID == channelID {
|
||||
result = append(result, *item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastPodrollRepo) GetByChannels(ctx context.Context, channelIDs []string) (model.PodcastPodrollItems, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
ids := make(map[string]bool)
|
||||
for _, cid := range channelIDs {
|
||||
ids[cid] = true
|
||||
}
|
||||
var result model.PodcastPodrollItems
|
||||
for _, item := range m.Data {
|
||||
if ids[item.ChannelID] {
|
||||
result = append(result, *item)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastPodrollRepo) SaveForChannel(ctx context.Context, channelID string, items []model.PodcastPodrollItem) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
// Remove old items for this channel.
|
||||
for k, v := range m.Data {
|
||||
if v.ChannelID == channelID {
|
||||
delete(m.Data, k)
|
||||
}
|
||||
}
|
||||
for i := range items {
|
||||
items[i].ID = id.NewRandom()
|
||||
items[i].ChannelID = channelID
|
||||
items[i].SortOrder = i
|
||||
cp := items[i]
|
||||
m.Data[cp.ID] = &cp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
75
tests/mock_podcast_transcript_repo.go
Normal file
75
tests/mock_podcast_transcript_repo.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
)
|
||||
|
||||
type MockPodcastTranscriptRepo struct {
|
||||
model.PodcastTranscriptRepository
|
||||
Data map[string]*model.PodcastTranscript
|
||||
Err bool
|
||||
}
|
||||
|
||||
func CreateMockPodcastTranscriptRepo() *MockPodcastTranscriptRepo {
|
||||
return &MockPodcastTranscriptRepo{Data: map[string]*model.PodcastTranscript{}}
|
||||
}
|
||||
|
||||
func (m *MockPodcastTranscriptRepo) GetByEpisode(ctx context.Context, episodeID string) (model.PodcastTranscripts, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
var result model.PodcastTranscripts
|
||||
for _, t := range m.Data {
|
||||
if t.EpisodeID == episodeID {
|
||||
result = append(result, *t)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastTranscriptRepo) GetByEpisodes(ctx context.Context, episodeIDs []string) (model.PodcastTranscripts, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
ids := make(map[string]bool, len(episodeIDs))
|
||||
for _, id := range episodeIDs {
|
||||
ids[id] = true
|
||||
}
|
||||
var result model.PodcastTranscripts
|
||||
for _, t := range m.Data {
|
||||
if ids[t.EpisodeID] {
|
||||
result = append(result, *t)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastTranscriptRepo) Save(ctx context.Context, transcripts []model.PodcastTranscript) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
for i := range transcripts {
|
||||
if transcripts[i].ID == "" {
|
||||
transcripts[i].ID = id.NewRandom()
|
||||
}
|
||||
t := transcripts[i]
|
||||
m.Data[t.ID] = &t
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPodcastTranscriptRepo) DeleteByEpisode(ctx context.Context, episodeID string) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
for k, t := range m.Data {
|
||||
if t.EpisodeID == episodeID {
|
||||
delete(m.Data, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -139,7 +139,8 @@
|
|||
"actions": {
|
||||
"topSongs": "Top Songs",
|
||||
"shuffle": "Shuffle",
|
||||
"radio": "Radio"
|
||||
"radio": "Radio",
|
||||
"podcast": "Podcasts"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
|
|
@ -275,6 +276,43 @@
|
|||
"playNow": "Play Now"
|
||||
}
|
||||
},
|
||||
"podcast": {
|
||||
"name": "Podcast |||| Podcasts",
|
||||
"fields": {
|
||||
"title": "Title",
|
||||
"url": "Feed URL",
|
||||
"description": "Description",
|
||||
"status": "Status",
|
||||
"publishDate": "Published",
|
||||
"duration": "Duration",
|
||||
"episodeCount": "Episodes"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Refresh All",
|
||||
"download": "Download",
|
||||
"addChannel": "Add Channel",
|
||||
"fetchFeed": "Fetch Feed Info",
|
||||
"copyUrl": "Copy Feed URL"
|
||||
},
|
||||
"notifications": {
|
||||
"refreshStarted": "Podcast refresh started",
|
||||
"downloadStarted": "Download started",
|
||||
"channelAdded": "Channel added",
|
||||
"urlCopied": "Feed URL copied to clipboard",
|
||||
"urlCopyFailed": "Could not copy the feed URL",
|
||||
"alreadyExists": "This channel is already registered.",
|
||||
"channelDeleted": "Channel deleted",
|
||||
"episodeDeleted": "Episode deleted"
|
||||
},
|
||||
"status": {
|
||||
"new": "New",
|
||||
"downloading": "Downloading",
|
||||
"completed": "Downloaded",
|
||||
"error": "Error",
|
||||
"skipped": "Skipped",
|
||||
"deleted": "Deleted"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Share |||| Shares",
|
||||
"fields": {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import React, { useCallback, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
BulkActionsToolbar,
|
||||
FunctionField,
|
||||
Link,
|
||||
ListToolbar,
|
||||
TextField,
|
||||
NumberField,
|
||||
TextField,
|
||||
useDataProvider,
|
||||
useListContext,
|
||||
useNotify,
|
||||
useVersion,
|
||||
useListContext,
|
||||
FunctionField,
|
||||
} from 'react-admin'
|
||||
import clsx from 'clsx'
|
||||
import { useDispatch } from 'react-redux'
|
||||
|
|
@ -16,24 +17,56 @@ import { Card, useMediaQuery } from '@material-ui/core'
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import ReactDragListView from 'react-drag-listview'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
DateField,
|
||||
DurationField,
|
||||
SongInfo,
|
||||
QualityInfo,
|
||||
RatingField,
|
||||
SongContextMenu,
|
||||
SongDatagrid,
|
||||
SongInfo,
|
||||
SongTitleField,
|
||||
QualityInfo,
|
||||
useSelectedFields,
|
||||
useResourceRefresh,
|
||||
DateField,
|
||||
ArtistLinkField,
|
||||
RatingField,
|
||||
useSelectedFields,
|
||||
} from '../common'
|
||||
import { AlbumLinkField } from '../song/AlbumLinkField'
|
||||
import { playTracks } from '../actions'
|
||||
import { closeExtendedInfoDialog, playTracks } from '../actions'
|
||||
import PlaylistSongBulkActions from './PlaylistSongBulkActions'
|
||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||
import config from '../config'
|
||||
|
||||
const AlbumOrChannelLinkField = (props) => {
|
||||
const dispatch = useDispatch()
|
||||
const record = props.record
|
||||
const isPodcast = record?.genre === 'Podcast'
|
||||
const linkTo = isPodcast
|
||||
? `/podcast/${record.albumId}/show`
|
||||
: `/album/${record.albumId}/show`
|
||||
return (
|
||||
<Link
|
||||
to={linkTo}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
dispatch(closeExtendedInfoDialog())
|
||||
}}
|
||||
>
|
||||
{record?.album}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
AlbumOrChannelLinkField.defaultProps = { addLabel: true, sortBy: 'album' }
|
||||
|
||||
const PodcastAwareArtistField = (props) => {
|
||||
const record = props.record
|
||||
const isPodcast = record?.genre === 'Podcast'
|
||||
const hasNoRealArtist =
|
||||
!record?.artist || record?.artist === '[Unknown Artist]'
|
||||
if (isPodcast && hasNoRealArtist) {
|
||||
return <span>-</span>
|
||||
}
|
||||
return <ArtistLinkField {...props} />
|
||||
}
|
||||
PodcastAwareArtistField.defaultProps = { addLabel: true, sortBy: 'artist' }
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
root: {},
|
||||
|
|
@ -148,8 +181,8 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => {
|
|||
return {
|
||||
trackNumber: isDesktop && <TextField source="id" label={'#'} />,
|
||||
title: <SongTitleField source="title" showTrackNumbers={false} />,
|
||||
album: isDesktop && <AlbumLinkField source="album" />,
|
||||
artist: isDesktop && <ArtistLinkField source="artist" />,
|
||||
album: isDesktop && <AlbumOrChannelLinkField source="album" />,
|
||||
artist: isDesktop && <PodcastAwareArtistField source="artist" />,
|
||||
albumArtist: isDesktop && <ArtistLinkField source="albumArtist" />,
|
||||
duration: (
|
||||
<DurationField source="duration" className={classes.draggable} />
|
||||
|
|
|
|||
73
ui/src/podcast/EpisodeActions.jsx
Normal file
73
ui/src/podcast/EpisodeActions.jsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import React from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { IconButton, CircularProgress } from '@material-ui/core'
|
||||
import GetAppIcon from '@material-ui/icons/GetApp'
|
||||
import DeleteIcon from '@material-ui/icons/Delete'
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||
import subsonic from '../subsonic'
|
||||
import { setTrack } from '../actions'
|
||||
|
||||
const EpisodeActions = ({ episode, onRefresh, channelTitle }) => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const handleDownload = async () => {
|
||||
await subsonic.downloadPodcastEpisode(episode.id)
|
||||
onRefresh?.()
|
||||
}
|
||||
|
||||
const handleDelete = async (e) => {
|
||||
e.stopPropagation()
|
||||
await subsonic.deletePodcastEpisode(episode.id)
|
||||
onRefresh?.()
|
||||
}
|
||||
|
||||
const handlePlay = (e) => {
|
||||
e.stopPropagation()
|
||||
dispatch(
|
||||
setTrack({
|
||||
id: episode.streamId,
|
||||
title: episode.title,
|
||||
album: channelTitle || episode.channelId,
|
||||
artist: '',
|
||||
duration: episode.duration,
|
||||
suffix: episode.suffix,
|
||||
isPodcast: true,
|
||||
channelId: episode.channelId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (episode.status === 'downloading') {
|
||||
return <CircularProgress size={20} />
|
||||
}
|
||||
|
||||
if (episode.status === 'completed') {
|
||||
return (
|
||||
<>
|
||||
<IconButton aria-label="play" size="small" onClick={handlePlay}>
|
||||
<PlayArrowIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton aria-label="delete" size="small" onClick={handleDelete}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (episode.status === 'new' || episode.status === 'error') {
|
||||
return (
|
||||
<>
|
||||
<IconButton aria-label="download" size="small" onClick={handleDownload}>
|
||||
<GetAppIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton aria-label="delete" size="small" onClick={handleDelete}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default EpisodeActions
|
||||
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
|
||||
103
ui/src/podcast/PodcastCreate.test.jsx
Normal file
103
ui/src/podcast/PodcastCreate.test.jsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import React from 'react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('../subsonic', () => ({
|
||||
default: {
|
||||
createPodcastChannel: vi.fn().mockResolvedValue({}),
|
||||
previewPodcastFeed: vi.fn().mockResolvedValue({
|
||||
json: {
|
||||
title: 'Example Podcast',
|
||||
description: 'An example feed',
|
||||
episodeCount: 3,
|
||||
alreadyExists: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
const mockRefresh = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('react-admin', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...actual,
|
||||
useTranslate: () => (key) => key,
|
||||
useNotify: () => mockNotify,
|
||||
useRedirect: () => mockRedirect,
|
||||
useRefresh: () => mockRefresh,
|
||||
Title: () => null,
|
||||
Button: ({ children, onClick, label, disabled }) => (
|
||||
<button aria-label={label} onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
import subsonic from '../subsonic'
|
||||
import PodcastCreate from './PodcastCreate'
|
||||
|
||||
const fetchPreview = async (url) => {
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: url } })
|
||||
fireEvent.click(
|
||||
screen.getByLabelText('resources.podcast.actions.fetchFeed'),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(subsonic.previewPodcastFeed).toHaveBeenCalledWith(url)
|
||||
})
|
||||
}
|
||||
|
||||
describe('PodcastCreate', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders a URL input field', () => {
|
||||
render(<PodcastCreate />)
|
||||
expect(screen.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('fetches a preview of the feed for the entered URL', async () => {
|
||||
render(<PodcastCreate />)
|
||||
await fetchPreview('https://example.com/feed.xml')
|
||||
expect(await screen.findByText('Example Podcast')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls createPodcastChannel with the entered URL when adding the previewed channel', async () => {
|
||||
render(<PodcastCreate />)
|
||||
await fetchPreview('https://example.com/feed.xml')
|
||||
fireEvent.click(
|
||||
await screen.findByLabelText('resources.podcast.actions.addChannel'),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(subsonic.createPodcastChannel).toHaveBeenCalledWith(
|
||||
'https://example.com/feed.xml',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('redirects to /podcast after successfully adding the channel', async () => {
|
||||
render(<PodcastCreate />)
|
||||
await fetchPreview('https://example.com/feed.xml')
|
||||
fireEvent.click(
|
||||
await screen.findByLabelText('resources.podcast.actions.addChannel'),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/podcast')
|
||||
})
|
||||
})
|
||||
|
||||
it('notifies on success', async () => {
|
||||
render(<PodcastCreate />)
|
||||
await fetchPreview('https://example.com/feed.xml')
|
||||
fireEvent.click(
|
||||
await screen.findByLabelText('resources.podcast.actions.addChannel'),
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(
|
||||
'resources.podcast.notifications.channelAdded',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
101
ui/src/podcast/PodcastGridView.jsx
Normal file
101
ui/src/podcast/PodcastGridView.jsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
GridList,
|
||||
GridListTile,
|
||||
GridListTileBar,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import MicIcon from '@material-ui/icons/Mic'
|
||||
import { useListContext, linkToRecord } from 'react-admin'
|
||||
import { Link } from 'react-router-dom'
|
||||
import withWidth from '@material-ui/core/withWidth'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: { margin: theme.spacing(1) },
|
||||
tileContainer: { cursor: 'pointer' },
|
||||
link: { display: 'block', textDecoration: 'none', color: 'inherit' },
|
||||
cover: { width: '100%', display: 'block', objectFit: 'cover' },
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
paddingBottom: '100%',
|
||||
position: 'relative',
|
||||
backgroundColor: theme.palette.grey[300],
|
||||
},
|
||||
placeholderIcon: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
},
|
||||
tileBar: {
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 100%)',
|
||||
},
|
||||
title: {
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
marginTop: theme.spacing(0.5),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
}))
|
||||
|
||||
const buildSrcSet = (images) => {
|
||||
if (!images || images.length === 0) return undefined
|
||||
return images.map((img) => `${img.url} ${img.width}w`).join(', ')
|
||||
}
|
||||
|
||||
const getColsForWidth = (width) => {
|
||||
if (width === 'xs') return 2
|
||||
if (width === 'sm') return 3
|
||||
if (width === 'md') return 4
|
||||
if (width === 'lg') return 5
|
||||
return 6
|
||||
}
|
||||
|
||||
const PodcastGridView = ({ width, ...props }) => {
|
||||
const classes = useStyles()
|
||||
const { ids, data, basePath } = useListContext(props)
|
||||
|
||||
if (!ids || !data) return null
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<GridList cellHeight="auto" cols={getColsForWidth(width)} spacing={16}>
|
||||
{ids.map((id) => {
|
||||
const record = data[id]
|
||||
if (!record) return null
|
||||
return (
|
||||
<GridListTile key={id}>
|
||||
<div className={classes.tileContainer}>
|
||||
<Link className={classes.link} to={linkToRecord(basePath, id, 'show')}>
|
||||
{record.imageUrl ? (
|
||||
<img
|
||||
src={record.imageUrl}
|
||||
srcSet={buildSrcSet(record.images)}
|
||||
sizes="(max-width: 600px) 50vw, (max-width: 960px) 33vw, (max-width: 1280px) 25vw, 20vw"
|
||||
alt={record.title}
|
||||
className={classes.cover}
|
||||
/>
|
||||
) : (
|
||||
<div className={classes.placeholder}>
|
||||
<MicIcon className={classes.placeholderIcon} style={{ fontSize: 48, color: '#888' }} />
|
||||
</div>
|
||||
)}
|
||||
<GridListTileBar className={classes.tileBar} title="" />
|
||||
</Link>
|
||||
<Link className={classes.link} to={linkToRecord(basePath, id, 'show')}>
|
||||
<Typography className={classes.title}>{record.title}</Typography>
|
||||
</Link>
|
||||
</div>
|
||||
</GridListTile>
|
||||
)
|
||||
})}
|
||||
</GridList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withWidth()(PodcastGridView)
|
||||
202
ui/src/podcast/PodcastList.jsx
Normal file
202
ui/src/podcast/PodcastList.jsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import React from 'react'
|
||||
import { Avatar, ButtonGroup, IconButton, makeStyles, Tooltip, Typography, useMediaQuery } from '@material-ui/core'
|
||||
import FileCopyIcon from '@material-ui/icons/FileCopy'
|
||||
import MicIcon from '@material-ui/icons/Mic'
|
||||
import RefreshIcon from '@material-ui/icons/Refresh'
|
||||
import ViewModuleIcon from '@material-ui/icons/ViewModule'
|
||||
import ViewHeadlineIcon from '@material-ui/icons/ViewHeadline'
|
||||
import {
|
||||
Button,
|
||||
CreateButton,
|
||||
Datagrid,
|
||||
DateField,
|
||||
Filter,
|
||||
sanitizeListRestProps,
|
||||
SearchInput,
|
||||
SimpleList,
|
||||
TextField,
|
||||
TopToolbar,
|
||||
useNotify,
|
||||
useRefresh,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { List } from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import PodcastGridView from './PodcastGridView'
|
||||
import { podcastViewGrid, podcastViewTable } from '../actions'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
row: { '&:hover': { '& $contextMenu': { visibility: 'visible' } } },
|
||||
contextMenu: { visibility: 'hidden' },
|
||||
toggleTitle: { margin: '1rem' },
|
||||
buttonGroup: { width: '100%', justifyContent: 'center' },
|
||||
leftButton: { paddingRight: '0.5rem' },
|
||||
rightButton: { paddingLeft: '0.5rem' },
|
||||
})
|
||||
|
||||
const PodcastFilter = (props) => (
|
||||
<Filter {...props} variant="outlined">
|
||||
<SearchInput id="search" source="title" alwaysOn />
|
||||
</Filter>
|
||||
)
|
||||
|
||||
const bestImageUrl = (record, targetWidth) => {
|
||||
if (!record.images || record.images.length === 0) return record.imageUrl
|
||||
const sorted = [...record.images].sort((a, b) => a.width - b.width)
|
||||
const best = sorted.find((img) => img.width >= targetWidth) || sorted[sorted.length - 1]
|
||||
return best ? best.url : record.imageUrl
|
||||
}
|
||||
|
||||
const CoverArtField = ({ record }) => {
|
||||
if (!record) return null
|
||||
if (record.imageUrl) {
|
||||
return (
|
||||
<Avatar src={bestImageUrl(record, 55)} variant="rounded" style={{ width: 55, height: 55 }} alt={record.title} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Avatar variant="rounded" style={{ width: 55, height: 55 }}>
|
||||
<MicIcon />
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
CoverArtField.defaultProps = { label: '', sortable: false }
|
||||
|
||||
const FeedUrlField = ({ record }) => {
|
||||
const notify = useNotify()
|
||||
const translate = useTranslate()
|
||||
if (!record?.url) return null
|
||||
const handleCopy = (e) => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard
|
||||
.writeText(record.url)
|
||||
.then(() => notify('resources.podcast.notifications.urlCopied'))
|
||||
.catch(() => notify('resources.podcast.notifications.urlCopyFailed', 'warning'))
|
||||
}
|
||||
return (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{record.url}
|
||||
</span>
|
||||
<Tooltip title={translate('resources.podcast.actions.copyUrl', { _: 'Copy URL' })}>
|
||||
<IconButton size="small" onClick={handleCopy}>
|
||||
<FileCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
FeedUrlField.defaultProps = { label: 'resources.podcast.fields.url', sortable: false }
|
||||
|
||||
const StatusField = ({ record }) => {
|
||||
if (!record || record.status !== 'error') return null
|
||||
return <StatusBadge status="error" errorMessage={record.errorMessage} />
|
||||
}
|
||||
StatusField.defaultProps = { label: '' }
|
||||
|
||||
const RefreshButton = () => {
|
||||
const notify = useNotify()
|
||||
const refresh = useRefresh()
|
||||
|
||||
const handleClick = async () => {
|
||||
await subsonic.refreshPodcasts()
|
||||
notify('resources.podcast.notifications.refreshStarted')
|
||||
refresh()
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} label="resources.podcast.actions.refresh">
|
||||
<RefreshIcon />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const PodcastViewToggler = React.forwardRef(({ showTitle = true }, ref) => {
|
||||
const dispatch = useDispatch()
|
||||
const podcastView = useSelector((state) => state.podcastView)
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{showTitle && (
|
||||
<Typography className={classes.toggleTitle}>
|
||||
{translate('ra.toggleFieldsMenu.layout')}
|
||||
</Typography>
|
||||
)}
|
||||
<ButtonGroup variant="text" color="primary" className={classes.buttonGroup}>
|
||||
<Button
|
||||
size="small"
|
||||
className={classes.leftButton}
|
||||
label={translate('ra.toggleFieldsMenu.grid')}
|
||||
color={podcastView.grid ? 'primary' : 'secondary'}
|
||||
onClick={() => dispatch(podcastViewGrid())}
|
||||
>
|
||||
<ViewModuleIcon fontSize="inherit" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
className={classes.rightButton}
|
||||
label={translate('ra.toggleFieldsMenu.table')}
|
||||
color={podcastView.grid ? 'secondary' : 'primary'}
|
||||
onClick={() => dispatch(podcastViewTable())}
|
||||
>
|
||||
<ViewHeadlineIcon fontSize="inherit" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
PodcastViewToggler.displayName = 'PodcastViewToggler'
|
||||
|
||||
const PodcastListActions = ({ className, filters, resource, showFilter, displayedFilters, filterValues, isAdmin, ...rest }) => {
|
||||
return (
|
||||
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
|
||||
{isAdmin && <RefreshButton />}
|
||||
{isAdmin && <CreateButton basePath="/podcast" />}
|
||||
{filters && React.cloneElement(filters, { resource, showFilter, displayedFilters, filterValues, context: 'button' })}
|
||||
<PodcastViewToggler showTitle={false} />
|
||||
</TopToolbar>
|
||||
)
|
||||
}
|
||||
|
||||
const PodcastList = ({ permissions, ...props }) => {
|
||||
const classes = useStyles()
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
const isAdmin = permissions === 'admin'
|
||||
const podcastView = useSelector((state) => state.podcastView)
|
||||
|
||||
return (
|
||||
<List
|
||||
{...props}
|
||||
exporter={false}
|
||||
sort={{ field: 'title', order: 'ASC' }}
|
||||
bulkActionButtons={isAdmin ? undefined : false}
|
||||
hasCreate={isAdmin}
|
||||
actions={<PodcastListActions isAdmin={isAdmin} />}
|
||||
filters={<PodcastFilter />}
|
||||
>
|
||||
{isXsmall ? (
|
||||
<SimpleList
|
||||
linkType="show"
|
||||
leftAvatar={(r) => <CoverArtField record={r} />}
|
||||
primaryText={(r) => r.title}
|
||||
secondaryText={(r) => r.url}
|
||||
/>
|
||||
) : podcastView.grid ? (
|
||||
<PodcastGridView />
|
||||
) : (
|
||||
<Datagrid rowClick="show" classes={{ row: classes.row }}>
|
||||
<CoverArtField source="id" />
|
||||
<TextField source="title" />
|
||||
<FeedUrlField source="url" />
|
||||
<StatusField source="status" sortable={false} />
|
||||
<DateField source="updatedAt" showTime />
|
||||
</Datagrid>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
export default PodcastList
|
||||
242
ui/src/podcast/PodcastShow.jsx
Normal file
242
ui/src/podcast/PodcastShow.jsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
makeStyles,
|
||||
Link,
|
||||
} from '@material-ui/core'
|
||||
import { Button, useTranslate, useShowController, Title } from 'react-admin'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import MicIcon from '@material-ui/icons/Mic'
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
|
||||
import ShuffleIcon from '@material-ui/icons/Shuffle'
|
||||
import { RiPlayList2Fill, RiPlayListAddFill } from 'react-icons/ri'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import EpisodeActions from './EpisodeActions'
|
||||
import subsonic from '../subsonic'
|
||||
import { setTrack, playTracks, shuffleTracks, playNext, addTracks } from '../actions'
|
||||
|
||||
const songFromEpisode = (episode, channelTitle) => ({
|
||||
id: episode.streamId,
|
||||
title: episode.title,
|
||||
album: channelTitle || episode.channelId,
|
||||
artist: '',
|
||||
duration: episode.duration,
|
||||
suffix: episode.suffix,
|
||||
isPodcast: true,
|
||||
channelId: episode.channelId,
|
||||
})
|
||||
|
||||
const buildTracksData = (episodes, channelTitle) => {
|
||||
const data = {}
|
||||
const ids = []
|
||||
episodes
|
||||
.filter((ep) => ep.status === 'completed' && ep.streamId)
|
||||
.forEach((ep) => {
|
||||
const song = songFromEpisode(ep, channelTitle)
|
||||
data[song.id] = song
|
||||
ids.push(song.id)
|
||||
})
|
||||
return { data, ids }
|
||||
}
|
||||
|
||||
const EpisodePlayButtons = ({ episodes, channelTitle }) => {
|
||||
const dispatch = useDispatch()
|
||||
const translate = useTranslate()
|
||||
const { data, ids } = buildTracksData(episodes, channelTitle)
|
||||
if (!ids.length) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Button onClick={() => dispatch(playTracks(data, ids))} label={translate('resources.album.actions.playAll')}>
|
||||
<PlayArrowIcon />
|
||||
</Button>
|
||||
<Button onClick={() => dispatch(shuffleTracks(data, ids))} label={translate('resources.album.actions.shuffle')}>
|
||||
<ShuffleIcon />
|
||||
</Button>
|
||||
<Button onClick={() => dispatch(playNext(data, ids))} label={translate('resources.album.actions.playNext')}>
|
||||
<RiPlayList2Fill />
|
||||
</Button>
|
||||
<Button onClick={() => dispatch(addTracks(data, ids))} label={translate('resources.album.actions.addToQueue')}>
|
||||
<RiPlayListAddFill />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
card: { marginTop: theme.spacing(2) },
|
||||
header: { display: 'flex', gap: theme.spacing(2), marginBottom: theme.spacing(3) },
|
||||
playableRow: { cursor: 'pointer', '&:hover': { backgroundColor: theme.palette.action.hover } },
|
||||
avatar: {
|
||||
width: 192,
|
||||
height: 192,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme.palette.grey[300],
|
||||
borderRadius: 4,
|
||||
flexShrink: 0,
|
||||
},
|
||||
meta: { flex: 1 },
|
||||
description: { marginTop: theme.spacing(1), color: theme.palette.text.secondary },
|
||||
tableWrapper: { marginTop: theme.spacing(2), overflowX: 'auto' },
|
||||
}))
|
||||
|
||||
const buildSrcSet = (images) => {
|
||||
if (!images || images.length === 0) return undefined
|
||||
return images.map((img) => `${img.url} ${img.width}w`).join(', ')
|
||||
}
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return ''
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
|
||||
const PodcastShow = (props) => {
|
||||
const classes = useStyles()
|
||||
const translate = useTranslate()
|
||||
const dispatch = useDispatch()
|
||||
const { record } = useShowController(props)
|
||||
const [episodes, setEpisodes] = useState([])
|
||||
|
||||
// Incremented on every load and on channel change; responses from a
|
||||
// superseded request are ignored so they can't overwrite the current channel.
|
||||
const requestGen = useRef(0)
|
||||
|
||||
const loadEpisodes = () => {
|
||||
if (!record?.id) return
|
||||
const gen = ++requestGen.current
|
||||
subsonic
|
||||
.getPodcasts(record.id, true)
|
||||
.then((res) => {
|
||||
if (gen !== requestGen.current) return
|
||||
const channels = res?.json?.['subsonic-response']?.podcasts?.channel || []
|
||||
const ch = channels.find((c) => c.id === record.id)
|
||||
setEpisodes(ch?.episode || [])
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setEpisodes([]) // don't show the previous channel's episodes
|
||||
loadEpisodes()
|
||||
return () => {
|
||||
requestGen.current++
|
||||
}
|
||||
}, [record?.id])
|
||||
|
||||
// Stay subscribed to SSE progress while the view is mounted: a download
|
||||
// started from here may not be flagged 'downloading' yet when we refresh.
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
const { episodeId, downloadedBytes, size, duration, status } = e.detail
|
||||
if (status === 'completed' || status === 'error') {
|
||||
// Reload to get updated streamId and full episode data
|
||||
loadEpisodes()
|
||||
return
|
||||
}
|
||||
setEpisodes((prev) =>
|
||||
prev.map((ep) =>
|
||||
ep.id === episodeId
|
||||
? { ...ep, downloadedBytes, size, ...(duration ? { duration } : {}) }
|
||||
: ep,
|
||||
),
|
||||
)
|
||||
}
|
||||
window.addEventListener('podcastEpisodeProgress', handler)
|
||||
return () => window.removeEventListener('podcastEpisodeProgress', handler)
|
||||
}, [record?.id])
|
||||
|
||||
if (!record) return null
|
||||
|
||||
return (
|
||||
<Card className={classes.card}>
|
||||
<Title subTitle={record.title} />
|
||||
<CardContent>
|
||||
<div className={classes.header}>
|
||||
<div className={classes.avatar}>
|
||||
{record.imageUrl ? (
|
||||
<img
|
||||
src={record.imageUrl}
|
||||
srcSet={buildSrcSet(record.images)}
|
||||
sizes="192px"
|
||||
alt={record.title}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<MicIcon style={{ fontSize: 40, color: '#888' }} />
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.meta}>
|
||||
<Typography variant="h5">{record.title}</Typography>
|
||||
<Link href={record.url} target="_blank" rel="noopener noreferrer" variant="body2">
|
||||
{record.url}
|
||||
</Link>
|
||||
{record.description && (
|
||||
<Typography variant="body2" className={classes.description}>
|
||||
{record.description}
|
||||
</Typography>
|
||||
)}
|
||||
{record.status === 'error' && (
|
||||
<StatusBadge status="error" errorMessage={record.errorMessage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EpisodePlayButtons episodes={episodes} channelTitle={record.title} />
|
||||
|
||||
<div className={classes.tableWrapper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{translate('resources.podcast.fields.title')}</TableCell>
|
||||
<TableCell>{translate('resources.podcast.fields.publishDate')}</TableCell>
|
||||
<TableCell>{translate('resources.podcast.fields.duration')}</TableCell>
|
||||
<TableCell>{translate('resources.podcast.fields.status')}</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{episodes.map((ep) => (
|
||||
<TableRow
|
||||
key={ep.id}
|
||||
className={ep.status === 'completed' ? classes.playableRow : undefined}
|
||||
onClick={() => ep.status === 'completed' && dispatch(setTrack(songFromEpisode(ep, record.title)))}
|
||||
>
|
||||
<TableCell>{ep.title}</TableCell>
|
||||
<TableCell>
|
||||
{ep.publishDate ? new Date(ep.publishDate).toLocaleDateString() : ''}
|
||||
</TableCell>
|
||||
<TableCell>{formatDuration(ep.duration)}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={ep.status} errorMessage={ep.errorMessage} downloadedBytes={ep.downloadedBytes} size={ep.size} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<EpisodeActions
|
||||
episode={ep}
|
||||
onRefresh={loadEpisodes}
|
||||
channelTitle={record.title}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default PodcastShow
|
||||
48
ui/src/podcast/StatusBadge.jsx
Normal file
48
ui/src/podcast/StatusBadge.jsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import React from 'react'
|
||||
import { Chip, CircularProgress, Tooltip, Typography, Box, makeStyles } from '@material-ui/core'
|
||||
import { useTranslate } from 'react-admin'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
completed: { backgroundColor: theme.palette.success?.main || '#4caf50', color: '#fff' },
|
||||
error: { backgroundColor: theme.palette.error.main, color: '#fff', cursor: 'default' },
|
||||
new: {},
|
||||
skipped: { backgroundColor: theme.palette.warning?.main || '#ff9800', color: '#fff' },
|
||||
}))
|
||||
|
||||
const StatusBadge = ({ status, errorMessage, downloadedBytes, size }) => {
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
|
||||
if (!status || status === 'deleted') return null
|
||||
|
||||
const label = translate(`resources.podcast.status.${status}`, { _: status })
|
||||
|
||||
if (status === 'downloading') {
|
||||
const pct = size > 0 && downloadedBytes > 0 ? Math.round((downloadedBytes / size) * 100) : null
|
||||
return (
|
||||
<Box display="flex" alignItems="center" style={{ gap: 6 }}>
|
||||
<CircularProgress size={14} />
|
||||
<Typography variant="caption">{label}</Typography>
|
||||
{pct !== null && <Typography variant="caption">{`${pct}%`}</Typography>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error' && errorMessage) {
|
||||
return (
|
||||
<Tooltip title={errorMessage}>
|
||||
<Chip className={classes.error} label={label} size="small" />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
className={classes[status] || classes.new}
|
||||
label={label}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusBadge
|
||||
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