diff options
Diffstat (limited to 'internal/service/podcast.go')
| -rw-r--r-- | internal/service/podcast.go | 194 |
1 files changed, 161 insertions, 33 deletions
diff --git a/internal/service/podcast.go b/internal/service/podcast.go index 4425bfc..ad5e22b 100644 --- a/internal/service/podcast.go +++ b/internal/service/podcast.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "io" "log/slog" @@ -28,6 +29,7 @@ import ( // PodcastSubService manages podcast feed subscriptions. type PodcastSubService interface { SubscribeFeed(ctx context.Context, feedURL, setName string, userID int64) (*model.PodcastFeed, error) + ListFeeds(ctx context.Context, userID int64) ([]model.PodcastFeed, error) EditFeed(ctx context.Context, feedID int64, feedURL string, checkInterval int, userID int64) error UnsubscribeFeed(ctx context.Context, feedID int64, userID int64) error } @@ -35,6 +37,7 @@ type PodcastSubService interface { // PodcastEpisodeService manages episode browsing and downloading. type PodcastEpisodeService interface { SubscribeFeed(ctx context.Context, feedURL, setName string, userID int64) (*model.PodcastFeed, error) + ListFeeds(ctx context.Context, userID int64) ([]model.PodcastFeed, error) EditFeed(ctx context.Context, feedID int64, feedURL string, checkInterval int, userID int64) error UnsubscribeFeed(ctx context.Context, feedID int64, userID int64) error ListEpisodes(ctx context.Context, setID, userID int64, limit, offset int) ([]model.PodcastEpisodeWithStatus, error) @@ -122,6 +125,8 @@ func NewPodcastServiceWithLogger(store PodcastServiceStore, clk clock.Clock, med // Subscription // ------------------------------------------------------------------ +const podcastSetName = "podcast" + func (s *podcastService) SubscribeFeed(ctx context.Context, feedURL, setName string, userID int64) (*model.PodcastFeed, error) { if err := s.verifyAdmin(ctx, userID); err != nil { return nil, err @@ -132,29 +137,119 @@ func (s *podcastService) SubscribeFeed(ctx context.Context, feedURL, setName str return nil, fmt.Errorf("%w: %v", ErrInvalidFeed, err) } - safeName, setPath := s.resolveSetPath(setName, parsed.Title) - - set, err := s.createPodcastSet(ctx, parsed.Title, safeName, setPath, userID) + set, err := s.ensurePodcastSet(ctx, userID) if err != nil { return nil, err } - feed, err := s.createPodcastFeed(ctx, parsed, set.ID, feedURL, setPath) + feed, err := s.findExistingFeed(ctx, set.ID, parsed.Title, feedURL) if err != nil { - s.rollbackSet(ctx, set.ID, setPath) return nil, err } + if feed == nil { + feed, err = s.createPodcastFeed(ctx, parsed, set.ID, feedURL) + if err != nil { + return nil, err + } + } else { + feed.FeedURL = feedURL + feed.Title = parsed.Title + feed.Description = parsed.Description + feed.ImageURL = parsed.ImageURL + feed.CheckIntervalMinutes = s.checkInterval + } - s.downloadCover(s.httpClient, parsed.ImageURL, setPath) + folderPath := filepath.Join(s.mediaRoot, set.RootPath, podcastFolderName("", parsed.Title, feed.ID)) + if err := os.MkdirAll(folderPath, 0o755); err != nil { + return nil, fmt.Errorf("mkdir podcast folder: %w", err) + } + s.downloadCover(s.httpClient, parsed.ImageURL, folderPath) s.insertPodcastEpisodes(ctx, parsed, feed.ID) now := s.clock.Now() feed.LastCheckedAt = &now - _ = s.store.UpdateFeed(ctx, feed) + if err := s.store.UpdateFeed(ctx, feed); err != nil { + return nil, fmt.Errorf("update feed: %w", err) + } return feed, nil } +func (s *podcastService) ensurePodcastSet(ctx context.Context, userID int64) (*model.Set, error) { + set, err := s.findPodcastSet(ctx) + if err != nil { + return nil, err + } + setPath := filepath.Join(s.mediaRoot, podcastSetName) + if set == nil { + set, err = s.createPodcastSet(ctx, userID) + if err != nil { + return nil, err + } + } + if err := os.MkdirAll(setPath, 0o755); err != nil { + return nil, fmt.Errorf("mkdir podcast set path: %w", err) + } + if err := s.store.GrantPermission(ctx, &model.SetPermission{ + SetID: set.ID, + UserID: userID, + Role: model.RoleOwner, + CreatedAt: s.clock.Now(), + }); err != nil { + return nil, fmt.Errorf("grant permission: %w", err) + } + return set, nil +} + +func (s *podcastService) findPodcastSet(ctx context.Context) (*model.Set, error) { + sets, err := s.store.ListSets(ctx) + if err != nil { + return nil, err + } + for _, set := range sets { + if set.IsPodcast && (set.RootPath == podcastSetName || strings.EqualFold(set.Name, podcastSetName)) { + if set.Name != podcastSetName || set.RootPath != podcastSetName { + set.Name = podcastSetName + set.RootPath = podcastSetName + if err := s.store.UpdateSet(ctx, &set); err != nil { + return nil, fmt.Errorf("update podcast set: %w", err) + } + } + return &set, nil + } + } + return nil, nil +} + +func (s *podcastService) findExistingFeed(ctx context.Context, setID int64, title, feedURL string) (*model.PodcastFeed, error) { + feeds, err := s.store.ListFeedsBySetID(ctx, setID) + if err != nil { + return nil, fmt.Errorf("list feeds by set: %w", err) + } + for _, feed := range feeds { + if feed.FeedURL == feedURL { + return &feed, nil + } + } + for _, feed := range feeds { + if strings.EqualFold(feed.Title, title) { + return &feed, nil + } + } + return nil, nil +} + +func podcastFolderName(requestedName, title string, feedID int64) string { + name := sanitizeSetName(requestedName) + if name == "" { + name = sanitizeSetName(title) + } + if name == "" { + name = fmt.Sprintf("feed-%d", feedID) + } + return name +} + func (s *podcastService) verifyAdmin(ctx context.Context, userID int64) error { user, err := s.store.GetUserByID(ctx, userID) if err != nil { @@ -166,26 +261,16 @@ func (s *podcastService) verifyAdmin(ctx context.Context, userID int64) error { return nil } -func (s *podcastService) resolveSetPath(setName, fallbackTitle string) (string, string) { - safeName := sanitizeSetName(setName) - if safeName == "" { - safeName = sanitizeSetName(fallbackTitle) - } - if safeName == "" { - safeName = "podcast" - } - return safeName, filepath.Join(s.mediaRoot, safeName) -} - -func (s *podcastService) createPodcastSet(ctx context.Context, title, safeName, setPath string, userID int64) (*model.Set, error) { +func (s *podcastService) createPodcastSet(ctx context.Context, userID int64) (*model.Set, error) { + setPath := filepath.Join(s.mediaRoot, podcastSetName) // Create the directory first so we can clean it up easily on DB errors. if err := os.MkdirAll(setPath, 0o755); err != nil { return nil, fmt.Errorf("mkdir set path: %w", err) } set := &model.Set{ - Name: title, - RootPath: safeName, + Name: podcastSetName, + RootPath: podcastSetName, IsPodcast: true, CreatedAt: s.clock.Now(), } @@ -211,7 +296,7 @@ func (s *podcastService) rollbackSet(ctx context.Context, setID int64, setPath s os.RemoveAll(setPath) } -func (s *podcastService) createPodcastFeed(ctx context.Context, parsed *podcast.ParsedFeed, setID int64, feedURL, setPath string) (*model.PodcastFeed, error) { +func (s *podcastService) createPodcastFeed(ctx context.Context, parsed *podcast.ParsedFeed, setID int64, feedURL string) (*model.PodcastFeed, error) { feed := &model.PodcastFeed{ SetID: setID, FeedURL: feedURL, @@ -229,6 +314,25 @@ func (s *podcastService) createPodcastFeed(ctx context.Context, parsed *podcast. return feed, nil } +func (s *podcastService) ListFeeds(ctx context.Context, userID int64) ([]model.PodcastFeed, error) { + feeds, err := s.store.ListFeeds(ctx) + if err != nil { + return nil, fmt.Errorf("list feeds: %w", err) + } + + visible := make([]model.PodcastFeed, 0, len(feeds)) + for _, feed := range feeds { + if err := s.helper.checkSetPermission(ctx, feed.SetID, userID, ""); err != nil { + if errors.Is(err, ErrForbidden) { + continue + } + return nil, err + } + visible = append(visible, feed) + } + return visible, nil +} + func (s *podcastService) insertPodcastEpisodes(ctx context.Context, parsed *podcast.ParsedFeed, feedID int64) { for _, ep := range parsed.Episodes { episode := &model.PodcastEpisode{ @@ -281,20 +385,19 @@ func (s *podcastService) UnsubscribeFeed(ctx context.Context, feedID int64, user return err } - // Delete the set row; ON DELETE CASCADE removes feed + episodes. set, err := s.store.GetSetByID(ctx, feed.SetID) if err != nil { return fmt.Errorf("get set: %w", err) } - if err := s.store.DeleteSet(ctx, feed.SetID); err != nil { - return fmt.Errorf("delete set: %w", err) + if err := s.store.DeleteFeed(ctx, feedID); err != nil { + return fmt.Errorf("delete feed: %w", err) } // Optionally delete the folder contents on disk. if set != nil { - setPath := filepath.Join(s.mediaRoot, set.RootPath) - _ = os.RemoveAll(setPath) + folder := podcastFolderName("", feed.Title, feed.ID) + _ = os.RemoveAll(filepath.Join(s.mediaRoot, set.RootPath, folder)) } return nil @@ -309,15 +412,30 @@ func (s *podcastService) ListEpisodes(ctx context.Context, setID, userID int64, return nil, err } - feed, err := s.store.GetFeedBySetID(ctx, setID) + feeds, err := s.store.ListFeedsBySetID(ctx, setID) if err != nil { - return nil, fmt.Errorf("get feed by set: %w", err) + return nil, fmt.Errorf("list feeds by set: %w", err) } - if feed == nil { + if len(feeds) == 0 { return nil, ErrNotFound } - return s.store.ListEpisodesWithStatus(ctx, userID, feed.ID, limit, offset) + all := make([]model.PodcastEpisodeWithStatus, 0) + for _, feed := range feeds { + episodes, err := s.store.ListEpisodesWithStatus(ctx, userID, feed.ID, limit, 0) + if err != nil { + return nil, err + } + all = append(all, episodes...) + } + if offset >= len(all) { + return []model.PodcastEpisodeWithStatus{}, nil + } + end := len(all) + if limit > 0 && offset+limit < end { + end = offset + limit + } + return all[offset:end], nil } func (s *podcastService) DownloadEpisode(ctx context.Context, episodeID, userID int64) (*model.Media, error) { @@ -376,7 +494,8 @@ func (s *podcastService) resolveEpisodeAndSet(ctx context.Context, episodeID, us return nil, nil, "", ErrNotFound } - setPath := filepath.Join(s.mediaRoot, set.RootPath) + feedFolder := podcastFolderName("", feed.Title, feed.ID) + setPath := filepath.Join(s.mediaRoot, set.RootPath, feedFolder) path := buildEpisodePath(setPath, episode, s.clock.Now()) return episode, set, path, nil } @@ -415,6 +534,9 @@ func (s *podcastService) downloadEnclosure(ctx context.Context, episode *model.P if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("download episode: status %d", resp.StatusCode) } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return 0, fmt.Errorf("mkdir episode folder: %w", err) + } f, err := os.Create(path) if err != nil { @@ -438,9 +560,15 @@ func (s *podcastService) downloadEnclosure(ctx context.Context, episode *model.P // persistDownloadedEpisode records the downloaded file in the database, // probes it, and returns a cleanup function to undo work on failure. func (s *podcastService) persistDownloadedEpisode(ctx context.Context, episode *model.PodcastEpisode, set *model.Set, path string, n int64) (*model.Media, func(), error) { + relPath, err := filepath.Rel(filepath.Join(s.mediaRoot, set.RootPath), path) + if err != nil { + os.Remove(path) + return nil, nil, fmt.Errorf("relative episode path: %w", err) + } + relPath = filepath.ToSlash(relPath) media := &model.Media{ SetID: set.ID, - RelPath: filepath.Base(path), + RelPath: relPath, FileName: filepath.Base(path), AbsPath: path, Type: mediatype.TypeForExt(filepath.Base(path)), |
