summaryrefslogtreecommitdiff
path: root/internal/service
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-09 11:11:49 +0300
committerPaul Buetow <paul@buetow.org>2026-05-09 11:11:49 +0300
commit8c32cd117abe79f4ee6cff3a950ffc35c95faeff (patch)
tree5b63a4789351da4f2d9c1cd2e7007f67d43b3371 /internal/service
parent59c8870061465142306e7a6e5d3fcb344686cfe9 (diff)
Refine media browsing and set covers
Diffstat (limited to 'internal/service')
-rw-r--r--internal/service/browse.go66
-rw-r--r--internal/service/browse_test.go35
-rw-r--r--internal/service/media_test.go45
-rw-r--r--internal/service/mock.go9
-rw-r--r--internal/service/podcast.go194
-rw-r--r--internal/service/podcast_test.go45
-rw-r--r--internal/service/service.go2
-rw-r--r--internal/service/tag.go8
-rw-r--r--internal/service/write.go83
9 files changed, 397 insertions, 90 deletions
diff --git a/internal/service/browse.go b/internal/service/browse.go
index 1e7949a..2641693 100644
--- a/internal/service/browse.go
+++ b/internal/service/browse.go
@@ -236,6 +236,29 @@ type folderContent struct {
subfolders map[string]struct{}
}
+func isFolderArtworkMedia(media model.Media) bool {
+ if media.Type != model.MediaTypeImage {
+ return false
+ }
+ switch strings.ToLower(filepath.Base(media.RelPath)) {
+ case "cover.jpg", "cover.jpeg", "cover.png", "cover.gif", "folder.jpg", "folder.jpeg", "folder.png", "folder.gif":
+ return true
+ default:
+ return false
+ }
+}
+
+func folderContentFiles(files []model.Media) []model.Media {
+ content := make([]model.Media, 0, len(files))
+ for _, file := range files {
+ if isFolderArtworkMedia(file) {
+ continue
+ }
+ content = append(content, file)
+ }
+ return content
+}
+
// buildFolderMap walks media and groups entries by the first folder component under parent.
func buildFolderMap(media []model.Media, parent string) (map[string]*folderContent, []model.Media) {
prefix := prefixForParent(parent)
@@ -284,9 +307,13 @@ func folderHasCover(mediaRoot, setRootPath, parent, name string, media []model.M
func buildFolders(folderMap map[string]*folderContent, media []model.Media, items []model.Media, mediaRoot, setRootPath, parent string) ([]BrowseFolder, []model.Media) {
var folders []BrowseFolder
for name, fc := range folderMap {
- total := len(fc.files) + len(fc.subfolders)
- if total == 1 && len(fc.files) == 1 {
- items = append(items, fc.files[0])
+ files := folderContentFiles(fc.files)
+ total := len(files) + len(fc.subfolders)
+ if total == 0 {
+ continue
+ }
+ if total == 1 && len(files) == 1 {
+ items = append(items, files[0])
} else {
hasCover := folderHasCover(mediaRoot, setRootPath, parent, name, media)
folders = append(folders, BrowseFolder{Name: name, HasCover: hasCover})
@@ -326,11 +353,31 @@ func (s *browseService) BrowseSet(ctx context.Context, setID, userID int64, pare
// For podcast sets, also load episodes (undownloaded items) into the grid.
if set.IsPodcast {
- feed, err := s.store.GetFeedBySetID(ctx, setID)
- if err == nil && feed != nil {
- episodes, err := s.store.ListEpisodesWithStatus(ctx, userID, feed.ID, 1000, 0)
- if err == nil {
- result.Episodes = undownloadedEpisodes(episodes)
+ feeds, err := s.store.ListFeedsBySetID(ctx, setID)
+ if err == nil {
+ if parent == "" {
+ knownFolders := make(map[string]struct{}, len(result.Folders))
+ for _, folder := range result.Folders {
+ knownFolders[folder.Name] = struct{}{}
+ }
+ for _, feed := range feeds {
+ name := podcastFolderName("", feed.Title, feed.ID)
+ if _, ok := knownFolders[name]; ok {
+ continue
+ }
+ result.Folders = append(result.Folders, BrowseFolder{Name: name, HasCover: folderHasCover(s.mediaRoot, set.RootPath, "", name, media)})
+ }
+ sort.Slice(result.Folders, func(i, j int) bool { return result.Folders[i].Name < result.Folders[j].Name })
+ } else {
+ for _, feed := range feeds {
+ if parent != podcastFolderName("", feed.Title, feed.ID) {
+ continue
+ }
+ episodes, err := s.store.ListEpisodesWithStatus(ctx, userID, feed.ID, 1000, 0)
+ if err == nil {
+ result.Episodes = append(result.Episodes, undownloadedEpisodes(episodes)...)
+ }
+ }
}
}
}
@@ -414,9 +461,6 @@ func randomFolderThumbnail(media []model.Media, folder string) string {
if prefix != "" && !strings.HasPrefix(rel, prefix) {
continue
}
- if prefix == "" && strings.Contains(rel, "/") {
- continue
- }
candidates = append(candidates, m.ThumbnailPath)
}
if len(candidates) == 0 {
diff --git a/internal/service/browse_test.go b/internal/service/browse_test.go
index 5f436c4..8a5c7c2 100644
--- a/internal/service/browse_test.go
+++ b/internal/service/browse_test.go
@@ -39,6 +39,27 @@ func TestBrowseService_BrowseSet(t *testing.T) {
wantFolders: 0,
},
{
+ name: "single audio folder with cover is flattened",
+ set: &model.Set{ID: 1, RootPath: "audiobooks"},
+ media: []model.Media{
+ {ID: 1, SetID: 1, RelPath: "Book/Book.m4b", FileName: "Book.m4b", Type: model.MediaTypeAudio},
+ {ID: 2, SetID: 1, RelPath: "Book/cover.jpg", FileName: "cover.jpg", Type: model.MediaTypeImage},
+ },
+ wantMedia: 1,
+ wantFolders: 0,
+ },
+ {
+ name: "audio folder with extra content is still enterable",
+ set: &model.Set{ID: 1, RootPath: "audiobooks"},
+ media: []model.Media{
+ {ID: 1, SetID: 1, RelPath: "Book/Book.m4b", FileName: "Book.m4b", Type: model.MediaTypeAudio},
+ {ID: 2, SetID: 1, RelPath: "Book/bonus.mp3", FileName: "bonus.mp3", Type: model.MediaTypeAudio},
+ {ID: 3, SetID: 1, RelPath: "Book/cover.jpg", FileName: "cover.jpg", Type: model.MediaTypeImage},
+ },
+ wantMedia: 0,
+ wantFolders: 1,
+ },
+ {
name: "list media error",
set: &model.Set{ID: 1, RootPath: "music"},
mediaErr: errors.New("boom"),
@@ -102,7 +123,7 @@ func TestBrowseService_BrowseSet_PodcastEpisodes(t *testing.T) {
store := &repository.MockStore{
MediaRepo: repository.MockMediaRepo{
ListMediaFunc: func(ctx context.Context, filter repository.MediaFilter) ([]model.Media, error) {
- return []model.Media{{ID: 10, SetID: 1, RelPath: "downloaded.mp3", FileName: "downloaded.mp3"}}, nil
+ return []model.Media{{ID: 10, SetID: 1, RelPath: "Test Feed/downloaded.mp3", FileName: "downloaded.mp3"}}, nil
},
},
SetRepo: repository.MockSetRepo{
@@ -121,8 +142,8 @@ func TestBrowseService_BrowseSet_PodcastEpisodes(t *testing.T) {
},
},
PodcastRepo: repository.MockPodcastRepo{
- GetFeedBySetIDFunc: func(ctx context.Context, setID int64) (*model.PodcastFeed, error) {
- return &model.PodcastFeed{ID: 99, SetID: setID}, nil
+ ListFeedsBySetIDFunc: func(ctx context.Context, setID int64) ([]model.PodcastFeed, error) {
+ return []model.PodcastFeed{{ID: 99, SetID: setID, Title: "Test Feed"}}, nil
},
ListEpisodesWithStatusFunc: func(ctx context.Context, userID, feedID int64, limit, offset int) ([]model.PodcastEpisodeWithStatus, error) {
return []model.PodcastEpisodeWithStatus{
@@ -137,6 +158,14 @@ func TestBrowseService_BrowseSet_PodcastEpisodes(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
+ if len(res.Folders) != 1 || res.Folders[0].Name != "Test Feed" {
+ t.Fatalf("expected podcast feed folder, got %+v", res.Folders)
+ }
+
+ res, err = svc.BrowseSet(ctx, 1, 1, "Test Feed")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
if len(res.Media) != 1 {
t.Fatalf("expected downloaded media card, got %d media items", len(res.Media))
}
diff --git a/internal/service/media_test.go b/internal/service/media_test.go
index 3d550ce..e371a1b 100644
--- a/internal/service/media_test.go
+++ b/internal/service/media_test.go
@@ -1871,6 +1871,49 @@ func TestMediaService_RegenerateSetCover(t *testing.T) {
}
})
+ t.Run("set cover can use nested audiobook artwork", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ coverDir := filepath.Join(tmpDir, "audiobooks", "Book")
+ if err := os.MkdirAll(coverDir, 0o755); err != nil {
+ t.Fatalf("mkdir cover dir: %v", err)
+ }
+ coverPath := filepath.Join(coverDir, "cover.jpg")
+ if err := os.WriteFile(coverPath, []byte("fake cover"), 0o644); err != nil {
+ t.Fatalf("write cover: %v", err)
+ }
+ set := &model.Set{ID: 1, RootPath: "audiobooks"}
+ media := []model.Media{
+ {ID: 1, SetID: 1, RelPath: "Book/book.m4b", AbsPath: filepath.Join(coverDir, "book.m4b"), Type: model.MediaTypeAudio},
+ {ID: 2, SetID: 1, RelPath: "Book/cover.jpg", AbsPath: coverPath, Type: model.MediaTypeImage},
+ }
+ store := makeStore(1, media, set)
+ var gotInput string
+ var gotOutput string
+ thumbGen := &mockThumbGenerator{GenerateFunc: func(ctx context.Context, inputPath, outputPath string, duration float64) error {
+ gotInput = inputPath
+ gotOutput = outputPath
+ return os.WriteFile(outputPath, []byte("generated cover"), 0o644)
+ }}
+ prober := &mockProber{ProbeFunc: func(ctx context.Context, path string) (*model.Metadata, error) {
+ t.Fatalf("image artwork should not require probing, probed %s", path)
+ return nil, nil
+ }}
+ svc := NewMediaService(store, newMockClock(), tmpDir, thumbGen, prober)
+ if err := svc.RegenerateSetCover(ctx, 1, "", 1); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if gotInput != coverPath {
+ t.Fatalf("expected input %q, got %q", coverPath, gotInput)
+ }
+ wantOutput := filepath.Join(tmpDir, "audiobooks", ".cover.jpg")
+ if gotOutput != wantOutput {
+ t.Fatalf("expected output %q, got %q", wantOutput, gotOutput)
+ }
+ if _, err := os.Stat(wantOutput); err != nil {
+ t.Fatalf("expected generated cover: %v", err)
+ }
+ })
+
t.Run("viewer cannot regenerate cover", func(t *testing.T) {
set := &model.Set{ID: 1, RootPath: "music", Permissions: []model.SetPermission{{SetID: 1, UserID: 2, Role: model.RoleViewer}}}
store := makeStore(1, nil, set)
@@ -1890,7 +1933,7 @@ func TestMediaService_RegenerateSetCover(t *testing.T) {
}
})
- t.Run("no video files", func(t *testing.T) {
+ t.Run("no usable cover media", func(t *testing.T) {
set := &model.Set{ID: 1, RootPath: "music"}
media := []model.Media{{ID: 1, SetID: 1, AbsPath: "/tmp/song.mp3", Type: model.MediaTypeAudio}}
store := makeStore(1, media, set)
diff --git a/internal/service/mock.go b/internal/service/mock.go
index 8e6780c..5b3c96b 100644
--- a/internal/service/mock.go
+++ b/internal/service/mock.go
@@ -33,6 +33,7 @@ type MockMediaService struct {
BrowseSetFunc func(ctx context.Context, setID, userID int64, parent string) (*BrowseResult, error)
GetSetCoverFunc func(ctx context.Context, setID int64, folder string, userID int64) (*FileResult, error)
ToggleFavoriteFunc func(ctx context.Context, userID, mediaID int64) (bool, error)
+ ListTagsFunc func(ctx context.Context, userID int64) ([]model.Tag, error)
AssignTagFunc func(ctx context.Context, mediaID, userID int64, tagName string) error
RemoveTagFunc func(ctx context.Context, mediaID, userID int64, tagName string) error
SoftDeleteMediaFunc func(ctx context.Context, mediaID, userID int64) error
@@ -139,6 +140,14 @@ func (m *MockMediaService) ToggleFavorite(ctx context.Context, userID, mediaID i
return false, nil
}
+// ListTags calls ListTagsFunc or returns nil.
+func (m *MockMediaService) ListTags(ctx context.Context, userID int64) ([]model.Tag, error) {
+ if m.ListTagsFunc != nil {
+ return m.ListTagsFunc(ctx, userID)
+ }
+ return nil, nil
+}
+
// AssignTag calls AssignTagFunc or returns nil.
func (m *MockMediaService) AssignTag(ctx context.Context, mediaID, userID int64, tagName string) error {
if m.AssignTagFunc != nil {
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)),
diff --git a/internal/service/podcast_test.go b/internal/service/podcast_test.go
index 0d92248..77b592c 100644
--- a/internal/service/podcast_test.go
+++ b/internal/service/podcast_test.go
@@ -64,7 +64,13 @@ func TestPodcastService_SubscribeFeed_Ok(t *testing.T) {
}
var setID int64
store.SetRepo = repository.MockSetRepo{
+ ListSetsFunc: func(ctx context.Context) ([]model.Set, error) {
+ return nil, nil
+ },
CreateSetFunc: func(ctx context.Context, set *model.Set) (int64, error) {
+ if set.Name != "podcast" || set.RootPath != "podcast" {
+ t.Fatalf("expected fixed podcast set, got name=%q root=%q", set.Name, set.RootPath)
+ }
setID++
return setID, nil
},
@@ -119,8 +125,12 @@ func TestPodcastService_SubscribeFeed_Ok(t *testing.T) {
}
setPath := filepath.Join(svc.mediaRoot, "my-podcast")
- if _, err := os.Stat(setPath); os.IsNotExist(err) {
- t.Error("expected set directory to exist on disk")
+ if _, err := os.Stat(setPath); !os.IsNotExist(err) {
+ t.Error("custom set directory should not be created")
+ }
+ feedPath := filepath.Join(svc.mediaRoot, "podcast", "Test Feed")
+ if _, err := os.Stat(feedPath); os.IsNotExist(err) {
+ t.Error("expected feed directory under podcast set to exist on disk")
}
}
@@ -230,7 +240,7 @@ func TestPodcastService_SubscribeFeed_CreateSetError(t *testing.T) {
if !errors.Is(err, boom) {
t.Fatalf("expected wrapped boom, got %v", err)
}
- setPath := filepath.Join(svc.mediaRoot, "name")
+ setPath := filepath.Join(svc.mediaRoot, "podcast")
if _, err := os.Stat(setPath); !os.IsNotExist(err) {
t.Error("expected set directory to be cleaned up")
}
@@ -274,7 +284,7 @@ func TestPodcastService_SubscribeFeed_GrantPermissionError(t *testing.T) {
if deletedSetID != 42 {
t.Fatalf("expected set rollback (delete %d), got %d", 42, deletedSetID)
}
- setPath := filepath.Join(svc.mediaRoot, "name")
+ setPath := filepath.Join(svc.mediaRoot, "podcast")
if _, err := os.Stat(setPath); !os.IsNotExist(err) {
t.Error("expected set directory to be cleaned up on permission error")
}
@@ -284,7 +294,6 @@ func TestPodcastService_SubscribeFeed_CreateFeedError(t *testing.T) {
ctx := context.Background()
svc, store := setupPodcastService(t)
boom := errors.New("boom")
- var deletedSetID int64
store.UserRepo = repository.MockUserRepo{
GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
return &model.User{ID: id, IsAdmin: true}, nil
@@ -294,10 +303,6 @@ func TestPodcastService_SubscribeFeed_CreateFeedError(t *testing.T) {
CreateSetFunc: func(ctx context.Context, set *model.Set) (int64, error) {
return 42, nil
},
- DeleteSetFunc: func(ctx context.Context, id int64) error {
- deletedSetID = id
- return nil
- },
}
store.SetPermissionRepo = repository.MockSetPermissionRepo{
GrantPermissionFunc: func(ctx context.Context, perm *model.SetPermission) error {
@@ -320,18 +325,13 @@ func TestPodcastService_SubscribeFeed_CreateFeedError(t *testing.T) {
if !errors.Is(err, boom) {
t.Fatalf("expected wrapped boom, got %v", err)
}
- if deletedSetID != 42 {
- t.Fatalf("expected set rollback (delete %d), got %d", 42, deletedSetID)
- }
- setPath := filepath.Join(svc.mediaRoot, "name")
- if _, err := os.Stat(setPath); !os.IsNotExist(err) {
- t.Error("expected set directory to be cleaned up on feed error")
+ setPath := filepath.Join(svc.mediaRoot, "podcast")
+ if _, err := os.Stat(setPath); os.IsNotExist(err) {
+ t.Error("expected podcast set directory to remain")
}
}
-func TestPodcastService_ResolveSetPath(t *testing.T) {
- svc, _ := setupPodcastService(t)
-
+func TestPodcastService_PodcastFolderName(t *testing.T) {
tests := []struct {
setName string
title string
@@ -339,17 +339,14 @@ func TestPodcastService_ResolveSetPath(t *testing.T) {
}{
{"my-podcast", "Some Title", "my-podcast"},
{"", "Some Title", "Some Title"},
- {"", "", "podcast"},
+ {"", "", "feed-7"},
{"../../etc", "", "------etc"},
}
for _, tt := range tests {
- name, path := svc.resolveSetPath(tt.setName, tt.title)
+ name := podcastFolderName(tt.setName, tt.title, 7)
if name != tt.want {
- t.Errorf("resolveSetPath(%q, %q) name = %q, want %q", tt.setName, tt.title, name, tt.want)
- }
- if path != filepath.Join(svc.mediaRoot, tt.want) {
- t.Errorf("resolveSetPath(%q, %q) path mismatch", tt.setName, tt.title)
+ t.Errorf("podcastFolderName(%q, %q) = %q, want %q", tt.setName, tt.title, name, tt.want)
}
}
}
diff --git a/internal/service/service.go b/internal/service/service.go
index de218dc..6a02d98 100644
--- a/internal/service/service.go
+++ b/internal/service/service.go
@@ -144,6 +144,8 @@ type MediaShareService interface {
// MediaTagService handles tagging of media items.
type MediaTagService interface {
+ // ListTags returns known tag names.
+ ListTags(ctx context.Context, userID int64) ([]model.Tag, error)
// AssignTag attaches a named tag to a media item.
AssignTag(ctx context.Context, mediaID, userID int64, tagName string) error
// RemoveTag detaches a named tag from a media item.
diff --git a/internal/service/tag.go b/internal/service/tag.go
index 4112489..d08c387 100644
--- a/internal/service/tag.go
+++ b/internal/service/tag.go
@@ -23,6 +23,14 @@ func NewTagService(store repository.TagServiceStore, helper *accessHelper) Media
}
}
+func (s *tagService) ListTags(ctx context.Context, userID int64) ([]model.Tag, error) {
+ tags, err := s.store.ListTags(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("list tags: %w", err)
+ }
+ return tags, nil
+}
+
func (s *tagService) AssignTag(ctx context.Context, mediaID, userID int64, tagName string) error {
if _, err := s.helper.verifyAccess(ctx, mediaID, userID); err != nil {
return err
diff --git a/internal/service/write.go b/internal/service/write.go
index b5cd181..b23355a 100644
--- a/internal/service/write.go
+++ b/internal/service/write.go
@@ -149,7 +149,10 @@ func (s *writeService) RegenerateSetCover(ctx context.Context, setID int64, fold
}
prefix := filepath.ToSlash(strings.Trim(folder, "/"))
- var candidates []model.Media
+ var artworkCandidates []model.Media
+ var videoCandidates []model.Media
+ var imageCandidates []model.Media
+ var thumbnailCandidates []model.Media
for _, m := range media {
if m.DeletedAt != nil {
continue
@@ -163,37 +166,81 @@ func (s *writeService) RegenerateSetCover(ctx context.Context, setID int64, fold
if strings.Contains(suffix, "/") {
continue
}
- } else if strings.Contains(rel, "/") {
- continue
}
- if m.Type == model.MediaTypeVideo {
- candidates = append(candidates, m)
+ switch {
+ case isFolderArtworkMedia(m):
+ artworkCandidates = append(artworkCandidates, m)
+ case m.Type == model.MediaTypeVideo:
+ videoCandidates = append(videoCandidates, m)
+ case m.Type == model.MediaTypeImage:
+ imageCandidates = append(imageCandidates, m)
+ case m.ThumbnailPath != "":
+ thumbnailCandidates = append(thumbnailCandidates, m)
}
}
- if len(candidates) == 0 {
- return errors.New("no video files available for cover")
- }
-
- candidate := candidates[0]
- if len(candidates) > 1 {
- candidate = candidates[mrand.Intn(len(candidates))]
- }
baseDir := filepath.Join(s.mediaRoot, filepath.FromSlash(set.RootPath))
if prefix != "" {
baseDir = filepath.Join(baseDir, filepath.FromSlash(prefix))
}
coverPath := filepath.Join(filepath.Clean(baseDir), ".cover.jpg")
- meta, err := s.prober.Probe(ctx, candidate.AbsPath)
+
+ switch {
+ case len(artworkCandidates) > 0:
+ candidate := randomMedia(artworkCandidates)
+ if err := s.thumbGen.Generate(ctx, candidate.AbsPath, coverPath, 0); err != nil {
+ return fmt.Errorf("generate cover: %w", err)
+ }
+ case len(videoCandidates) > 0:
+ candidate := randomMedia(videoCandidates)
+ meta, err := s.prober.Probe(ctx, candidate.AbsPath)
+ if err != nil {
+ return fmt.Errorf("probe cover candidate: %w", err)
+ }
+ if err := s.thumbGen.Generate(ctx, candidate.AbsPath, coverPath, meta.Duration); err != nil {
+ return fmt.Errorf("generate cover: %w", err)
+ }
+ case len(imageCandidates) > 0:
+ candidate := randomMedia(imageCandidates)
+ if err := s.thumbGen.Generate(ctx, candidate.AbsPath, coverPath, 0); err != nil {
+ return fmt.Errorf("generate cover: %w", err)
+ }
+ case len(thumbnailCandidates) > 0:
+ candidate := randomMedia(thumbnailCandidates)
+ if err := copyFile(candidate.ThumbnailPath, coverPath); err != nil {
+ return fmt.Errorf("copy thumbnail cover: %w", err)
+ }
+ default:
+ return errors.New("no media files available for cover")
+ }
+
+ return nil
+}
+
+func randomMedia(media []model.Media) model.Media {
+ if len(media) == 1 {
+ return media[0]
+ }
+ return media[mrand.Intn(len(media))]
+}
+
+func copyFile(src, dst string) error {
+ in, err := os.Open(src)
if err != nil {
- return fmt.Errorf("probe cover candidate: %w", err)
+ return err
}
+ defer in.Close()
- if err := s.thumbGen.Generate(ctx, candidate.AbsPath, coverPath, meta.Duration); err != nil {
- return fmt.Errorf("generate cover: %w", err)
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
}
- return nil
+ if _, err := io.Copy(out, in); err != nil {
+ _ = out.Close()
+ return err
+ }
+ return out.Close()
}
func (s *writeService) saveUploadedMedia(ctx context.Context, setID int64, path string, data io.Reader, size int64) (*model.Media, error) {