summaryrefslogtreecommitdiff
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
parent59c8870061465142306e7a6e5d3fcb344686cfe9 (diff)
Refine media browsing and set covers
-rw-r--r--AGENTS.md11
-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
-rwxr-xr-xstart.sh32
-rw-r--r--web/bootstrap.html9
-rw-r--r--web/css/components.css65
-rw-r--r--web/css/layout.css46
-rw-r--r--web/css/login.css56
-rw-r--r--web/css/player.css2
-rw-r--r--web/detach.html1
-rw-r--r--web/index.html86
-rw-r--r--web/js/api.js1
-rw-r--r--web/js/app.js133
-rw-r--r--web/js/detach.js2
-rw-r--r--web/js/detachPopup.js50
-rw-r--r--web/js/keyboard.js40
-rw-r--r--web/js/playback.js36
-rw-r--r--web/js/podcasts.js24
-rw-r--r--web/js/search.js48
-rw-r--r--web/js/selection.js2
-rw-r--r--web/js/state.js1
-rw-r--r--web/js/tests/media-pagination.test.js56
-rw-r--r--web/js/tests/search-parser.test.js33
-rw-r--r--web/js/views/help.js2
-rw-r--r--web/js/views/media-grid.js243
-rw-r--r--web/js/views/playback-nav.js6
-rw-r--r--web/js/views/sets.js83
-rw-r--r--web/js/views/tags.js52
-rw-r--r--web/login.html6
-rw-r--r--web/sw.js4
52 files changed, 1530 insertions, 362 deletions
diff --git a/AGENTS.md b/AGENTS.md
index a50ba5f..a842196 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -194,6 +194,7 @@ This triggers `FSScanner.Scan()`, which:
| `SESSION_TIMEOUT_HOURS` | `24` | ≥ 1 | Cookie / session expiry |
| `GC_INTERVAL_MINUTES` | `30` | ≥ 1 | Garbage collector tick interval |
| `SHARE_DEFAULT_EXPIRY_DAYS` | `7` | ≥ 1 | Default share link lifetime |
+| `MEDIA_PAGE_SIZE` | `100` | ≥ 1 | Items displayed per thumbnail grid page |
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | Log verbosity |
| `SECURE_COOKIES` | `true` | `true` / `false` | Set `Secure` flag on session cookies; set to `false` for plain-HTTP local deployments |
@@ -203,17 +204,17 @@ This triggers `FSScanner.Scan()`, which:
## Podcast Support
-Podcasts are **special sets** (`sets.is_podcast = 1`). They reuse set permissions, browsing, and cover images, while adding feed management and episode tracking.
+Podcasts live under one shared **podcast set** (`sets.is_podcast = 1`, `root_path = "podcast"`). Each subscribed podcast is a `podcast_feeds` row and gets its own folder inside that single set.
### Subscribing
Admin opens the **Podcasts** button in the admin panel (or calls `POST /api/podcasts`):
-- Submit an RSS/Atom feed URL and optional folder name.
-- Server creates a set folder, parses the feed, downloads the cover image, and inserts episodes into `podcast_episodes`.
+- Submit an RSS/Atom feed URL.
+- Server creates or reuses the shared `podcast` set, creates a feed folder inside it, parses the feed, downloads the cover image, and inserts episodes into `podcast_episodes`.
### Episode Management
-Episodes are stored in `podcast_episodes` and rendered in the browse grid for podcast sets:
+Episodes are stored in `podcast_episodes` and rendered in the browse grid under the shared podcast set:
- **Undownloaded** episodes show a **Download to server** button (calls `POST /api/podcasts/episodes/{id}/download`).
- **Downloaded** episodes become regular `media` rows and appear as normal media cards.
- Users can mark episodes as listened/unlistened via the checkmark button.
@@ -226,7 +227,7 @@ A background goroutine (`CheckFeeds`) refreshes feeds every hour (configurable v
| Method | Path | Description |
|--------|------|-------------|
-| `GET` | `/api/podcasts` | List podcast sets |
+| `GET` | `/api/podcasts` | List subscribed podcast feeds |
| `POST` | `/api/podcasts` | Subscribe to a new feed (admin) |
| `GET` | `/api/podcasts/{id}/episodes` | List episodes with status |
| `POST` | `/api/podcasts/episodes/{id}/download` | Server-side download |
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(l