summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/handlers_podcast.go164
-rw-r--r--internal/api/handlers_test.go52
-rw-r--r--internal/api/server.go16
-rw-r--r--internal/config.go5
-rw-r--r--internal/model/media.go1
-rw-r--r--internal/model/podcast.go52
-rw-r--r--internal/podcast/cover.go39
-rw-r--r--internal/podcast/feed.go117
-rw-r--r--internal/repository/migrate.go45
-rw-r--r--internal/repository/mock.go232
-rw-r--r--internal/repository/podcast.go329
-rw-r--r--internal/repository/podcast_repo.go46
-rw-r--r--internal/repository/podcast_test.go169
-rw-r--r--internal/repository/repository.go3
-rw-r--r--internal/repository/set.go16
-rw-r--r--internal/repository/sqlite.go1
-rw-r--r--internal/service/access.go5
-rw-r--r--internal/service/browse.go17
-rw-r--r--internal/service/import.go92
-rw-r--r--internal/service/podcast.go562
-rw-r--r--internal/service/service.go7
-rw-r--r--internal/service/write.go65
22 files changed, 1955 insertions, 80 deletions
diff --git a/internal/api/handlers_podcast.go b/internal/api/handlers_podcast.go
new file mode 100644
index 0000000..53b3ea2
--- /dev/null
+++ b/internal/api/handlers_podcast.go
@@ -0,0 +1,164 @@
+package api
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "codeberg.org/snonux/player/internal/service"
+)
+
+// ------------------------------------------------------------------
+// Podcast Handlers
+// ------------------------------------------------------------------
+
+func (s *Server) handleListPodcasts(w http.ResponseWriter, r *http.Request) {
+ if !requireService(w, s.browseSvc) {
+ return
+ }
+ userID := userIDFromContext(r)
+ sets, err := s.browseSvc.ListSets(r.Context(), userID)
+ 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)
+}
+
+func (s *Server) handleSubscribePodcast(w http.ResponseWriter, r *http.Request) {
+ if !requireService(w, s.podcastSvc) {
+ return
+ }
+
+ var req struct {
+ FeedURL string `json:"feed_url"`
+ SetName string `json:"set_name"`
+ }
+ if err := readJSON(r, &req); err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"})
+ return
+ }
+ if req.FeedURL == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "feed_url is required"})
+ return
+ }
+
+ feed, err := s.podcastSvc.SubscribeFeed(r.Context(), req.FeedURL, req.SetName, userIDFromContext(r))
+ if err != nil {
+ if errors.Is(err, service.ErrForbidden) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "access denied"})
+ return
+ }
+ s.logger.Error("subscribe podcast", "err", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to subscribe"})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, feed)
+}
+
+func (s *Server) handleListEpisodes(w http.ResponseWriter, r *http.Request) {
+ if !requireService(w, s.podcastSvc) {
+ return
+ }
+
+ setID := pathID(r, "id")
+ if setID == 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid set id"})
+ return
+ }
+
+ limitStr := r.URL.Query().Get("limit")
+ offsetStr := r.URL.Query().Get("offset")
+ limit := 50
+ offset := 0
+ if v, err := strconv.Atoi(limitStr); err == nil && v > 0 {
+ limit = v
+ }
+ if v, err := strconv.Atoi(offsetStr); err == nil && v >= 0 {
+ offset = v
+ }
+
+ episodes, err := s.podcastSvc.ListEpisodes(r.Context(), setID, userIDFromContext(r), limit, offset)
+ if err != nil {
+ if errors.Is(err, service.ErrForbidden) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "access denied"})
+ return
+ }
+ if errors.Is(err, service.ErrNotFound) {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+ return
+ }
+ s.logger.Error("list episodes", "err", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list episodes"})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, episodes)
+}
+
+func (s *Server) handleDownloadEpisode(w http.ResponseWriter, r *http.Request) {
+ if !requireService(w, s.podcastSvc) {
+ return
+ }
+
+ episodeID := pathID(r, "episode_id")
+ if episodeID == 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
+ return
+ }
+
+ media, err := s.podcastSvc.DownloadEpisode(r.Context(), episodeID, userIDFromContext(r))
+ if err != nil {
+ if errors.Is(err, service.ErrForbidden) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "access denied"})
+ return
+ }
+ if errors.Is(err, service.ErrNotFound) {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+ return
+ }
+ s.logger.Error("download episode", "err", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to download episode"})
+ return
+ }
+
+ writeJSON(w, http.StatusOK, media)
+}
+
+func (s *Server) handleToggleComplete(w http.ResponseWriter, r *http.Request) {
+ if !requireService(w, s.podcastSvc) {
+ return
+ }
+
+ episodeID := pathID(r, "episode_id")
+ if episodeID == 0 {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
+ return
+ }
+
+ if err := s.podcastSvc.ToggleEpisodeComplete(r.Context(), episodeID, userIDFromContext(r)); err != nil {
+ if errors.Is(err, service.ErrForbidden) {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "access denied"})
+ return
+ }
+ if errors.Is(err, service.ErrNotFound) {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
+ return
+ }
+ s.logger.Error("toggle complete", "err", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to toggle completion"})
+ return
+ }
+
+ writeJSON(w, http.StatusNoContent, nil)
+}
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go
index 922da58..584ac31 100644
--- a/internal/api/handlers_test.go
+++ b/internal/api/handlers_test.go
@@ -66,7 +66,7 @@ func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm
if len(remuxer) > 0 {
rem = remuxer[0]
}
- return NewServer(store, hasher, sm, cfg, browseSvc, writeSvc, shareSvc, tagSvc, favSvc, noteSvc, adminSvc, progressSvc, authSvc, fs, rem)
+ return NewServer(store, hasher, sm, cfg, browseSvc, writeSvc, shareSvc, tagSvc, favSvc, noteSvc, adminSvc, progressSvc, authSvc, nil, fs, rem)
}
func addSessionCookie(t *testing.T, store repository.Store, sm *auth.SessionManager, userID int64) *http.Cookie {
@@ -1481,3 +1481,53 @@ func (m *mockPingStore) DeleteNote(ctx context.Context, mediaID, userID int64) e
func (m *mockPingStore) Ping(ctx context.Context) error {
return m.err
}
+
+// PodcastRepo methods added for Store interface compliance.
+func (m *mockPingStore) CreateFeed(ctx context.Context, feed *model.PodcastFeed) (int64, error) {
+ return m.store.CreateFeed(ctx, feed)
+}
+func (m *mockPingStore) UpdateFeed(ctx context.Context, feed *model.PodcastFeed) error {
+ return m.store.UpdateFeed(ctx, feed)
+}
+func (m *mockPingStore) DeleteFeed(ctx context.Context, id int64) error {
+ return m.store.DeleteFeed(ctx, id)
+}
+func (m *mockPingStore) GetFeedByID(ctx context.Context, id int64) (*model.PodcastFeed, error) {
+ return m.store.GetFeedByID(ctx, id)
+}
+func (m *mockPingStore) GetFeedBySetID(ctx context.Context, setID int64) (*model.PodcastFeed, error) {
+ return m.store.GetFeedBySetID(ctx, setID)
+}
+func (m *mockPingStore) ListFeeds(ctx context.Context) ([]model.PodcastFeed, error) {
+ return m.store.ListFeeds(ctx)
+}
+func (m *mockPingStore) ListFeedsNeedingCheck(ctx context.Context, before time.Time) ([]model.PodcastFeed, error) {
+ return m.store.ListFeedsNeedingCheck(ctx, before)
+}
+func (m *mockPingStore) CreateEpisode(ctx context.Context, episode *model.PodcastEpisode) (int64, error) {
+ return m.store.CreateEpisode(ctx, episode)
+}
+func (m *mockPingStore) GetEpisodeByID(ctx context.Context, id int64) (*model.PodcastEpisode, error) {
+ return m.store.GetEpisodeByID(ctx, id)
+}
+func (m *mockPingStore) GetEpisodeByGUID(ctx context.Context, feedID int64, guid string) (*model.PodcastEpisode, error) {
+ return m.store.GetEpisodeByGUID(ctx, feedID, guid)
+}
+func (m *mockPingStore) ListEpisodesByFeed(ctx context.Context, feedID int64, limit, offset int) ([]model.PodcastEpisode, error) {
+ return m.store.ListEpisodesByFeed(ctx, feedID, limit, offset)
+}
+func (m *mockPingStore) UpdateEpisodeMedia(ctx context.Context, episodeID, mediaID int64, fileName string) error {
+ return m.store.UpdateEpisodeMedia(ctx, episodeID, mediaID, fileName)
+}
+func (m *mockPingStore) DeleteEpisodesByFeed(ctx context.Context, feedID int64) error {
+ return m.store.DeleteEpisodesByFeed(ctx, feedID)
+}
+func (m *mockPingStore) UpsertEpisodeProgress(ctx context.Context, status *model.PodcastStatus) error {
+ return m.store.UpsertEpisodeProgress(ctx, status)
+}
+func (m *mockPingStore) GetEpisodeProgress(ctx context.Context, userID, episodeID int64) (*model.PodcastStatus, error) {
+ return m.store.GetEpisodeProgress(ctx, userID, episodeID)
+}
+func (m *mockPingStore) ListEpisodesWithStatus(ctx context.Context, userID, feedID int64, limit, offset int) ([]model.PodcastEpisodeWithStatus, error) {
+ return m.store.ListEpisodesWithStatus(ctx, userID, feedID, limit, offset)
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index c1a79d4..8f8c260 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -30,6 +30,7 @@ type Server struct {
adminSvc service.AdminService
progressSvc service.ProgressService
authSvc service.AuthService
+ podcastSvc service.PodcastEpisodeService
staticFS http.FileSystem
remuxer probe.Remuxer
logger *slog.Logger
@@ -52,10 +53,11 @@ func NewServer(
adminSvc service.AdminService,
progressSvc service.ProgressService,
authSvc service.AuthService,
+ podcastSvc service.PodcastEpisodeService,
staticFS http.FileSystem,
remuxer probe.Remuxer,
) *Server {
- return NewServerWithLogger(store, hasher, sm, cfg, browseSvc, writeSvc, shareSvc, tagSvc, favSvc, noteSvc, adminSvc, progressSvc, authSvc, staticFS, remuxer, slog.Default())
+ return NewServerWithLogger(store, hasher, sm, cfg, browseSvc, writeSvc, shareSvc, tagSvc, favSvc, noteSvc, adminSvc, progressSvc, authSvc, podcastSvc, staticFS, remuxer, slog.Default())
}
// NewServerWithLogger creates a Server with routes and an injected logger.
@@ -73,6 +75,7 @@ func NewServerWithLogger(
adminSvc service.AdminService,
progressSvc service.ProgressService,
authSvc service.AuthService,
+ podcastSvc service.PodcastEpisodeService,
staticFS http.FileSystem,
remuxer probe.Remuxer,
logger *slog.Logger,
@@ -98,6 +101,7 @@ func NewServerWithLogger(
adminSvc: adminSvc,
progressSvc: progressSvc,
authSvc: authSvc,
+ podcastSvc: podcastSvc,
staticFS: staticFS,
remuxer: remuxer,
logger: logger,
@@ -239,6 +243,16 @@ func (s *Server) routes() {
s.routesProgress()
s.routesShares()
s.routesAdmin()
+ s.routesPodcast()
+}
+
+// routesPodcast wires the podcast API routes.
+func (s *Server) routesPodcast() {
+ s.mux.Handle("GET /api/podcasts", s.requireSession(s.handleListPodcasts))
+ s.mux.Handle("POST /api/podcasts", s.requireAdmin(s.handleSubscribePodcast))
+ s.mux.Handle("GET /api/podcasts/{id}/episodes", s.requireSession(s.handleListEpisodes))
+ s.mux.Handle("POST /api/podcasts/episodes/{episode_id}/download", s.requireSession(s.handleDownloadEpisode))
+ s.mux.Handle("POST /api/podcasts/episodes/{episode_id}/complete", s.requireSession(s.handleToggleComplete))
}
func (s *Server) pingStore(ctx context.Context) error {
diff --git a/internal/config.go b/internal/config.go
index e3e0647..ee63ccf 100644
--- a/internal/config.go
+++ b/internal/config.go
@@ -14,8 +14,9 @@ const (
DefaultDBPath = "data.db"
DefaultMaxUploadSizeMB = 100
DefaultSessionTimeoutHours = 24
- DefaultGCIntervalMinutes = 30
- DefaultShareDefaultExpiryDays = 7
+ DefaultGCIntervalMinutes = 30
+ DefaultShareDefaultExpiryDays = 7
+ DefaultPodcastCheckMinutes = 60
DefaultLogLevel = "info"
DefaultSecureCookies = true
)
diff --git a/internal/model/media.go b/internal/model/media.go
index f242746..48ab487 100644
--- a/internal/model/media.go
+++ b/internal/model/media.go
@@ -40,6 +40,7 @@ type Set struct {
Name string `json:"name"`
RootPath string `json:"root_path"`
CoverThumbnailPath string `json:"cover_thumbnail_path"`
+ IsPodcast bool `json:"is_podcast"`
Permissions []SetPermission `json:"permissions"`
CreatedAt time.Time `json:"created_at"`
}
diff --git a/internal/model/podcast.go b/internal/model/podcast.go
new file mode 100644
index 0000000..5c3cdbc
--- /dev/null
+++ b/internal/model/podcast.go
@@ -0,0 +1,52 @@
+// Package model defines domain entities for podcast feeds and episodes.
+package model
+
+import "time"
+
+// PodcastFeed represents a subscribed RSS/Atom feed linked to a set.
+type PodcastFeed struct {
+ ID int64 `json:"id"`
+ SetID int64 `json:"set_id"`
+ FeedURL string `json:"feed_url"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ ImageURL string `json:"image_url"`
+ LastCheckedAt *time.Time `json:"last_checked_at"`
+ LastETag string `json:"last_etag"`
+ CheckIntervalMinutes int `json:"check_interval_minutes"`
+ AutoDownload bool `json:"auto_download"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// PodcastEpisode represents an individual episode from a feed.
+type PodcastEpisode struct {
+ ID int64 `json:"id"`
+ FeedID int64 `json:"feed_id"`
+ MediaID *int64 `json:"media_id"`
+ GUID string `json:"guid"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ PublishedAt *time.Time `json:"published_at"`
+ EpisodeURL string `json:"episode_url"`
+ DurationSeconds *float64 `json:"duration_seconds"`
+ FileSize *int64 `json:"file_size"`
+ FileName string `json:"file_name"`
+ IsDownloaded bool `json:"is_downloaded"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// PodcastStatus tracks per-user completion and progress for an episode.
+type PodcastStatus struct {
+ UserID int64 `json:"user_id"`
+ EpisodeID int64 `json:"episode_id"`
+ IsCompleted bool `json:"is_completed"`
+ PositionSeconds float64 `json:"position_seconds"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// PodcastEpisodeWithStatus is a PodcastEpisode augmented with per-user status.
+type PodcastEpisodeWithStatus struct {
+ PodcastEpisode
+ IsCompleted bool `json:"is_completed"`
+ PositionSeconds float64 `json:"position_seconds"`
+}
diff --git a/internal/podcast/cover.go b/internal/podcast/cover.go
new file mode 100644
index 0000000..f7792b5
--- /dev/null
+++ b/internal/podcast/cover.go
@@ -0,0 +1,39 @@
+package podcast
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+)
+
+// DownloadCoverImage fetches a podcast cover image and saves it to the set folder as cover.jpg.
+func DownloadCoverImage(imageURL, setPath string) error {
+ if imageURL == "" {
+ return nil
+ }
+
+ resp, err := http.Get(imageURL)
+ if err != nil {
+ return fmt.Errorf("fetch cover image: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("fetch cover image: status %d", resp.StatusCode)
+ }
+
+ coverPath := filepath.Join(setPath, "cover.jpg")
+ f, err := os.Create(coverPath)
+ if err != nil {
+ return fmt.Errorf("create cover file: %w", err)
+ }
+ defer f.Close()
+
+ if _, err := io.Copy(f, resp.Body); err != nil {
+ return fmt.Errorf("write cover file: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/podcast/feed.go b/internal/podcast/feed.go
new file mode 100644
index 0000000..45d0257
--- /dev/null
+++ b/internal/podcast/feed.go
@@ -0,0 +1,117 @@
+// Package podcast implements RSS/Atom feed parsing and cover downloading.
+package podcast
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/mmcdole/gofeed"
+)
+
+// Episode represents a parsed podcast episode from a feed.
+type Episode struct {
+ GUID string
+ Title string
+ Description string
+ PublishedAt *time.Time
+ EpisodeURL string
+ DurationSeconds *float64
+ FileSize *int64
+}
+
+// ParsedFeed holds the result of parsing a podcast RSS/Atom feed.
+type ParsedFeed struct {
+ Title string
+ Description string
+ ImageURL string
+ Episodes []Episode
+}
+
+// ParseFeed fetches and parses a podcast RSS/Atom feed URL.
+func ParseFeed(url string) (*ParsedFeed, error) {
+ fp := gofeed.NewParser()
+ feed, err := fp.ParseURL(url)
+ if err != nil {
+ return nil, fmt.Errorf("parse feed %q: %w", url, err)
+ }
+
+ result := &ParsedFeed{
+ Title: feed.Title,
+ Description: feed.Description,
+ }
+
+ // Extract image URL from common RSS/Atom sources.
+ result.ImageURL = extractImageURL(feed)
+
+ for _, item := range feed.Items {
+ ep := Episode{
+ GUID: item.GUID,
+ Title: item.Title,
+ Description: item.Description,
+ }
+
+ if item.PublishedParsed != nil {
+ t := *item.PublishedParsed
+ ep.PublishedAt = &t
+ }
+
+ // Extract enclosure URL.
+ if len(item.Enclosures) > 0 {
+ ep.EpisodeURL = item.Enclosures[0].URL
+ if item.Enclosures[0].Length != "" {
+ var size int64
+ if _, err := fmt.Sscanf(item.Enclosures[0].Length, "%d", &size); err == nil {
+ ep.FileSize = &size
+ }
+ }
+ }
+
+ // Extract duration from iTunes extension if present.
+ if item.ITunesExt != nil && item.ITunesExt.Duration != "" {
+ dur := parseDuration(item.ITunesExt.Duration)
+ if dur > 0 {
+ ep.DurationSeconds = &dur
+ }
+ }
+
+ result.Episodes = append(result.Episodes, ep)
+ }
+
+ return result, nil
+}
+
+// extractImageURL looks for podcast cover images in RSS 2.0, Atom, and iTunes feed metadata.
+func extractImageURL(feed *gofeed.Feed) string {
+ // iTunes image (most common for podcasts).
+ if feed.ITunesExt != nil && feed.ITunesExt.Image != "" {
+ return feed.ITunesExt.Image
+ }
+
+ // RSS 2.0 <image><url>.
+ if feed.Image != nil && feed.Image.URL != "" {
+ return feed.Image.URL
+ }
+
+ return ""
+}
+
+// parseDuration converts an iTunes duration string (HH:MM:SS or MM:SS) to seconds.
+func parseDuration(s string) float64 {
+ parts := strings.Split(s, ":")
+ var hours, minutes, seconds int
+
+ switch len(parts) {
+ case 3:
+ fmt.Sscanf(parts[0], "%d", &hours)
+ fmt.Sscanf(parts[1], "%d", &minutes)
+ fmt.Sscanf(parts[2], "%d", &seconds)
+ case 2:
+ fmt.Sscanf(parts[0], "%d", &minutes)
+ fmt.Sscanf(parts[1], "%d", &seconds)
+ case 1:
+ fmt.Sscanf(parts[0], "%d", &seconds)
+ }
+
+ return float64(hours*3600 + minutes*60 + seconds)
+}
diff --git a/internal/repository/migrate.go b/internal/repository/migrate.go
index daf94c6..2896906 100644
--- a/internal/repository/migrate.go
+++ b/internal/repository/migrate.go
@@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS sets (
name TEXT NOT NULL,
root_path TEXT UNIQUE NOT NULL,
cover_thumbnail_path TEXT,
+ is_podcast INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -121,6 +122,46 @@ CREATE TABLE IF NOT EXISTS media_notes (
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(media_id, user_id)
);
+
+CREATE TABLE IF NOT EXISTS podcast_feeds (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ set_id INTEGER NOT NULL UNIQUE REFERENCES sets(id) ON DELETE CASCADE,
+ feed_url TEXT NOT NULL,
+ title TEXT,
+ description TEXT,
+ image_url TEXT,
+ last_checked_at DATETIME,
+ last_etag TEXT,
+ check_interval_minutes INTEGER NOT NULL DEFAULT 60,
+ auto_download INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS podcast_episodes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ feed_id INTEGER NOT NULL REFERENCES podcast_feeds(id) ON DELETE CASCADE,
+ media_id INTEGER UNIQUE REFERENCES media(id) ON DELETE SET NULL,
+ guid TEXT NOT NULL,
+ title TEXT,
+ description TEXT,
+ published_at DATETIME,
+ episode_url TEXT NOT NULL,
+ duration_seconds REAL,
+ file_size INTEGER,
+ file_name TEXT,
+ is_downloaded INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(feed_id, guid)
+);
+
+CREATE TABLE IF NOT EXISTS podcast_status (
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ episode_id INTEGER NOT NULL REFERENCES podcast_episodes(id) ON DELETE CASCADE,
+ is_completed INTEGER NOT NULL DEFAULT 0,
+ position_seconds REAL NOT NULL DEFAULT 0,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, episode_id)
+);
`
// indexesSchema defines all CREATE INDEX statements.
@@ -133,6 +174,10 @@ CREATE INDEX IF NOT EXISTS idx_media_filename ON media(file_name);
CREATE INDEX IF NOT EXISTS idx_permissions_user ON set_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_permissions_set ON set_permissions(set_id);
CREATE INDEX IF NOT EXISTS idx_shares_expires ON shares(expires_at);
+CREATE INDEX IF NOT EXISTS idx_podcast_episodes_feed ON podcast_episodes(feed_id);
+CREATE INDEX IF NOT EXISTS idx_podcast_episodes_media ON podcast_episodes(media_id);
+CREATE INDEX IF NOT EXISTS idx_podcast_status_episode ON podcast_status(episode_id);
+CREATE INDEX IF NOT EXISTS idx_sets_is_podcast ON sets(is_podcast);
`
// execSchema executes a raw SQL schema block against the given database.
diff --git a/internal/repository/mock.go b/internal/repository/mock.go
index abab4c8..d715984 100644
--- a/internal/repository/mock.go
+++ b/internal/repository/mock.go
@@ -27,6 +27,7 @@ var (
_ TrashServiceStore = (*MockStore)(nil)
_ UserAdminServiceStore = (*MockStore)(nil)
_ PermissionAdminServiceStore = (*MockStore)(nil)
+ _ PodcastRepo = (*MockStore)(nil)
)
// NewMockStore returns a MockStore with all no-op defaults.
@@ -49,6 +50,7 @@ type MockStore struct {
SessionRepo MockSessionRepo
ShareRepo MockShareRepo
NoteRepo MockNoteRepo
+ PodcastRepo MockPodcastRepo
}
// CreateUser implements UserRepo.
@@ -876,3 +878,233 @@ func (m *MockNoteRepo) DeleteNote(ctx context.Context, mediaID, userID int64) er
}
return nil
}
+
+// CreateFeed implements PodcastRepo.
+func (m *MockStore) CreateFeed(ctx context.Context, feed *model.PodcastFeed) (int64, error) {
+ return m.PodcastRepo.CreateFeed(ctx, feed)
+}
+
+// UpdateFeed implements PodcastRepo.
+func (m *MockStore) UpdateFeed(ctx context.Context, feed *model.PodcastFeed) error {
+ return m.PodcastRepo.UpdateFeed(ctx, feed)
+}
+
+// DeleteFeed implements PodcastRepo.
+func (m *MockStore) DeleteFeed(ctx context.Context, id int64) error {
+ return m.PodcastRepo.DeleteFeed(ctx, id)
+}
+
+// GetFeedByID implements PodcastRepo.
+func (m *MockStore) GetFeedByID(ctx context.Context, id int64) (*model.PodcastFeed, error) {
+ return m.PodcastRepo.GetFeedByID(ctx, id)
+}
+
+// GetFeedBySetID implements PodcastRepo.
+func (m *MockStore) GetFeedBySetID(ctx context.Context, setID int64) (*model.PodcastFeed, error) {
+ return m.PodcastRepo.GetFeedBySetID(ctx, setID)
+}
+
+// ListFeeds implements PodcastRepo.
+func (m *MockStore) ListFeeds(ctx context.Context) ([]model.PodcastFeed, error) {
+ return m.PodcastRepo.ListFeeds(ctx)
+}
+
+// ListFeedsNeedingCheck implements PodcastRepo.
+func (m *MockStore) ListFeedsNeedingCheck(ctx context.Context, before time.Time) ([]model.PodcastFeed, error) {
+ return m.PodcastRepo.ListFeedsNeedingCheck(ctx, before)
+}
+
+// CreateEpisode implements PodcastRepo.
+func (m *MockStore) CreateEpisode(ctx context.Context, episode *model.PodcastEpisode) (int64, error) {
+ return m.PodcastRepo.CreateEpisode(ctx, episode)
+}
+
+// GetEpisodeByID implements PodcastRepo.
+func (m *MockStore) GetEpisodeByID(ctx context.Context, id int64) (*model.PodcastEpisode, error) {
+ return m.PodcastRepo.GetEpisodeByID(ctx, id)
+}
+
+// GetEpisodeByGUID implements PodcastRepo.
+func (m *MockStore) GetEpisodeByGUID(ctx context.Context, feedID int64, guid string) (*model.PodcastEpisode, error) {
+ return m.PodcastRepo.GetEpisodeByGUID(ctx, feedID, guid)
+}
+
+// ListEpisodesByFeed implements PodcastRepo.
+func (m *MockStore) ListEpisodesByFeed(ctx context.Context, feedID int64, limit, offset int) ([]model.PodcastEpisode, error) {
+ return m.PodcastRepo.ListEpisodesByFeed(ctx, feedID, limit, offset)
+}
+
+// UpdateEpisodeMedia implements PodcastRepo.
+func (m *MockStore) UpdateEpisodeMedia(ctx context.Context, episodeID, mediaID int64, fileName string) error {
+ return m.PodcastRepo.UpdateEpisodeMedia(ctx, episodeID, mediaID, fileName)
+}
+
+// DeleteEpisodesByFeed implements PodcastRepo.
+func (m *MockStore) DeleteEpisodesByFeed(ctx context.Context, feedID int64) error {
+ return m.PodcastRepo.DeleteEpisodesByFeed(ctx, feedID)
+}
+
+// UpsertEpisodeProgress implements PodcastRepo.
+func (m *MockStore) UpsertEpisodeProgress(ctx context.Context, status *model.PodcastStatus) error {
+ return m.PodcastRepo.UpsertEpisodeProgress(ctx, status)
+}
+
+// GetEpisodeProgress implements PodcastRepo.
+func (m *MockStore) GetEpisodeProgress(ctx context.Context, userID, episodeID int64) (*model.PodcastStatus, error) {
+ return m.PodcastRepo.GetEpisodeProgress(ctx, userID, episodeID)
+}
+
+// ListEpisodesWithStatus implements PodcastRepo.
+func (m *MockStore) ListEpisodesWithStatus(ctx context.Context, userID, feedID int64, limit, offset int) ([]model.PodcastEpisodeWithStatus, error) {
+ return m.PodcastRepo.ListEpisodesWithStatus(ctx, userID, feedID, limit, offset)
+}
+
+// MockPodcastRepo is a fake PodcastRepo.
+type MockPodcastRepo struct {
+ CreateFeedFunc func(ctx context.Context, feed *model.PodcastFeed) (int64, error)
+ UpdateFeedFunc func(ctx context.Context, feed *model.PodcastFeed) error
+ 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)
+ 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)
+ 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
+ DeleteEpisodesByFeedFunc func(ctx context.Context, feedID int64) error
+
+ UpsertEpisodeProgressFunc func(ctx context.Context, status *model.PodcastStatus) error
+ GetEpisodeProgressFunc func(ctx context.Context, userID, episodeID int64) (*model.PodcastStatus, error)
+ ListEpisodesWithStatusFunc func(ctx context.Context, userID, feedID int64, limit, offset int) ([]model.PodcastEpisodeWithStatus, error)
+}
+
+// CreateFeed calls CreateFeedFunc or returns a default ID.
+func (m *MockPodcastRepo) CreateFeed(ctx context.Context, feed *model.PodcastFeed) (int64, error) {
+ if m.CreateFeedFunc != nil {
+ return m.CreateFeedFunc(ctx, feed)
+ }
+ return 1, nil
+}
+
+// UpdateFeed calls UpdateFeedFunc or returns nil.
+func (m *MockPodcastRepo) UpdateFeed(ctx context.Context, feed *model.PodcastFeed) error {
+ if m.UpdateFeedFunc != nil {
+ return m.UpdateFeedFunc(ctx, feed)
+ }
+ return nil
+}
+
+// DeleteFeed calls DeleteFeedFunc or returns nil.
+func (m *MockPodcastRepo) DeleteFeed(ctx context.Context, id int64) error {
+ if m.DeleteFeedFunc != nil {
+ return m.DeleteFeedFunc(ctx, id)
+ }
+ return nil
+}
+
+// GetFeedByID calls GetFeedByIDFunc or returns nil.
+func (m *MockPodcastRepo) GetFeedByID(ctx context.Context, id int64) (*model.PodcastFeed, error) {
+ if m.GetFeedByIDFunc != nil {
+ return m.GetFeedByIDFunc(ctx, id)
+ }
+ return nil, nil
+}
+
+// GetFeedBySetID calls GetFeedBySetIDFunc or returns nil.
+func (m *MockPodcastRepo) GetFeedBySetID(ctx context.Context, setID int64) (*model.PodcastFeed, error) {
+ if m.GetFeedBySetIDFunc != nil {
+ return m.GetFeedBySetIDFunc(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 {
+ return m.ListFeedsFunc(ctx)
+ }
+ return nil, nil
+}
+
+// ListFeedsNeedingCheck calls ListFeedsNeedingCheckFunc or returns nil.
+func (m *MockPodcastRepo) ListFeedsNeedingCheck(ctx context.Context, before time.Time) ([]model.PodcastFeed, error) {
+ if m.ListFeedsNeedingCheckFunc != nil {
+ return m.ListFeedsNeedingCheckFunc(ctx, before)
+ }
+ return nil, nil
+}
+
+// CreateEpisode calls CreateEpisodeFunc or returns a default ID.
+func (m *MockPodcastRepo) CreateEpisode(ctx context.Context, episode *model.PodcastEpisode) (int64, error) {
+ if m.CreateEpisodeFunc != nil {
+ return m.CreateEpisodeFunc(ctx, episode)
+ }
+ return 1, nil
+}
+
+// GetEpisodeByID calls GetEpisodeByIDFunc or returns nil.
+func (m *MockPodcastRepo) GetEpisodeByID(ctx context.Context, id int64) (*model.PodcastEpisode, error) {
+ if m.GetEpisodeByIDFunc != nil {
+ return m.GetEpisodeByIDFunc(ctx, id)
+ }
+ return nil, nil
+}
+
+// GetEpisodeByGUID calls GetEpisodeByGUIDFunc or returns nil.
+func (m *MockPodcastRepo) GetEpisodeByGUID(ctx context.Context, feedID int64, guid string) (*model.PodcastEpisode, error) {
+ if m.GetEpisodeByGUIDFunc != nil {
+ return m.GetEpisodeByGUIDFunc(ctx, feedID, guid)
+ }
+ return nil, nil
+}
+
+// ListEpisodesByFeed calls ListEpisodesByFeedFunc or returns nil.
+func (m *MockPodcastRepo) ListEpisodesByFeed(ctx context.Context, feedID int64, limit, offset int) ([]model.PodcastEpisode, error) {
+ if m.ListEpisodesByFeedFunc != nil {
+ return m.ListEpisodesByFeedFunc(ctx, feedID, limit, offset)
+ }
+ return nil, nil
+}
+
+// UpdateEpisodeMedia calls UpdateEpisodeMediaFunc or returns nil.
+func (m *MockPodcastRepo) UpdateEpisodeMedia(ctx context.Context, episodeID, mediaID int64, fileName string) error {
+ if m.UpdateEpisodeMediaFunc != nil {
+ return m.UpdateEpisodeMediaFunc(ctx, episodeID, mediaID, fileName)
+ }
+ return nil
+}
+
+// DeleteEpisodesByFeed calls DeleteEpisodesByFeedFunc or returns nil.
+func (m *MockPodcastRepo) DeleteEpisodesByFeed(ctx context.Context, feedID int64) error {
+ if m.DeleteEpisodesByFeedFunc != nil {
+ return m.DeleteEpisodesByFeedFunc(ctx, feedID)
+ }
+ return nil
+}
+
+// UpsertEpisodeProgress calls UpsertEpisodeProgressFunc or returns nil.
+func (m *MockPodcastRepo) UpsertEpisodeProgress(ctx context.Context, status *model.PodcastStatus) error {
+ if m.UpsertEpisodeProgressFunc != nil {
+ return m.UpsertEpisodeProgressFunc(ctx, status)
+ }
+ return nil
+}
+
+// GetEpisodeProgress calls GetEpisodeProgressFunc or returns nil.
+func (m *MockPodcastRepo) GetEpisodeProgress(ctx context.Context, userID, episodeID int64) (*model.PodcastStatus, error) {
+ if m.GetEpisodeProgressFunc != nil {
+ return m.GetEpisodeProgressFunc(ctx, userID, episodeID)
+ }
+ return nil, nil
+}
+
+// ListEpisodesWithStatus calls ListEpisodesWithStatusFunc or returns nil.
+func (m *MockPodcastRepo) ListEpisodesWithStatus(ctx context.Context, userID, feedID int64, limit, offset int) ([]model.PodcastEpisodeWithStatus, error) {
+ if m.ListEpisodesWithStatusFunc != nil {
+ return m.ListEpisodesWithStatusFunc(ctx, userID, feedID, limit, offset)
+ }
<