From 8c32cd117abe79f4ee6cff3a950ffc35c95faeff Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 9 May 2026 11:11:49 +0300 Subject: Refine media browsing and set covers --- internal/api/handlers_config.go | 17 +++ internal/api/handlers_media.go | 12 +++ internal/api/handlers_more_test.go | 22 ++++ internal/api/handlers_podcast.go | 14 +-- internal/api/handlers_podcast_test.go | 60 +++++++++-- internal/api/handlers_test.go | 3 + internal/api/server.go | 7 ++ internal/config.go | 21 +++- internal/config_test.go | 13 +++ internal/repository/migrate.go | 2 +- internal/repository/mock.go | 18 +++- internal/repository/podcast.go | 21 +++- internal/repository/podcast_repo.go | 2 + internal/scanner/scanner.go | 11 ++ internal/scanner/scanner_test.go | 52 ++++++++- internal/service/browse.go | 66 ++++++++++-- internal/service/browse_test.go | 35 +++++- internal/service/media_test.go | 45 +++++++- internal/service/mock.go | 9 ++ internal/service/podcast.go | 194 ++++++++++++++++++++++++++++------ internal/service/podcast_test.go | 45 ++++---- internal/service/service.go | 2 + internal/service/tag.go | 8 ++ internal/service/write.go | 83 +++++++++++---- 24 files changed, 639 insertions(+), 123 deletions(-) create mode 100644 internal/api/handlers_config.go (limited to 'internal') diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go new file mode 100644 index 0000000..0fdefe7 --- /dev/null +++ b/internal/api/handlers_config.go @@ -0,0 +1,17 @@ +package api + +import ( + "net/http" + + "codeberg.org/snonux/player/internal" +) + +func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request) { + pageSize := internal.DefaultMediaPageSize + if s.cfg != nil && s.cfg.MediaPageSize > 0 { + pageSize = s.cfg.MediaPageSize + } + writeJSON(w, http.StatusOK, map[string]int{ + "media_page_size": pageSize, + }) +} diff --git a/internal/api/handlers_media.go b/internal/api/handlers_media.go index 8eed3cc..ef3e149 100644 --- a/internal/api/handlers_media.go +++ b/internal/api/handlers_media.go @@ -287,6 +287,18 @@ func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"favorite": fav}) } +func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) { + if !requireService(w, s.tagSvc) { + return + } + tags, err := s.tagSvc.ListTags(r.Context(), userIDFromContext(r)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, tags) +} + func (s *Server) handleAddTag(w http.ResponseWriter, r *http.Request) { if !requireService(w, s.tagSvc) { return diff --git a/internal/api/handlers_more_test.go b/internal/api/handlers_more_test.go index 5d210f2..bc0edf3 100644 --- a/internal/api/handlers_more_test.go +++ b/internal/api/handlers_more_test.go @@ -87,6 +87,28 @@ func TestNewGracefulServer(t *testing.T) { } } +func TestServer_Config(t *testing.T) { + store := buildSessionStore(1) + sm := auth.NewSessionManager(store, &clock.MockClock{T: time.Now()}, time.Hour) + cfg := &internal.Config{MediaPageSize: 37} + srv := newTestServer(t, store, nil, sm, cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + req.AddCookie(addSessionCookie(t, store, sm, 1)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d", http.StatusOK, rr.Code) + } + var body map[string]int + if err := json.NewDecoder(rr.Body).Decode(&body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body["media_page_size"] != 37 { + t.Fatalf("expected media_page_size 37, got %d", body["media_page_size"]) + } +} + func TestPingStore_nonPinger(t *testing.T) { store := &repository.MockStore{} srv := newTestServer(t, store, nil, nil, &internal.Config{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) diff --git a/internal/api/handlers_podcast.go b/internal/api/handlers_podcast.go index f93b15c..721e83e 100644 --- a/internal/api/handlers_podcast.go +++ b/internal/api/handlers_podcast.go @@ -13,25 +13,17 @@ import ( // ------------------------------------------------------------------ func (s *Server) handleListPodcasts(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.podcastSvc) { return } - userID := userIDFromContext(r) - sets, err := s.browseSvc.ListSets(r.Context(), userID) + feeds, err := s.podcastSvc.ListFeeds(r.Context(), userIDFromContext(r)) if err != nil { s.logger.Error("list podcasts", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list podcasts"}) return } - // Filter to podcast sets only. - var podcasts []interface{} - for _, set := range sets { - if set.IsPodcast { - podcasts = append(podcasts, set) - } - } - writeJSON(w, http.StatusOK, podcasts) + writeJSON(w, http.StatusOK, feeds) } func (s *Server) handleSubscribePodcast(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handlers_podcast_test.go b/internal/api/handlers_podcast_test.go index 963c2a0..12c6198 100644 --- a/internal/api/handlers_podcast_test.go +++ b/internal/api/handlers_podcast_test.go @@ -154,6 +154,25 @@ func TestPodcastE2E_FullFlow(t *testing.T) { })) defer rssServer.Close() + secondRSSBody := fmt.Sprintf(` + + + Second Podcast + Another test podcast + + Second Episode + second-ep-1 + + + +`, audioServer.URL) + secondRSSServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(secondRSSBody)) + })) + defer secondRSSServer.Close() + badRSSServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) @@ -189,6 +208,27 @@ func TestPodcastE2E_FullFlow(t *testing.T) { podcastSetID = feed.SetID }) + t.Run("subscribe second podcast uses same set", func(t *testing.T) { + body := fmt.Sprintf(`{"feed_url":"%s/rss.xml","set_name":"second-podcast"}`, secondRSSServer.URL) + req := httptest.NewRequest(http.MethodPost, "/api/podcasts", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(cookie) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) + } + + var feed model.PodcastFeed + if err := json.Unmarshal(rr.Body.Bytes(), &feed); err != nil { + t.Fatalf("unmarshal feed: %v", err) + } + if feed.SetID != podcastSetID { + t.Fatalf("expected set_id %d, got %d", podcastSetID, feed.SetID) + } + }) + t.Run("list podcasts", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/podcasts", nil) req.AddCookie(cookie) @@ -199,20 +239,18 @@ func TestPodcastE2E_FullFlow(t *testing.T) { t.Fatalf("expected %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String()) } - var sets []model.Set - if err := json.Unmarshal(rr.Body.Bytes(), &sets); err != nil { - t.Fatalf("unmarshal sets: %v", err) + var feeds []model.PodcastFeed + if err := json.Unmarshal(rr.Body.Bytes(), &feeds); err != nil { + t.Fatalf("unmarshal feeds: %v", err) } - found := false - for _, s := range sets { - if s.ID == podcastSetID && s.IsPodcast { - found = true - break - } + if len(feeds) != 2 { + t.Fatalf("expected 2 podcast feeds, got %d", len(feeds)) } - if !found { - t.Fatalf("expected podcast set %d in list", podcastSetID) + for _, feed := range feeds { + if feed.SetID != podcastSetID { + t.Fatalf("expected all feeds in set %d, got feed %+v", podcastSetID, feed) + } } }) diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 6f9f75e..435a924 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -1515,6 +1515,9 @@ func (m *mockPingStore) GetFeedByID(ctx context.Context, id int64) (*model.Podca func (m *mockPingStore) GetFeedBySetID(ctx context.Context, setID int64) (*model.PodcastFeed, error) { return m.store.GetFeedBySetID(ctx, setID) } +func (m *mockPingStore) ListFeedsBySetID(ctx context.Context, setID int64) ([]model.PodcastFeed, error) { + return m.store.ListFeedsBySetID(ctx, setID) +} func (m *mockPingStore) ListFeeds(ctx context.Context) ([]model.PodcastFeed, error) { return m.store.ListFeeds(ctx) } diff --git a/internal/api/server.go b/internal/api/server.go index e545e94..1469626 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -165,6 +165,11 @@ func (s *Server) routesAuth() { s.mux.Handle("POST /api/logout", s.requireSession(s.handleLogout)) } +// routesConfig wires authenticated client configuration. +func (s *Server) routesConfig() { + s.mux.Handle("GET /api/config", s.requireSession(s.handleConfig)) +} + // routesSets wires the set-related API routes. func (s *Server) routesSets() { s.mux.Handle("GET /api/sets", s.requireSession(s.handleListSets)) @@ -183,6 +188,7 @@ func (s *Server) routesMedia() { s.mux.Handle("GET /api/media/{id}/thumbnail", s.requireSession(s.handleThumbnail)) s.mux.Handle("POST /api/media/{id}/thumbnail", s.requireSession(s.handleRegenThumbnail)) s.mux.Handle("POST /api/media/{id}/favorite", s.requireSession(s.handleFavorite)) + s.mux.Handle("GET /api/tags", s.requireSession(s.handleListTags)) s.mux.Handle("POST /api/media/{id}/tags", s.requireSession(s.handleAddTag)) s.mux.Handle("DELETE /api/media/{id}/tags/{tag}", s.requireSession(s.handleRemoveTag)) s.mux.Handle("DELETE /api/media/{id}", s.requireSession(s.handleSoftDelete)) @@ -228,6 +234,7 @@ func (s *Server) routes() { s.routesStatic() s.routesHTML() s.routesAuth() + s.routesConfig() s.routesSets() s.routesMedia() s.routesNotes() diff --git a/internal/config.go b/internal/config.go index 1100503..b2873c7 100644 --- a/internal/config.go +++ b/internal/config.go @@ -14,9 +14,10 @@ const ( DefaultDBPath = "data.db" DefaultMaxUploadSizeMB = 100 DefaultSessionTimeoutHours = 24 - DefaultGCIntervalMinutes = 30 - DefaultShareDefaultExpiryDays = 7 - DefaultPodcastCheckMinutes = 60 + DefaultGCIntervalMinutes = 30 + DefaultShareDefaultExpiryDays = 7 + DefaultPodcastCheckMinutes = 60 + DefaultMediaPageSize = 100 DefaultLogLevel = "info" DefaultSecureCookies = true ) @@ -31,6 +32,7 @@ type Config struct { GCIntervalMinutes int ShareDefaultExpiryDays int PodcastCheckMinutes int + MediaPageSize int LogLevel string SecureCookies bool } @@ -79,13 +81,13 @@ func defaultConfig() *Config { GCIntervalMinutes: DefaultGCIntervalMinutes, ShareDefaultExpiryDays: DefaultShareDefaultExpiryDays, PodcastCheckMinutes: DefaultPodcastCheckMinutes, + MediaPageSize: DefaultMediaPageSize, LogLevel: DefaultLogLevel, SecureCookies: DefaultSecureCookies, } } -// loadNumericSettings reads PORT, MAX_UPLOAD_SIZE_MB, SESSION_TIMEOUT_HOURS, -// GC_INTERVAL_MINUTES and SHARE_DEFAULT_EXPIRY_DAYS from the environment. +// loadNumericSettings reads numeric settings from the environment. func loadNumericSettings(cfg *Config) error { if err := envInt("PORT", func(n int) error { // Allow 0 so tests can bind to an ephemeral port. @@ -142,6 +144,15 @@ func loadNumericSettings(cfg *Config) error { return err } + if err := envInt("MEDIA_PAGE_SIZE", func(n int) error { + if n < 1 { + return fmt.Errorf("must be >= 1, got %d", n) + } + return nil + }, func(n int) { cfg.MediaPageSize = n }); err != nil { + return err + } + return nil } diff --git a/internal/config_test.go b/internal/config_test.go index 5abf301..9933457 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -34,6 +34,9 @@ func TestLoadConfig_Defaults(t *testing.T) { if cfg.ShareDefaultExpiryDays != DefaultShareDefaultExpiryDays { t.Errorf("ShareDefaultExpiryDays: expected %d, got %d", DefaultShareDefaultExpiryDays, cfg.ShareDefaultExpiryDays) } + if cfg.MediaPageSize != DefaultMediaPageSize { + t.Errorf("MediaPageSize: expected %d, got %d", DefaultMediaPageSize, cfg.MediaPageSize) + } if cfg.LogLevel != DefaultLogLevel { t.Errorf("LogLevel: expected %q, got %q", DefaultLogLevel, cfg.LogLevel) } @@ -52,6 +55,7 @@ func TestLoadConfig_EnvOverrides(t *testing.T) { {"SESSION_TIMEOUT_HOURS", "48"}, {"GC_INTERVAL_MINUTES", "60"}, {"SHARE_DEFAULT_EXPIRY_DAYS", "14"}, + {"MEDIA_PAGE_SIZE", "25"}, {"LOG_LEVEL", "debug"}, {"SECURE_COOKIES", "false"}, }) @@ -81,6 +85,9 @@ func TestLoadConfig_EnvOverrides(t *testing.T) { if cfg.ShareDefaultExpiryDays != 14 { t.Errorf("ShareDefaultExpiryDays: expected 14, got %d", cfg.ShareDefaultExpiryDays) } + if cfg.MediaPageSize != 25 { + t.Errorf("MediaPageSize: expected 25, got %d", cfg.MediaPageSize) + } if cfg.LogLevel != "debug" { t.Errorf("LogLevel: expected %q, got %q", "debug", cfg.LogLevel) } @@ -130,6 +137,11 @@ func TestLoadConfig_InvalidValues(t *testing.T) { env: []envPair{{"SHARE_DEFAULT_EXPIRY_DAYS", "0"}}, wantErr: "invalid SHARE_DEFAULT_EXPIRY_DAYS", }, + { + name: "invalid MEDIA_PAGE_SIZE", + env: []envPair{{"MEDIA_PAGE_SIZE", "0"}}, + wantErr: "invalid MEDIA_PAGE_SIZE", + }, { name: "invalid LOG_LEVEL", env: []envPair{{"LOG_LEVEL", "trace"}}, @@ -168,6 +180,7 @@ func clearEnv() { "PORT", "MEDIA_ROOT", "DB_PATH", "MAX_UPLOAD_SIZE_MB", "SESSION_TIMEOUT_HOURS", "GC_INTERVAL_MINUTES", "SHARE_DEFAULT_EXPIRY_DAYS", + "PODCAST_CHECK_INTERVAL_MINUTES", "MEDIA_PAGE_SIZE", "LOG_LEVEL", "SECURE_COOKIES", } { os.Unsetenv(k) diff --git a/internal/repository/migrate.go b/internal/repository/migrate.go index d64732d..3c4702f 100644 --- a/internal/repository/migrate.go +++ b/internal/repository/migrate.go @@ -125,7 +125,7 @@ CREATE TABLE IF NOT EXISTS media_notes ( CREATE TABLE IF NOT EXISTS podcast_feeds ( id INTEGER PRIMARY KEY AUTOINCREMENT, - set_id INTEGER NOT NULL UNIQUE REFERENCES sets(id) ON DELETE CASCADE, + set_id INTEGER NOT NULL REFERENCES sets(id) ON DELETE CASCADE, feed_url TEXT NOT NULL, title TEXT, description TEXT, diff --git a/internal/repository/mock.go b/internal/repository/mock.go index d715984..d7b61a4 100644 --- a/internal/repository/mock.go +++ b/internal/repository/mock.go @@ -904,6 +904,11 @@ func (m *MockStore) GetFeedBySetID(ctx context.Context, setID int64) (*model.Pod return m.PodcastRepo.GetFeedBySetID(ctx, setID) } +// ListFeedsBySetID implements PodcastRepo. +func (m *MockStore) ListFeedsBySetID(ctx context.Context, setID int64) ([]model.PodcastFeed, error) { + return m.PodcastRepo.ListFeedsBySetID(ctx, setID) +} + // ListFeeds implements PodcastRepo. func (m *MockStore) ListFeeds(ctx context.Context) ([]model.PodcastFeed, error) { return m.PodcastRepo.ListFeeds(ctx) @@ -966,11 +971,12 @@ type MockPodcastRepo struct { DeleteFeedFunc func(ctx context.Context, id int64) error GetFeedByIDFunc func(ctx context.Context, id int64) (*model.PodcastFeed, error) GetFeedBySetIDFunc func(ctx context.Context, setID int64) (*model.PodcastFeed, error) + ListFeedsBySetIDFunc func(ctx context.Context, setID int64) ([]model.PodcastFeed, error) ListFeedsFunc func(ctx context.Context) ([]model.PodcastFeed, error) ListFeedsNeedingCheckFunc func(ctx context.Context, before time.Time) ([]model.PodcastFeed, error) - CreateEpisodeFunc func(ctx context.Context, episode *model.PodcastEpisode) (int64, error) - GetEpisodeByIDFunc func(ctx context.Context, id int64) (*model.PodcastEpisode, error) + CreateEpisodeFunc func(ctx context.Context, episode *model.PodcastEpisode) (int64, error) + GetEpisodeByIDFunc func(ctx context.Context, id int64) (*model.PodcastEpisode, error) GetEpisodeByGUIDFunc func(ctx context.Context, feedID int64, guid string) (*model.PodcastEpisode, error) ListEpisodesByFeedFunc func(ctx context.Context, feedID int64, limit, offset int) ([]model.PodcastEpisode, error) UpdateEpisodeMediaFunc func(ctx context.Context, episodeID, mediaID int64, fileName string) error @@ -1021,6 +1027,14 @@ func (m *MockPodcastRepo) GetFeedBySetID(ctx context.Context, setID int64) (*mod return nil, nil } +// ListFeedsBySetID calls ListFeedsBySetIDFunc or returns nil. +func (m *MockPodcastRepo) ListFeedsBySetID(ctx context.Context, setID int64) ([]model.PodcastFeed, error) { + if m.ListFeedsBySetIDFunc != nil { + return m.ListFeedsBySetIDFunc(ctx, setID) + } + return nil, nil +} + // ListFeeds calls ListFeedsFunc or returns nil. func (m *MockPodcastRepo) ListFeeds(ctx context.Context) ([]model.PodcastFeed, error) { if m.ListFeedsFunc != nil { diff --git a/internal/repository/podcast.go b/internal/repository/podcast.go index 0e9887b..f3d0c6f 100644 --- a/internal/repository/podcast.go +++ b/internal/repository/podcast.go @@ -86,6 +86,25 @@ func (s *SQLite) GetFeedBySetID(ctx context.Context, setID int64) (*model.Podcas return scanFeed(row) } +// ListFeedsBySetID returns all podcast feeds linked to a set. +func (s *SQLite) ListFeedsBySetID(ctx context.Context, setID int64) ([]model.PodcastFeed, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, set_id, feed_url, title, description, image_url, last_checked_at, last_etag, check_interval_minutes, auto_download, created_at FROM podcast_feeds WHERE set_id = ? ORDER BY title, created_at`, setID) + if err != nil { + return nil, fmt.Errorf("list podcast feeds by set: %w", err) + } + defer rows.Close() + var feeds []model.PodcastFeed + for rows.Next() { + f, err := scanFeed(rows) + if err != nil { + return nil, err + } + feeds = append(feeds, *f) + } + return feeds, rows.Err() +} + // ListFeeds returns all podcast feeds. func (s *SQLite) ListFeeds(ctx context.Context) ([]model.PodcastFeed, error) { rows, err := s.db.QueryContext(ctx, @@ -338,5 +357,3 @@ func (s *SQLite) ListEpisodesWithStatus(ctx context.Context, userID, feedID int6 } return episodes, rows.Err() } - - diff --git a/internal/repository/podcast_repo.go b/internal/repository/podcast_repo.go index 3fbd2f9..fb4917a 100644 --- a/internal/repository/podcast_repo.go +++ b/internal/repository/podcast_repo.go @@ -19,6 +19,8 @@ type PodcastRepo interface { GetFeedByID(ctx context.Context, id int64) (*model.PodcastFeed, error) // GetFeedBySetID returns a podcast feed linked to a set. GetFeedBySetID(ctx context.Context, setID int64) (*model.PodcastFeed, error) + // ListFeedsBySetID returns all podcast feeds linked to a set. + ListFeedsBySetID(ctx context.Context, setID int64) ([]model.PodcastFeed, error) // ListFeeds returns all podcast feeds. ListFeeds(ctx context.Context) ([]model.PodcastFeed, error) // ListFeedsNeedingCheck returns feeds whose last_checked_at is before the given time. diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 92bc6c3..dc5f34f 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -116,6 +116,12 @@ func (s *FSScanner) ensureSet(ctx context.Context, root, setPath string) (int64, for i := range sets { if sets[i].RootPath == relRoot { + if isPodcastRoot(relRoot) && !sets[i].IsPodcast { + sets[i].IsPodcast = true + if err := s.store.UpdateSet(ctx, &sets[i]); err != nil { + return 0, "", fmt.Errorf("update podcast set %q: %w", setName, err) + } + } return sets[i].ID, setName, nil } } @@ -123,6 +129,7 @@ func (s *FSScanner) ensureSet(ctx context.Context, root, setPath string) (int64, newSet := &model.Set{ Name: setName, RootPath: relRoot, + IsPodcast: isPodcastRoot(relRoot), CreatedAt: s.clock.Now(), } id, err := s.store.CreateSet(ctx, newSet) @@ -132,6 +139,10 @@ func (s *FSScanner) ensureSet(ctx context.Context, root, setPath string) (int64, return id, setName, nil } +func isPodcastRoot(rootPath string) bool { + return strings.EqualFold(filepath.ToSlash(rootPath), "podcast") +} + // loadExistingMedia builds a lookup map of existing media keyed by relPath. func (s *FSScanner) loadExistingMedia(ctx context.Context, setID int64, setName string) (map[string]model.Media, error) { existing := make(map[string]model.Media) diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 3a77f72..ad6ddd7 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -268,6 +268,57 @@ func TestFSScanner_Scan(t *testing.T) { } }) + t.Run("scans all top-level dirs and marks canonical podcast root", func(t *testing.T) { + mfs := &mockFS{ + entries: map[string][]os.DirEntry{ + "/media": { + mockDirEntry{name: "qa-stable-podcast-1", isDir: true}, + mockDirEntry{name: "podcast", isDir: true}, + }, + }, + fileInfos: map[string]os.FileInfo{ + "/media/podcast/feed/episode.mp3": mockFileInfo{name: "episode.mp3", size: 500}, + }, + walkList: []walkEntry{ + {path: "/media/podcast", isDir: true}, + {path: "/media/podcast/feed", isDir: true}, + {path: "/media/podcast/feed/episode.mp3", isDir: false}, + }, + } + store := repository.NewMockStore() + store.SetRepo.ListSetsFunc = func(_ context.Context) ([]model.Set, error) { return nil, nil } + var createdSets []model.Set + store.SetRepo.CreateSetFunc = func(_ context.Context, set *model.Set) (int64, error) { + createdSets = append(createdSets, *set) + return int64(len(createdSets)), nil + } + store.MediaRepo.ListMediaFunc = func(_ context.Context, filter repository.MediaFilter) ([]model.Media, error) { + return nil, nil + } + store.MediaRepo.CreateMediaFunc = func(_ context.Context, m *model.Media) (int64, error) { + return 1, nil + } + prober := &probe.MockProber{ + ProbeFunc: func(_ context.Context, path string) (*model.Metadata, error) { + return &model.Metadata{Duration: 180, Codec: "mp3", Bitrate: 256}, nil + }, + } + + s := newTestScanner(store, prober, &thumb.MockGenerator{}, clk, mfs) + if err := s.Scan(ctx, "/media", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(createdSets) != 2 { + t.Fatalf("expected both top-level dirs to be created, got %+v", createdSets) + } + if createdSets[0].RootPath != "qa-stable-podcast-1" { + t.Fatalf("expected qa-stable-podcast-1 root path, got %+v", createdSets[0]) + } + if createdSets[1].RootPath != "podcast" || !createdSets[1].IsPodcast { + t.Fatalf("expected podcast root path marked as podcast, got %+v", createdSets[1]) + } + }) + t.Run("nested directories", func(t *testing.T) { mfs := &mockFS{ entries: map[string][]os.DirEntry{ @@ -501,4 +552,3 @@ func TestFSScanner_collectFiles(t *testing.T) { } }) } - 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 @@ -38,6 +38,27 @@ func TestBrowseService_BrowseSet(t *testing.T) { wantMedia: 1, 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"}, @@ -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) { -- cgit v1.2.3