summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-09 11:11:49 +0300
committerPaul Buetow <paul@buetow.org>2026-05-09 11:11:49 +0300
commit8c32cd117abe79f4ee6cff3a950ffc35c95faeff (patch)
tree5b63a4789351da4f2d9c1cd2e7007f67d43b3371 /internal
parent59c8870061465142306e7a6e5d3fcb344686cfe9 (diff)
Refine media browsing and set covers
Diffstat (limited to 'internal')
-rw-r--r--internal/api/handlers_config.go17
-rw-r--r--internal/api/handlers_media.go12
-rw-r--r--internal/api/handlers_more_test.go22
-rw-r--r--internal/api/handlers_podcast.go14
-rw-r--r--internal/api/handlers_podcast_test.go60
-rw-r--r--internal/api/handlers_test.go3
-rw-r--r--internal/api/server.go7
-rw-r--r--internal/config.go21
-rw-r--r--internal/config_test.go13
-rw-r--r--internal/repository/migrate.go2
-rw-r--r--internal/repository/mock.go18
-rw-r--r--internal/repository/podcast.go21
-rw-r--r--internal/repository/podcast_repo.go2
-rw-r--r--internal/scanner/scanner.go11
-rw-r--r--internal/scanner/scanner_test.go52
-rw-r--r--internal/service/browse.go66
-rw-r--r--internal/service/browse_test.go35
-rw-r--r--internal/service/media_test.go45
-rw-r--r--internal/service/mock.go9
-rw-r--r--internal/service/podcast.go194
-rw-r--r--internal/service/podcast_test.go45
-rw-r--r--internal/service/service.go2
-rw-r--r--internal/service/tag.go8
-rw-r--r--internal/service/write.go83
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