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 | |
| parent | 59c8870061465142306e7a6e5d3fcb344686cfe9 (diff) | |
Refine media browsing and set covers
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/handlers_config.go | 17 | ||||
| -rw-r--r-- | internal/api/handlers_media.go | 12 | ||||
| -rw-r--r-- | internal/api/handlers_more_test.go | 22 | ||||
| -rw-r--r-- | internal/api/handlers_podcast.go | 14 | ||||
| -rw-r--r-- | internal/api/handlers_podcast_test.go | 60 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 3 | ||||
| -rw-r--r-- | internal/api/server.go | 7 | ||||
| -rw-r--r-- | internal/config.go | 21 | ||||
| -rw-r--r-- | internal/config_test.go | 13 | ||||
| -rw-r--r-- | internal/repository/migrate.go | 2 | ||||
| -rw-r--r-- | internal/repository/mock.go | 18 | ||||
| -rw-r--r-- | internal/repository/podcast.go | 21 | ||||
| -rw-r--r-- | internal/repository/podcast_repo.go | 2 | ||||
| -rw-r--r-- | internal/scanner/scanner.go | 11 | ||||
| -rw-r--r-- | internal/scanner/scanner_test.go | 52 | ||||
| -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 |
24 files changed, 639 insertions, 123 deletions
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(`<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0"> + <channel> + <title>Second Podcast</title> + <description>Another test podcast</description> + <item> + <title>Second Episode</title> + <guid>second-ep-1</guid> + <enclosure url="%s/second.mp3" length="4321" type="audio/mpeg"/> + </item> + </channel> +</rss>`, 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) } @@ -131,6 +138,11 @@ func TestLoadConfig_InvalidValues(t *testing.T) { 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"}}, wantErr: "invalid LOG_LEVEL", @@ -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 @@ -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 str |
