diff --git a/internal/model/feed.go b/internal/model/feed.go index 1f87b416..9e2b89e8 100644 --- a/internal/model/feed.go +++ b/internal/model/feed.go @@ -49,14 +49,18 @@ type Feed struct { IgnoreHTTPCache bool `json:"ignore_http_cache"` AllowSelfSignedCertificates bool `json:"allow_self_signed_certificates"` FetchViaProxy bool `json:"fetch_via_proxy"` - Category *Category `json:"category,omitempty"` - Entries Entries `json:"entries,omitempty"` - IconURL string `json:"-"` - Icon *FeedIcon `json:"icon"` HideGlobally bool `json:"hide_globally"` - UnreadCount int `json:"-"` - ReadCount int `json:"-"` AppriseServiceURLs string `json:"apprise_service_urls"` + + // Non persisted attributes + Category *Category `json:"category,omitempty"` + Icon *FeedIcon `json:"icon"` + Entries Entries `json:"entries,omitempty"` + + TTL int `json:"-"` + IconURL string `json:"-"` + UnreadCount int `json:"-"` + ReadCount int `json:"-"` } type FeedCounters struct { diff --git a/internal/reader/handler/handler.go b/internal/reader/handler/handler.go index 4a87f92e..cfd03ce1 100644 --- a/internal/reader/handler/handler.go +++ b/internal/reader/handler/handler.go @@ -5,6 +5,7 @@ package handler // import "miniflux.app/v2/internal/reader/handler" import ( "log/slog" + "time" "miniflux.app/v2/internal/config" "miniflux.app/v2/internal/errors" @@ -185,6 +186,28 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool return parseErr } + // If the feed has a TTL defined, we use it to make sure we don't check it too often. + if updatedFeed.TTL > 0 { + minNextCheckAt := time.Now().Add(time.Minute * time.Duration(updatedFeed.TTL)) + slog.Debug("Feed TTL", + slog.Int64("user_id", userID), + slog.Int64("feed_id", feedID), + slog.Int("ttl", updatedFeed.TTL), + slog.Time("next_check_at", originalFeed.NextCheckAt), + ) + + if originalFeed.NextCheckAt.IsZero() || originalFeed.NextCheckAt.Before(minNextCheckAt) { + slog.Debug("Updating next check date based on TTL", + slog.Int64("user_id", userID), + slog.Int64("feed_id", feedID), + slog.Int("ttl", updatedFeed.TTL), + slog.Time("new_next_check_at", minNextCheckAt), + slog.Time("old_next_check_at", originalFeed.NextCheckAt), + ) + originalFeed.NextCheckAt = minNextCheckAt + } + } + originalFeed.Entries = updatedFeed.Entries processor.ProcessFeedEntries(store, originalFeed, user, forceRefresh) diff --git a/internal/reader/rss/parser_test.go b/internal/reader/rss/parser_test.go index 7846e89d..56486060 100644 --- a/internal/reader/rss/parser_test.go +++ b/internal/reader/rss/parser_test.go @@ -1500,3 +1500,51 @@ func TestParseEntryWithCategoryAndCDATA(t *testing.T) { t.Errorf("Incorrect entry category, got %q instead of %q", result, expected) } } + +func TestParseFeedWithTTLField(t *testing.T) { + data := ` + + + Example + https://example.org/ + 60 + + Test + https://example.org/item + + + ` + + feed, err := Parse("https://example.org/", bytes.NewBufferString(data)) + if err != nil { + t.Fatal(err) + } + + if feed.TTL != 60 { + t.Errorf("Incorrect TTL, got: %d", feed.TTL) + } +} + +func TestParseFeedWithIncorrectTTLValue(t *testing.T) { + data := ` + + + Example + https://example.org/ + invalid + + Test + https://example.org/item + + + ` + + feed, err := Parse("https://example.org/", bytes.NewBufferString(data)) + if err != nil { + t.Fatal(err) + } + + if feed.TTL != 0 { + t.Errorf("Incorrect TTL, got: %d", feed.TTL) + } +} diff --git a/internal/reader/rss/podcast.go b/internal/reader/rss/podcast.go index f64dd4cd..b72426cc 100644 --- a/internal/reader/rss/podcast.go +++ b/internal/reader/rss/podcast.go @@ -4,12 +4,14 @@ package rss // import "miniflux.app/v2/internal/reader/rss" import ( - "fmt" + "errors" "math" "strconv" "strings" ) +var ErrInvalidDurationFormat = errors.New("rss: invalid duration format") + // PodcastFeedElement represents iTunes and GooglePlay feed XML elements. // Specs: // - https://github.com/simplepie/simplepie-ng/wiki/Spec:-iTunes-Podcast-RSS @@ -74,21 +76,19 @@ func (e *PodcastEntryElement) PodcastDescription() string { return strings.TrimSpace(description) } -var invalidDurationFormatErr = fmt.Errorf("rss: invalid duration format") - // normalizeDuration returns the duration tag value as a number of minutes func normalizeDuration(rawDuration string) (int, error) { var sumSeconds int durationParts := strings.Split(rawDuration, ":") if len(durationParts) > 3 { - return 0, invalidDurationFormatErr + return 0, ErrInvalidDurationFormat } for i, durationPart := range durationParts { durationPartValue, err := strconv.Atoi(durationPart) if err != nil { - return 0, invalidDurationFormatErr + return 0, ErrInvalidDurationFormat } sumSeconds += int(math.Pow(60, float64(len(durationParts)-i-1))) * durationPartValue diff --git a/internal/reader/rss/rss.go b/internal/reader/rss/rss.go index a62dabd7..52488840 100644 --- a/internal/reader/rss/rss.go +++ b/internal/reader/rss/rss.go @@ -33,10 +33,28 @@ type rssFeed struct { PubDate string `xml:"channel>pubDate"` ManagingEditor string `xml:"channel>managingEditor"` Webmaster string `xml:"channel>webMaster"` + TimeToLive rssTTL `xml:"channel>ttl"` Items []rssItem `xml:"channel>item"` PodcastFeedElement } +type rssTTL struct { + Data string `xml:",chardata"` +} + +func (r *rssTTL) Value() int { + if r.Data == "" { + return 0 + } + + value, err := strconv.Atoi(r.Data) + if err != nil { + return 0 + } + + return value +} + func (r *rssFeed) Transform(baseURL string) *model.Feed { var err error @@ -60,6 +78,7 @@ func (r *rssFeed) Transform(baseURL string) *model.Feed { } feed.IconURL = strings.TrimSpace(r.ImageURL) + feed.TTL = r.TimeToLive.Value() for _, item := range r.Items { entry := item.Transform()