fix(podcast): refresh Podping channels and isolate podcast library from scans

- Stop skipping channels with usesPodping during RefreshChannels: no Podping
  listener exists, so those channels would never receive new episodes.
  UsesPodping is still parsed, stored and exposed as metadata.
- Root the podcast library at DataFolder/podcasts (episode paths are now
  relative to it) instead of DataFolder, so the scanner can't import
  unrelated audio files under DataFolder.
- Drop an empty .ndignore in the podcast root so regular scans skip it;
  episodes are registered as MediaFiles by the podcast service itself.
- Create the library with DefaultNewUsers and assign it to existing
  non-admin users (Put only auto-assigns admins).
This commit is contained in:
Jiho Andrew Lee 2026-09-30 15:50:41 +09:00
commit c6774f45e8
No known key found for this signature in database
GPG key ID: 399C495F019E884F
2 changed files with 79 additions and 16 deletions

View file

@ -15,6 +15,7 @@ import (
"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"
@ -43,9 +44,19 @@ func NewPodcastService(rootCtx context.Context, ds model.DataStore, ff ffmpeg.FF
return &podcastService{rootCtx: rootCtx, ds: ds, ff: ff, broker: broker}
}
// podcastLibraryID returns the ID of the podcast virtual library,
// creating it if it doesn't exist. The library root is DataFolder so that
// MediaFile paths stored as "podcasts/{ch}/{ep}.mp3" resolve correctly via AbsolutePath().
// 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 {
@ -56,16 +67,51 @@ func (s *podcastService) podcastLibraryID(ctx context.Context) (int, error) {
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: conf.Server.DataFolder.String(),
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 {
@ -191,9 +237,6 @@ func (s *podcastService) RefreshChannels(ctx context.Context) error {
}
for _, ch := range channels {
if ch.UsesPodping {
continue // skip — this channel uses Podping for updates
}
if err := s.refreshChannel(ctx, ch); err != nil {
log.Warn(ctx, "Failed to refresh podcast channel", "channel", ch.Title, err)
}
@ -298,7 +341,7 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod
if suffix == "" {
suffix = "mp3"
}
dir := filepath.Join(conf.Server.DataFolder.String(), "podcasts", ep.ChannelID)
dir := filepath.Join(podcastsDir(), ep.ChannelID)
if err := os.MkdirAll(dir, 0755); err != nil {
s.setEpisodeError(ctx, ep, err)
return
@ -355,12 +398,12 @@ func (s *podcastService) doDownload(ctx context.Context, ep *model.PodcastEpisod
s.writeID3Tags(ctx, dest, suffix, ep.Title, ch.Title)
// Register as a MediaFile so /rest/stream works with the standard media file path.
// Use a podcast virtual library whose root is DataFolder; store relative path.
// 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, conf.Server.DataFolder.String()+string(filepath.Separator))
relPath := strings.TrimPrefix(dest, podcastsDir()+string(filepath.Separator))
now := time.Now()
tags := model.Tags{}
tags.Add("genre", "Podcast")

View file

@ -159,6 +159,26 @@ var _ = Describe("PodcastService", func() {
}, "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("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")
@ -495,7 +515,7 @@ var _ = Describe("PodcastService", func() {
})
})
Describe("RefreshChannels — Tier 3 podping skip", func() {
Describe("RefreshChannels — Tier 3 podping", func() {
var podrollRepo *tests.MockPodcastPodrollRepo
var liveItemRepo *tests.MockPodcastLiveItemRepo
@ -506,8 +526,7 @@ var _ = Describe("PodcastService", func() {
ds.MockedPodcastLiveItem = liveItemRepo
})
It("skips channels with UsesPodping=true during refresh", func() {
// UsesPodping channel points to a server that would add episodes.
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",
@ -516,8 +535,9 @@ var _ = Describe("PodcastService", func() {
initialEpisodeCount := len(episodeRepo.Data)
Expect(svc.RefreshChannels(ctx)).To(Succeed())
// No new episodes should be added because the only channel uses podping.
Expect(episodeRepo.Data).To(HaveLen(initialEpisodeCount))
// 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() {