diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-05 14:24:23 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-05 14:24:23 +0300 |
| commit | 7bc3b65b3c66c7744e1c6d4fa69842985d06d9b7 (patch) | |
| tree | fa395b13fde18ba42bf7d5b28ad9d630cfa287cc | |
| parent | ed208d7120b6e6a7c2ce234504228b5a1ba48f11 (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.go | 18 | ||||
| -rw-r--r-- | internal/api/handlers_podcast.go | 2 | ||||
| -rw-r--r-- | internal/config.go | 11 | ||||
| -rw-r--r-- | internal/podcast/cover.go | 4 | ||||
| -rw-r--r-- | internal/podcast/feed.go | 20 | ||||
| -rw-r--r-- | internal/repository/podcast.go | 21 | ||||
| -rw-r--r-- | internal/repository/sqlite.go | 4 | ||||
| -rw-r--r-- | internal/service/browse.go | 2 | ||||
| -rw-r--r-- | internal/service/filename.go | 31 | ||||
| -rw-r--r-- | internal/service/podcast.go | 46 | ||||
| -rw-r--r-- | internal/service/write.go | 23 | ||||
| -rw-r--r-- | web/js/app.js | 7 | ||||
| -rw-r--r-- | web/js/podcasts.js | 61 |
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> |
