summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-05 14:24:23 +0300
committerPaul Buetow <paul@buetow.org>2026-05-05 14:24:23 +0300
commit7bc3b65b3c66c7744e1c6d4fa69842985d06d9b7 (patch)
treefa395b13fde18ba42bf7d5b28ad9d630cfa287cc
parented208d7120b6e6a7c2ce234504228b5a1ba48f11 (diff)
Fix podcast support critical bugs from code review
- Fix boolean scanning from SQLite INTEGER columns (intToBool helper) - Fix BrowseSet LIMIT 0 returning zero episodes (use 1000 instead) - Fix checkFeed double-fetch by parsing from resp.Body directly + Add ParseFeedReader for body reuse - Fix file handle leak in DownloadEpisode (explicit Close before ImportMediaFile) - Fix incomplete rollback in DownloadEpisode (remove file + delete media row) - Fix 204 No Content handler writing 'null' body - Fix DownloadCoverImage to accept *http.Client with timeout - Deduplicate uniqueFilename into shared internal/service/filename.go - Wire frontend renderPodcastEpisodes into renderBrowse from API data - Refactor podcasts.js: remove extra API call, fix toggle toast, remove duplicate toast - Add Config.PodcastCheckMinutes + PODCAST_CHECK_INTERVAL_MINUTES env var - Start background CheckFeeds goroutine in main.go with ticker - Update NewPodcastService to accept checkInterval parameter - Log errors from CheckFeeds and episode creation instead of silently discarding
-rw-r--r--cmd/player/main.go18
-rw-r--r--internal/api/handlers_podcast.go2
-rw-r--r--internal/config.go11
-rw-r--r--internal/podcast/cover.go4
-rw-r--r--internal/podcast/feed.go20
-rw-r--r--internal/repository/podcast.go21
-rw-r--r--internal/repository/sqlite.go4
-rw-r--r--internal/service/browse.go2
-rw-r--r--internal/service/filename.go31
-rw-r--r--internal/service/podcast.go46
-rw-r--r--internal/service/write.go23
-rw-r--r--web/js/app.js7
-rw-r--r--web/js/podcasts.js61
13 files changed, 151 insertions, 99 deletions
diff --git a/cmd/player/main.go b/cmd/player/main.go
index b0fec6c..0a7bacb 100644
--- a/cmd/player/main.go
+++ b/cmd/player/main.go
@@ -95,11 +95,27 @@ func wireDeps(cfg *internal.Config, store repository.Store, logger *slog.Logger,
authSvc := service.NewAuthService(store, clk, hasher, sm)
helper := service.NewAccessHelper(store)
- podcastSvc := service.NewPodcastService(store, clk, cfg.MediaRoot, helper, prober, thumbGen)
+ podcastSvc := service.NewPodcastService(store, clk, cfg.MediaRoot, helper, prober, thumbGen, cfg.PodcastCheckMinutes)
gcWorker := service.NewGCWorker(store, clk, cfg.MediaRoot, time.Duration(cfg.GCIntervalMinutes)*time.Minute, logger)
gcWorker.Start()
+ // Start podcast feed background checker.
+ go func() {
+ ticker := time.NewTicker(time.Duration(cfg.PodcastCheckMinutes) * time.Minute)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ if err := podcastSvc.CheckFeeds(context.Background()); err != nil {
+ logger.Error("podcast feed check failed", "err", err)
+ }
+ case <-appCtx.Done():
+ return
+ }
+ }
+ }()
+
return &appDeps{
store: store,
hasher: hasher,
diff --git a/internal/api/handlers_podcast.go b/internal/api/handlers_podcast.go
index 53b3ea2..43f724c 100644
--- a/internal/api/handlers_podcast.go
+++ b/internal/api/handlers_podcast.go
@@ -160,5 +160,5 @@ func (s *Server) handleToggleComplete(w http.ResponseWriter, r *http.Request) {
return
}
- writeJSON(w, http.StatusNoContent, nil)
+ w.WriteHeader(http.StatusNoContent)
}
diff --git a/internal/config.go b/internal/config.go
index ee63ccf..1100503 100644
--- a/internal/config.go
+++ b/internal/config.go
@@ -30,6 +30,7 @@ type Config struct {
SessionTimeoutHours int
GCIntervalMinutes int
ShareDefaultExpiryDays int
+ PodcastCheckMinutes int
LogLevel string
SecureCookies bool
}
@@ -77,6 +78,7 @@ func defaultConfig() *Config {
SessionTimeoutHours: DefaultSessionTimeoutHours,
GCIntervalMinutes: DefaultGCIntervalMinutes,
ShareDefaultExpiryDays: DefaultShareDefaultExpiryDays,
+ PodcastCheckMinutes: DefaultPodcastCheckMinutes,
LogLevel: DefaultLogLevel,
SecureCookies: DefaultSecureCookies,
}
@@ -131,6 +133,15 @@ func loadNumericSettings(cfg *Config) error {
return err
}
+ if err := envInt("PODCAST_CHECK_INTERVAL_MINUTES", func(n int) error {
+ if n < 1 {
+ return fmt.Errorf("must be >= 1, got %d", n)
+ }
+ return nil
+ }, func(n int) { cfg.PodcastCheckMinutes = n }); err != nil {
+ return err
+ }
+
return nil
}
diff --git a/internal/podcast/cover.go b/internal/podcast/cover.go
index f7792b5..2dd019f 100644
--- a/internal/podcast/cover.go
+++ b/internal/podcast/cover.go
@@ -9,12 +9,12 @@ import (
)
// DownloadCoverImage fetches a podcast cover image and saves it to the set folder as cover.jpg.
-func DownloadCoverImage(imageURL, setPath string) error {
+func DownloadCoverImage(client *http.Client, imageURL, setPath string) error {
if imageURL == "" {
return nil
}
- resp, err := http.Get(imageURL)
+ resp, err := client.Get(imageURL)
if err != nil {
return fmt.Errorf("fetch cover image: %w", err)
}
diff --git a/internal/podcast/feed.go b/internal/podcast/feed.go
index 45d0257..a058216 100644
--- a/internal/podcast/feed.go
+++ b/internal/podcast/feed.go
@@ -3,6 +3,7 @@ package podcast
import (
"fmt"
+ "io"
"strings"
"time"
@@ -35,15 +36,26 @@ func ParseFeed(url string) (*ParsedFeed, error) {
if err != nil {
return nil, fmt.Errorf("parse feed %q: %w", url, err)
}
+ return parsedFeedFromGoFeed(feed), nil
+}
+// ParseFeedReader reads and parses a podcast RSS/Atom feed from an io.Reader.
+func ParseFeedReader(r io.Reader) (*ParsedFeed, error) {
+ fp := gofeed.NewParser()
+ feed, err := fp.Parse(r)
+ if err != nil {
+ return nil, fmt.Errorf("parse feed: %w", err)
+ }
+ return parsedFeedFromGoFeed(feed), nil
+}
+
+func parsedFeedFromGoFeed(feed *gofeed.Feed) *ParsedFeed {
result := &ParsedFeed{
Title: feed.Title,
Description: feed.Description,
+ ImageURL: extractImageURL(feed),
}
- // Extract image URL from common RSS/Atom sources.
- result.ImageURL = extractImageURL(feed)
-
for _, item := range feed.Items {
ep := Episode{
GUID: item.GUID,
@@ -78,7 +90,7 @@ func ParseFeed(url string) (*ParsedFeed, error) {
result.Episodes = append(result.Episodes, ep)
}
- return result, nil
+ return result
}
// extractImageURL looks for podcast cover images in RSS 2.0, Atom, and iTunes feed metadata.
diff --git a/internal/repository/podcast.go b/internal/repository/podcast.go
index be9916d..0e9887b 100644
--- a/internal/repository/podcast.go
+++ b/internal/repository/podcast.go
@@ -53,13 +53,15 @@ func scanFeed(row sqlScanner) (*model.PodcastFeed, error) {
var f model.PodcastFeed
var title, description, imageURL, lastETag sql.NullString
var lastChecked sql.NullTime
- err := row.Scan(&f.ID, &f.SetID, &f.FeedURL, &title, &description, &imageURL, &lastChecked, &lastETag, &f.CheckIntervalMinutes, &f.AutoDownload, &f.CreatedAt)
+ var autoDownloadInt int
+ err := row.Scan(&f.ID, &f.SetID, &f.FeedURL, &title, &description, &imageURL, &lastChecked, &lastETag, &f.CheckIntervalMinutes, &autoDownloadInt, &f.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
+ f.AutoDownload = intToBool(autoDownloadInt)
f.Title = title.String
f.Description = description.String
f.ImageURL = imageURL.String
@@ -164,13 +166,15 @@ func scanEpisode(row sqlScanner) (*model.PodcastEpisode, error) {
var published sql.NullTime
var duration sql.NullFloat64
var fileSize sql.NullInt64
- err := row.Scan(&e.ID, &e.FeedID, &mediaID, &e.GUID, &title, &description, &published, &e.EpisodeURL, &duration, &fileSize, &fileName, &e.IsDownloaded, &e.CreatedAt)
+ var isDownloadedInt int
+ err := row.Scan(&e.ID, &e.FeedID, &mediaID, &e.GUID, &title, &description, &published, &e.EpisodeURL, &duration, &fileSize, &fileName, &isDownloadedInt, &e.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
+ e.IsDownloaded = intToBool(isDownloadedInt)
if mediaID.Valid {
e.MediaID = &mediaID.Int64
}
@@ -267,15 +271,17 @@ func (s *SQLite) UpsertEpisodeProgress(ctx context.Context, status *model.Podcas
// GetEpisodeProgress returns a user's status for an episode.
func (s *SQLite) GetEpisodeProgress(ctx context.Context, userID, episodeID int64) (*model.PodcastStatus, error) {
var st model.PodcastStatus
+ var isCompleted int
err := s.db.QueryRowContext(ctx,
`SELECT user_id, episode_id, is_completed, position_seconds, updated_at FROM podcast_status WHERE user_id = ? AND episode_id = ?`,
- userID, episodeID).Scan(&st.UserID, &st.EpisodeID, &st.IsCompleted, &st.PositionSeconds, &st.UpdatedAt)
+ userID, episodeID).Scan(&st.UserID, &st.EpisodeID, &isCompleted, &st.PositionSeconds, &st.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get podcast status: %w", err)
}
+ st.IsCompleted = intToBool(isCompleted)
return &st, nil
}
@@ -302,10 +308,17 @@ func (s *SQLite) ListEpisodesWithStatus(ctx context.Context, userID, feedID int6
var published sql.NullTime
var duration sql.NullFloat64
var fileSize sql.NullInt64
- err := rows.Scan(&e.ID, &e.FeedID, &mediaID, &e.GUID, &title, &description, &published, &e.EpisodeURL, &duration, &fileSize, &fileName, &e.IsDownloaded, &e.CreatedAt, &e.IsCompleted, &e.PositionSeconds)
+ var isDownloadedInt, isCompletedInt int
+ var positionSeconds sql.NullFloat64
+ err := rows.Scan(&e.ID, &e.FeedID, &mediaID, &e.GUID, &title, &description, &published, &e.EpisodeURL, &duration, &fileSize, &fileName, &isDownloadedInt, &e.CreatedAt, &isCompletedInt, &positionSeconds)
if err != nil {
return nil, err
}
+ e.IsDownloaded = intToBool(isDownloadedInt)
+ e.IsCompleted = intToBool(isCompletedInt)
+ if positionSeconds.Valid {
+ e.PositionSeconds = positionSeconds.Float64
+ }
if mediaID.Valid {
e.MediaID = &mediaID.Int64
}
diff --git a/internal/repository/sqlite.go b/internal/repository/sqlite.go
index 42a1a19..d324f8c 100644
--- a/internal/repository/sqlite.go
+++ b/internal/repository/sqlite.go
@@ -63,3 +63,7 @@ func boolToInt(b bool) int {
}
return 0
}
+
+func intToBool(i int) bool {
+ return i != 0
+}
diff --git a/internal/service/browse.go b/internal/service/browse.go
index 230624f..d1f32cc 100644
--- a/internal/service/browse.go
+++ b/internal/service/browse.go
@@ -416,7 +416,7 @@ func (s *browseService) BrowseSet(ctx context.Context, setID, userID int64, pare
if set.IsPodcast {
feed, err := s.store.GetFeedBySetID(ctx, setID)
if err == nil && feed != nil {
- episodes, err := s.store.ListEpisodesWithStatus(ctx, userID, feed.ID, 0, 0)
+ episodes, err := s.store.ListEpisodesWithStatus(ctx, userID, feed.ID, 1000, 0)
if err == nil {
result.Episodes = episodes
}
diff --git a/internal/service/filename.go b/internal/service/filename.go
new file mode 100644
index 0000000..9303de6
--- /dev/null
+++ b/internal/service/filename.go
@@ -0,0 +1,31 @@
+package service
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// uniqueFilename returns a non-conflicting full path by appending (n)
+// if a file with the same name already exists in dir.
+func uniqueFilename(dir, filename string) string {
+ filename = filepath.Base(filename)
+ if filename == "." || filename == ".." || filename == "" {
+ return ""
+ }
+ ext := filepath.Ext(filename)
+ base := strings.TrimSuffix(filename, ext)
+
+ candidate := filepath.Join(dir, filename)
+ if _, err := os.Stat(candidate); os.IsNotExist(err) {
+ return candidate
+ }
+
+ for i := 1; ; i++ {
+ candidate = filepath.Join(dir, fmt.Sprintf("%s(%d)%s", base, i, ext))
+ if _, err := os.Stat(candidate); os.IsNotExist(err) {
+ return candidate
+ }
+ }
+}
diff --git a/internal/service/podcast.go b/internal/service/podcast.go
index 4a6a3ae..5e56860 100644
--- a/internal/service/podcast.go
+++ b/internal/service/podcast.go
@@ -75,7 +75,12 @@ type podcastService struct {
}
// NewPodcastService creates a PodcastService with the given dependencies.
-func NewPodcastService(store PodcastServiceStore, clk clock.Clock, mediaRoot string, helper *accessHelper, prober probe.Prober, thumbGen thumb.Generator) *podcastService {
+// NewPodcastService creates a PodcastService with the given dependencies.
+// checkInterval should be the number of minutes between background feed checks.
+func NewPodcastService(store PodcastServiceStore, clk clock.Clock, mediaRoot string, helper *accessHelper, prober probe.Prober, thumbGen thumb.Generator, checkInterval int) *podcastService {
+ if checkInterval <= 0 {
+ checkInterval = 60
+ }
return &podcastService{
store: store,
clock: clk,
@@ -84,7 +89,7 @@ func NewPodcastService(store PodcastServiceStore, clk clock.Clock, mediaRoot str
prober: prober,
thumbGen: thumbGen,
httpClient: &http.Client{Timeout: 30 * time.Second},
- checkInterval: 60,
+ checkInterval: checkInterval,
}
}
@@ -166,7 +171,7 @@ func (s *podcastService) SubscribeFeed(ctx context.Context, feedURL, setName str
// Download cover image.
if parsed.ImageURL != "" {
- _ = podcast.DownloadCoverImage(parsed.ImageURL, setPath)
+ _ = podcast.DownloadCoverImage(s.httpClient, parsed.ImageURL, setPath)
}
// Insert episodes.
@@ -335,13 +340,17 @@ func (s *podcastService) DownloadEpisode(ctx context.Context, episodeID, userID
if err != nil {
return nil, fmt.Errorf("create file: %w", err)
}
- defer f.Close()
n, err := io.Copy(f, resp.Body)
if err != nil {
+ f.Close()
os.Remove(path)
return nil, fmt.Errorf("write file: %w", err)
}
+ if err := f.Close(); err != nil {
+ os.Remove(path)
+ return nil, fmt.Errorf("close file: %w", err)
+ }
// Create media row.
media := &model.Media{
@@ -369,6 +378,8 @@ func (s *podcastService) DownloadEpisode(ctx context.Context, episodeID, userID
// Link episode to media row.
if err := s.store.UpdateEpisodeMedia(ctx, episode.ID, media.ID, filepath.Base(path)); err != nil {
+ os.Remove(path)
+ _ = s.store.HardDeleteMedia(ctx, media.ID)
return nil, fmt.Errorf("update episode media: %w", err)
}
@@ -441,7 +452,7 @@ func (s *podcastService) CheckFeeds(ctx context.Context) error {
func (s *podcastService) checkFeed(ctx context.Context, feed model.PodcastFeed) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feed.FeedURL, nil)
if err != nil {
- return err
+ return fmt.Errorf("build request for feed %d: %w", feed.ID, err)
}
// Conditional GET headers.
@@ -468,7 +479,7 @@ func (s *podcastService) checkFeed(ctx context.Context, feed model.PodcastFeed)
return fmt.Errorf("feed check status %d", resp.StatusCode)
}
- parsed, err := podcast.ParseFeed(feed.FeedURL)
+ parsed, err := podcast.ParseFeedReader(resp.Body)
if err != nil {
return err
}
@@ -512,7 +523,7 @@ func (s *podcastService) checkFeed(ctx context.Context, feed model.PodcastFeed)
set, err := s.store.GetSetByID(ctx, feed.SetID)
if err == nil && set != nil {
setPath := filepath.Join(s.mediaRoot, set.RootPath)
- _ = podcast.DownloadCoverImage(parsed.ImageURL, setPath)
+ _ = podcast.DownloadCoverImage(s.httpClient, parsed.ImageURL, setPath)
}
}
@@ -539,24 +550,3 @@ func sanitizeFilename(name string) string {
name = strings.TrimSpace(name)
return name
}
-
-func uniqueFilename(dir, filename string) string {
- filename = filepath.Base(filename)
- if filename == "." || filename == ".." || filename == "" {
- return ""
- }
- ext := filepath.Ext(filename)
- base := strings.TrimSuffix(filename, ext)
-
- candidate := filepath.Join(dir, filename)
- if _, err := os.Stat(candidate); os.IsNotExist(err) {
- return candidate
- }
-
- for i := 1; ; i++ {
- candidate = filepath.Join(dir, fmt.Sprintf("%s(%d)%s", base, i, ext))
- if _, err := os.Stat(candidate); os.IsNotExist(err) {
- return candidate
- }
- }
-}
diff --git a/internal/service/write.go b/internal/service/write.go
index 51abf22..a83c7e1 100644
--- a/internal/service/write.go
+++ b/internal/service/write.go
@@ -77,7 +77,7 @@ func (s *writeService) UploadMedia(ctx context.Context, setID, userID int64, fil
return nil, fmt.Errorf("mkdir: %w", err)
}
- path := s.uniqueFilename(dir, filename)
+ path := uniqueFilename(dir, filename)
if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(dir)+string(filepath.Separator)) {
return nil, errors.New("invalid filename")
}
@@ -97,27 +97,6 @@ func (s *writeService) UploadMedia(ctx context.Context, setID, userID int64, fil
return media, nil
}
-func (s *writeService) uniqueFilename(dir, filename string) string {
- filename = filepath.Base(filename)
- if filename == "." || filename == ".." || filename == "" {
- return ""
- }
- ext := filepath.Ext(filename)
- base := strings.TrimSuffix(filename, ext)
-
- candidate := filepath.Join(dir, filename)
- if _, err := os.Stat(candidate); os.IsNotExist(err) {
- return candidate
- }
-
- for i := 1; ; i++ {
- candidate = filepath.Join(dir, fmt.Sprintf("%s(%d)%s", base, i, ext))
- if _, err := os.Stat(candidate); os.IsNotExist(err) {
- return candidate
- }
- }
-}
-
func (s *writeService) saveUploadedMedia(ctx context.Context, setID int64, path string, data io.Reader, size int64) (*model.Media, error) {
f, err := os.Create(path)
if err != nil {
diff --git a/web/js/app.js b/web/js/app.js
index fb6e787..9c769c9 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -474,6 +474,13 @@ function renderBrowse(data) {
tagBtn?.addEventListener('click', (e) => { e.stopPropagation(); openTagsForElement(el); });
thumbBtn?.addEventListener('click', (e) => { e.stopPropagation(); regenThumb(el.dataset.id); });
});
+
+ // For podcast sets, render episodes from the browse response after media.
+ if (data.episodes && data.episodes.length) {
+ import('./podcasts.js').then(m => {
+ m.renderPodcastEpisodes(grid, data.episodes);
+ }).catch(() => {});
+ }
}
function renderFolder(folder, index) {
diff --git a/web/js/podcasts.js b/web/js/podcasts.js
index c683159..690b24e 100644
--- a/web/js/podcasts.js
+++ b/web/js/podcasts.js
@@ -56,44 +56,33 @@ export function initPodcasts() {
}
}
-export function insertPodcastEpisodes(state) {
- if (!state.selectedSetId || state.selectedSetIds.length !== 1) return;
- const set = state.sets.find(s => s.id === state.selectedSetId);
- if (!set || !set.is_podcast) return;
+export function renderPodcastEpisodes(grid, episodes) {
+ if (!episodes || !episodes.length) return;
+ const divider = document.createElement('div');
+ divider.className = 'grid-divider';
+ divider.textContent = 'Podcast Episodes';
+ grid.appendChild(divider);
- const grid = document.getElementById('media-grid');
- if (!grid) return;
+ episodes.forEach(ep => {
+ const card = document.createElement('div');
+ card.className = 'media-card episode-card';
+ card.dataset.id = ep.id;
+ card.innerHTML = renderEpisodeHtml(ep);
+ grid.appendChild(card);
- API.podcastEpisodes(state.selectedSetId).then(episodes => {
- if (!episodes || !episodes.length) return;
- const divider = document.createElement('div');
- divider.className = 'grid-divider';
- divider.textContent = 'Podcast Episodes';
- grid.appendChild(divider);
-
- episodes.forEach(ep => {
- const card = document.createElement('div');
- card.className = 'media-card episode-card';
- card.innerHTML = renderEpisodeHtml(ep);
- grid.appendChild(card);
-
- const downloadBtn = card.querySelector('.btn-download-episode');
- const completeBtn = card.querySelector('.btn-complete');
- downloadBtn?.addEventListener('click', async () => {
- try {
- await API.downloadEpisode(ep.id);
- toast('Download started');
- } catch (err) { toast(err.message || 'Download failed', 'error'); }
- });
- completeBtn?.addEventListener('click', async () => {
- try {
- await API.toggleEpisodeComplete(ep.id);
- completeBtn.classList.toggle('active');
- toast(ep.is_completed ? 'Marked unlistened' : 'Marked listened');
- } catch (err) { toast(err.message || 'Toggle failed', 'error'); }
- });
+ const playBtn = card.querySelector('[data-action="play"]');
+ const completeBtn = card.querySelector('.btn-complete');
+ playBtn?.addEventListener('click', () => {
+ toast(ep.is_downloaded ? 'Play from downloads' : 'Download first to play', 'info');
});
- }).catch(() => {}); // silently ignore errors
+ completeBtn?.addEventListener('click', async () => {
+ try {
+ const res = await API.toggleEpisodeComplete(ep.id);
+ completeBtn.classList.toggle('active');
+ toast(res.is_completed ? 'Marked listened' : 'Marked unlistened');
+ } catch (err) { toast(err.message || 'Toggle failed', 'error'); }
+ });
+ });
}
function renderEpisodeHtml(ep) {
@@ -105,7 +94,7 @@ function renderEpisodeHtml(ep) {
<span class="placeholder">🎙️</span>
<span class="badge">${dateStr}${duration ? ' • ' + duration : ''}</span>
<div class="card-actions">
- <button class="icon-btn btn-sm btn-download-episode" title="Download to server">⬇</button>
+ <button class="icon-btn btn-sm" data-action="play" title="Play">▶</button>
<button class="icon-btn btn-sm btn-complete${completed ? ' active' : ''}" title="Mark as listened">✓</button>
</div>
</div>