diff options
| author | Sam Scholten | 2026-10-07 15:31:52 +1000 |
|---|---|---|
| committer | Sam Scholten | 2026-10-07 15:31:52 +1000 |
| commit | 2f75794194209586d0b17d26e4e87d94d3e6aead (patch) | |
| tree | 069798e987a7bc2a395baa38fbe4d27c3cba1dce /miniflux | |
| parent | f84f1c0e26908e2a70777441e1174a26dca10b91 (diff) | |
| download | fluxrec-2f75794194209586d0b17d26e4e87d94d3e6aead.tar.gz fluxrec-2f75794194209586d0b17d26e4e87d94d3e6aead.zip | |
feeds: subscription audit command (per-feed volume + star stats)
GET /v1/feeds + feed_id entries filter in the client, one cheap volume
probe per feed over a --days window, star stats joined from labels.jsonl
by feed id, category column, --min-per-day/--min-seen filters. Read-only;
justfile recipe with one-line doc comments throughout.
Diffstat (limited to 'miniflux')
| -rw-r--r-- | miniflux/client.go | 36 | ||||
| -rw-r--r-- | miniflux/client_test.go | 59 |
2 files changed, 92 insertions, 3 deletions
diff --git a/miniflux/client.go b/miniflux/client.go index ae23fe3..d430852 100644 --- a/miniflux/client.go +++ b/miniflux/client.go @@ -82,6 +82,18 @@ type Feed struct { Title string `json:"title"` } +// FeedInfo is one subscription as returned by GET /v1/feeds (the pruning- +// audit view; richer than the per-entry Feed subset above). +type FeedInfo struct { + ID int64 `json:"id"` + Title string `json:"title"` + SiteURL string `json:"site_url"` + Disabled bool `json:"disabled"` + Category struct { + Title string `json:"title"` + } `json:"category"` +} + // Entry is the subset of a Miniflux entry fluxrec needs. JSON field names // match the Miniflux API verbatim so decoding is a straight mirror. type Entry struct { @@ -103,6 +115,7 @@ type Entry struct { type Filters struct { Starred *bool // only starred entries when true Status string // "read" | "unread"; "" = any + FeedID int64 // restrict to one feed (feeds-lane volume probes) ChangedAfter int64 // unix timestamp; incremental export cursor PublishedAfter int64 // unix timestamp; only entries published after this AfterEntryID int64 // serve-lane poll cursor @@ -137,10 +150,28 @@ func (c *Client) RawEntries(f Filters) ([]byte, error) { return c.fetchEntries(f) } +// Feeds calls GET /v1/feeds, returning every subscription (the audit lane: +// one call, no paging). +func (c *Client) Feeds() ([]FeedInfo, error) { + body, err := c.getJSON(c.baseURL + "/v1/feeds") + if err != nil { + return nil, err + } + var feeds []FeedInfo + if err := json.Unmarshal(body, &feeds); err != nil { + return nil, fmt.Errorf("decoding /v1/feeds response: %w", err) + } + return feeds, nil +} + // fetchEntries issues the GET and returns the body on HTTP 200. func (c *Client) fetchEntries(f Filters) ([]byte, error) { - u := entriesURL(c.baseURL, f) + return c.getJSON(entriesURL(c.baseURL, f)) +} +// getJSON GETs u with the auth header and returns the body on HTTP 200; +// any other status becomes an error naming the status and a response sniff. +func (c *Client) getJSON(u string) ([]byte, error) { req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return nil, fmt.Errorf("building request: %w", err) @@ -174,6 +205,9 @@ func entriesURL(base string, f Filters) string { if f.Status != "" { q.Set("status", f.Status) } + if f.FeedID > 0 { + q.Set("feed_id", strconv.FormatInt(f.FeedID, 10)) + } if f.ChangedAfter > 0 { q.Set("changed_after", strconv.FormatInt(f.ChangedAfter, 10)) } diff --git a/miniflux/client_test.go b/miniflux/client_test.go index 8f8c6ad..e3774a3 100644 --- a/miniflux/client_test.go +++ b/miniflux/client_test.go @@ -80,7 +80,7 @@ const entriesFixture = `{ }` // fakeServer records the last request's auth header and query, then replies -// with the fixture (or an error status). +// with the fixture (or an error status). Accepts /v1/entries and /v1/feeds. type fakeServer struct { t *testing.T status int @@ -98,7 +98,7 @@ func (f *fakeServer) handler() http.HandlerFunc { for k, v := range r.URL.Query() { f.gotQuery[k] = v[0] } - if r.URL.Path != "/v1/entries" { + if r.URL.Path != "/v1/entries" && r.URL.Path != "/v1/feeds" { f.t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -165,6 +165,7 @@ func TestEntriesSendsFilters(t *testing.T) { _, err := client.Entries(Filters{ Starred: &starred, Status: "read", + FeedID: 77, ChangedAfter: 1700000000, PublishedAfter: 1700000000, AfterEntryID: 40999, @@ -178,6 +179,7 @@ func TestEntriesSendsFilters(t *testing.T) { want := map[string]string{ "starred": "true", "status": "read", + "feed_id": "77", "changed_after": "1700000000", "published_after": "1700000000", "after_entry_id": "40999", @@ -225,6 +227,59 @@ func TestEntriesBadJSON(t *testing.T) { } } +const feedsFixture = `[ + { + "id": 77, + "user_id": 3, + "feed_url": "https://example.com/feed.xml", + "site_url": "https://example.com", + "title": "Example Blog", + "disabled": false, + "category": {"id": 1, "title": "Tech", "user_id": 3} + }, + { + "id": 102, + "user_id": 3, + "feed_url": "https://daily.example/rss", + "site_url": "https://daily.example", + "title": "Some Daily", + "disabled": true, + "category": {"id": 2, "title": "News", "user_id": 3} + } +]` + +func TestFeedsDecodesFixture(t *testing.T) { + f := &fakeServer{t: t, status: http.StatusOK, body: feedsFixture} + client := newFakeClient(t, f) + + feeds, err := client.Feeds() + if err != nil { + t.Fatalf("Feeds: %v", err) + } + if !f.sawRequest { + t.Fatal("fake server saw no request") + } + if f.gotHeader != "test-token-123" { + t.Errorf("X-Auth-Token = %q, want test-token-123", f.gotHeader) + } + if len(feeds) != 2 { + t.Fatalf("len(feeds) = %d, want 2", len(feeds)) + } + if feeds[0].ID != 77 || feeds[0].Title != "Example Blog" || feeds[0].SiteURL != "https://example.com" { + t.Errorf("feeds[0] = %+v", feeds[0]) + } + if feeds[0].Disabled { + t.Error("feeds[0].Disabled = true, want false") + } + if !feeds[1].Disabled { + t.Error("feeds[1].Disabled = false, want true") + } + if feeds[0].Category.Title != "Tech" || feeds[1].Category.Title != "News" { + t.Errorf("categories = %q / %q, want Tech / News", + feeds[0].Category.Title, feeds[1].Category.Title) + } +} + func TestConfigFromEnv(t *testing.T) { t.Run("primary vars", func(t *testing.T) { t.Setenv("MINIFLUX_URL", "https://flux.example") |
