mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-10 11:27:11 +02:00
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:
parent
4bebfad0ed
commit
c6774f45e8
2 changed files with 79 additions and 16 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue