diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/api/handlers_podcast.go | 164 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 52 | ||||
| -rw-r--r-- | internal/api/server.go | 16 | ||||
| -rw-r--r-- | internal/config.go | 5 | ||||
| -rw-r--r-- | internal/model/media.go | 1 | ||||
| -rw-r--r-- | internal/model/podcast.go | 52 | ||||
| -rw-r--r-- | internal/podcast/cover.go | 39 | ||||
| -rw-r--r-- | internal/podcast/feed.go | 117 | ||||
| -rw-r--r-- | internal/repository/migrate.go | 45 | ||||
| -rw-r--r-- | internal/repository/mock.go | 232 | ||||
| -rw-r--r-- | internal/repository/podcast.go | 329 | ||||
| -rw-r--r-- | internal/repository/podcast_repo.go | 46 | ||||
| -rw-r--r-- | internal/repository/podcast_test.go | 169 | ||||
| -rw-r--r-- | internal/repository/repository.go | 3 | ||||
| -rw-r--r-- | internal/repository/set.go | 16 | ||||
| -rw-r--r-- | internal/repository/sqlite.go | 1 | ||||
| -rw-r--r-- | internal/service/access.go | 5 | ||||
| -rw-r--r-- | internal/service/browse.go | 17 | ||||
| -rw-r--r-- | internal/service/import.go | 92 | ||||
| -rw-r--r-- | internal/service/podcast.go | 562 | ||||
| -rw-r--r-- | internal/service/service.go | 7 | ||||
| -rw-r--r-- | internal/service/write.go | 65 |
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) + } < |
