Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ backend/internal/web/dist/*
.env*
# .env.example is an exception as it only contains dummy secrets.
!.env.example
backend/fusion
frontend/package-lock.json
73 changes: 61 additions & 12 deletions backend/internal/handler/feed.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package handler
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
Expand All @@ -17,20 +18,22 @@ import (
)

type createFeedRequest struct {
GroupID int64 `json:"group_id" binding:"required"`
Name string `json:"name" binding:"required"`
Link string `json:"link" binding:"required"`
SiteURL string `json:"site_url"`
Proxy string `json:"proxy"`
GroupID int64 `json:"group_id" binding:"required"`
Name string `json:"name" binding:"required"`
Link string `json:"link" binding:"required"`
SiteURL string `json:"site_url"`
Proxy string `json:"proxy"`
RefreshIntervalSeconds *int64 `json:"refresh_interval_seconds"`
}

type updateFeedRequest struct {
GroupID *int64 `json:"group_id"`
Name *string `json:"name"`
Link *string `json:"link"`
SiteURL *string `json:"site_url"`
Suspended *bool `json:"suspended"`
Proxy *string `json:"proxy"` // Empty string clears proxy
GroupID *int64 `json:"group_id"`
Name *string `json:"name"`
Link *string `json:"link"`
SiteURL *string `json:"site_url"`
Suspended *bool `json:"suspended"`
Proxy *string `json:"proxy"`
RefreshIntervalSeconds *int64 `json:"refresh_interval_seconds"`
}

type validateFeedRequest struct {
Expand All @@ -46,6 +49,10 @@ type validateFeedResponse struct {
Feeds []discoveredFeed `json:"feeds"`
}

type appInfoResponse struct {
PullInterval int `json:"pull_interval"`
}

type batchCreateFeedsRequest struct {
Feeds []batchCreateFeedItem `json:"feeds" binding:"required"`
}
Expand All @@ -57,6 +64,32 @@ type batchCreateFeedItem struct {
SiteURL string `json:"site_url"`
}

var allowedRefreshIntervals = map[int64]bool{
900: true,
1800: true,
3600: true,
7200: true,
21600: true,
43200: true,
86400: true,
}

func validateRefreshInterval(v *int64) error {
if v == nil || *v == 0 {
return nil
}
if !allowedRefreshIntervals[*v] {
return fmt.Errorf("invalid refresh_interval_seconds: must be one of 900, 1800, 3600, 7200, 21600, 43200, 86400")
}
return nil
}

func (h *Handler) getAppInfo(c *gin.Context) {
dataResponse(c, appInfoResponse{
PullInterval: h.config.PullInterval,
})
}

const refreshAllTimeout = 30 * time.Minute

func (h *Handler) listFeeds(c *gin.Context) {
Expand Down Expand Up @@ -99,8 +132,12 @@ func (h *Handler) createFeed(c *gin.Context) {
badRequestError(c, "invalid link")
return
}
if err := validateRefreshInterval(req.RefreshIntervalSeconds); err != nil {
badRequestError(c, err.Error())
return
}

feed, err := h.store.CreateFeed(req.GroupID, req.Name, req.Link, req.SiteURL, req.Proxy)
feed, err := h.store.CreateFeed(req.GroupID, req.Name, req.Link, req.SiteURL, req.Proxy, req.RefreshIntervalSeconds)
if err != nil {
internalError(c, err, "create feed")
return
Expand Down Expand Up @@ -131,6 +168,10 @@ func (h *Handler) updateFeed(c *gin.Context) {
badRequestError(c, "invalid request")
return
}
if err := validateRefreshInterval(req.RefreshIntervalSeconds); err != nil {
badRequestError(c, err.Error())
return
}

params := store.UpdateFeedParams{}
if req.GroupID != nil {
Expand All @@ -155,6 +196,14 @@ func (h *Handler) updateFeed(c *gin.Context) {
if req.Proxy != nil {
params.Proxy = req.Proxy
}
if req.RefreshIntervalSeconds != nil {
if *req.RefreshIntervalSeconds == 0 {
nilInterval := int64(-1)
params.RefreshIntervalSeconds = &nilInterval
} else {
params.RefreshIntervalSeconds = req.RefreshIntervalSeconds
}
}

if err := h.store.UpdateFeed(id, params); err != nil {
if errors.Is(err, store.ErrNotFound) {
Expand Down
14 changes: 7 additions & 7 deletions backend/internal/handler/fever_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func TestFeverReadAndMarkFlows(t *testing.T) {
if err != nil {
t.Fatalf("create group: %v", err)
}
feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "")
feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil)
if err != nil {
t.Fatalf("create feed: %v", err)
}
Expand Down Expand Up @@ -227,7 +227,7 @@ func TestFeverMarkSavedLinksExistingBookmarkToItem(t *testing.T) {
if err != nil {
t.Fatalf("create group: %v", err)
}
feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "")
feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil)
if err != nil {
t.Fatalf("create feed: %v", err)
}
Expand Down Expand Up @@ -306,7 +306,7 @@ func TestFeverFeedsIncludesFeedsGroups(t *testing.T) {
if err != nil {
t.Fatalf("create group: %v", err)
}
if _, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", ""); err != nil {
if _, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil); err != nil {
t.Fatalf("create feed: %v", err)
}

Expand Down Expand Up @@ -345,7 +345,7 @@ func TestFeverItemsWithMaxIDZeroReturnsRecentItems(t *testing.T) {
if err != nil {
t.Fatalf("create group: %v", err)
}
feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "")
feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil)
if err != nil {
t.Fatalf("create feed: %v", err)
}
Expand Down Expand Up @@ -408,7 +408,7 @@ func TestFeverFaviconsHaveDataURI(t *testing.T) {
if err != nil {
t.Fatalf("create group: %v", err)
}
if _, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", ""); err != nil {
if _, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil); err != nil {
t.Fatalf("create feed: %v", err)
}

Expand Down Expand Up @@ -457,7 +457,7 @@ func TestFeverMarkFeedReadRespectsBefore(t *testing.T) {
if err != nil {
t.Fatalf("create group: %v", err)
}
feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "")
feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil)
if err != nil {
t.Fatalf("create feed: %v", err)
}
Expand Down Expand Up @@ -516,7 +516,7 @@ func TestFeverMarkReadSupportsCSVItemIDs(t *testing.T) {
if err != nil {
t.Fatalf("create group: %v", err)
}
feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "")
feed, err := st.CreateFeed(group.ID, "Fusion Feed", "https://example.com/rss.xml", "https://example.com", "", nil)
if err != nil {
t.Fatalf("create feed: %v", err)
}
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ func (h *Handler) SetupRouter() *gin.Engine {
auth.POST("/feeds/validate", h.validateFeed)
auth.POST("/feeds/:id/refresh", h.refreshFeed)

auth.GET("/app", h.getAppInfo)

auth.GET("/items", h.listItems)
auth.GET("/items/:id", h.getItem)
auth.PATCH("/items/-/read", h.markItemsRead)
Expand Down
19 changes: 10 additions & 9 deletions backend/internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@ type Group struct {

// Feed represents an RSS/Atom feed.
type Feed struct {
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
Name string `json:"name"`
Link string `json:"link"`
SiteURL string `json:"site_url,omitempty"`
Suspended bool `json:"suspended"`
Proxy string `json:"proxy,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
Name string `json:"name"`
Link string `json:"link"`
SiteURL string `json:"site_url,omitempty"`
Suspended bool `json:"suspended"`
Proxy string `json:"proxy,omitempty"`
RefreshIntervalSeconds *int64 `json:"refresh_interval_seconds,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`

FetchState FeedFetchState `json:"fetch_state"`

Expand Down
47 changes: 41 additions & 6 deletions backend/internal/pull/puller.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ type Puller struct {
concurrency *semaphore.Weighted
}

func (p *Puller) effectiveInterval(feed *model.Feed) time.Duration {
if feed.RefreshIntervalSeconds != nil && *feed.RefreshIntervalSeconds > 0 {
return time.Duration(*feed.RefreshIntervalSeconds) * time.Second
}
return p.interval
}

func New(st *store.Store, cfg *config.Config) *Puller {
return &Puller{
store: st,
Expand All @@ -41,10 +48,10 @@ func New(st *store.Store, cfg *config.Config) *Puller {
func (p *Puller) Start(ctx context.Context) error {
p.logger.Info("pull service started", "interval", p.interval, "timeout", p.timeout, "concurrency", p.config.PullConcurrency)

// Run immediately on startup
p.pullAll(ctx)

ticker := time.NewTicker(p.interval)
tickerInterval := p.interval
ticker := time.NewTicker(tickerInterval)
defer ticker.Stop()

for {
Expand All @@ -55,7 +62,34 @@ func (p *Puller) Start(ctx context.Context) error {
case <-ticker.C:
p.pullAll(ctx)
}

newInterval := p.computeMinInterval()
if newInterval != tickerInterval {
tickerInterval = newInterval
ticker.Reset(tickerInterval)
p.logger.Info("adjusted pull ticker", "interval", tickerInterval)
}
}
}

func (p *Puller) computeMinInterval() time.Duration {
feeds, err := p.store.ListFeeds()
if err != nil {
p.logger.Error("failed to list feeds for ticker computation", "error", err)
return p.interval
}

minInterval := p.interval
for _, feed := range feeds {
if feed.Suspended {
continue
}
effective := p.effectiveInterval(feed)
if effective < minInterval {
minInterval = effective
}
}
return minInterval
}

// pullAll fetches all feeds concurrently with semaphore limiting.
Expand All @@ -76,7 +110,8 @@ func (p *Puller) pullAll(ctx context.Context) {
LastErrorAt: feed.FetchState.LastErrorAt,
LastCheckedAt: feed.FetchState.LastCheckedAt,
}
return !pullpolicy.ShouldSkip(now, state, p.interval, p.maxBackoff)
interval := p.effectiveInterval(feed)
return !pullpolicy.ShouldSkip(now, state, interval, p.maxBackoff)
})
}

Expand All @@ -99,7 +134,7 @@ func (p *Puller) pullFeed(ctx context.Context, feed *model.Feed) {
HTTPStatus: httpStatus,
LastError: err.Error(),
RetryAfterUntil: retryAfterUntil,
IntervalSeconds: int64(p.interval.Seconds()),
IntervalSeconds: int64(p.effectiveInterval(feed).Seconds()),
MaxBackoff: int64(p.maxBackoff.Seconds()),
}); err != nil {
p.logger.Error("failed to record failure", "feed_id", feed.ID, "error", err)
Expand Down Expand Up @@ -134,7 +169,7 @@ func (p *Puller) pullFeed(ctx context.Context, feed *model.Feed) {

nextCheckAt := pullpolicy.ComputeNextCheckAt(
checkedAt,
p.interval,
p.effectiveInterval(feed),
p.maxBackoff,
0,
result.RetryAfterUntil,
Expand Down Expand Up @@ -162,7 +197,7 @@ func (p *Puller) pullFeed(ctx context.Context, feed *model.Feed) {

nextCheckAt := pullpolicy.ComputeNextCheckAt(
checkedAt,
p.interval,
p.effectiveInterval(feed),
p.maxBackoff,
0,
result.RetryAfterUntil,
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/pull/puller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func TestRefreshFeedPreservesValidatorsWhen304OmitHeaders(t *testing.T) {
}))
defer server.Close()

feed, err := st.CreateFeed(1, "Feed A", server.URL, "", "")
feed, err := st.CreateFeed(1, "Feed A", server.URL, "", "", nil)
if err != nil {
t.Fatalf("create feed: %v", err)
}
Expand Down Expand Up @@ -108,10 +108,10 @@ func TestRefreshAllWaitsForRunningJobs(t *testing.T) {
}))
defer server.Close()

if _, err := st.CreateFeed(1, "Feed A", server.URL+"/a", "", ""); err != nil {
if _, err := st.CreateFeed(1, "Feed A", server.URL+"/a", "", "", nil); err != nil {
t.Fatalf("create feed A: %v", err)
}
if _, err := st.CreateFeed(1, "Feed B", server.URL+"/b", "", ""); err != nil {
if _, err := st.CreateFeed(1, "Feed B", server.URL+"/b", "", "", nil); err != nil {
t.Fatalf("create feed B: %v", err)
}

Expand Down
Loading