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()