diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-09 11:11:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-09 11:11:49 +0300 |
| commit | 8c32cd117abe79f4ee6cff3a950ffc35c95faeff (patch) | |
| tree | 5b63a4789351da4f2d9c1cd2e7007f67d43b3371 /internal/service | |
| parent | 59c8870061465142306e7a6e5d3fcb344686cfe9 (diff) | |
Refine media browsing and set covers
Diffstat (limited to 'internal/service')
| -rw-r--r-- | internal/service/browse.go | 66 | ||||
| -rw-r--r-- | internal/service/browse_test.go | 35 | ||||
| -rw-r--r-- | internal/service/media_test.go | 45 | ||||
| -rw-r--r-- | internal/service/mock.go | 9 | ||||
| -rw-r--r-- | internal/service/podcast.go | 194 | ||||
| -rw-r--r-- | internal/service/podcast_test.go | 45 | ||||
| -rw-r--r-- | internal/service/service.go | 2 | ||||
| -rw-r--r-- | internal/service/tag.go | 8 | ||||
| -rw-r--r-- | internal/service/write.go | 83 |
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) { |
