aboutsummaryrefslogtreecommitdiff
path: root/miniflux
diff options
context:
space:
mode:
authorSam Scholten2026-10-07 15:31:52 +1000
committerSam Scholten2026-10-07 15:31:52 +1000
commit2f75794194209586d0b17d26e4e87d94d3e6aead (patch)
tree069798e987a7bc2a395baa38fbe4d27c3cba1dce /miniflux
parentf84f1c0e26908e2a70777441e1174a26dca10b91 (diff)
downloadfluxrec-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.go36
-rw-r--r--miniflux/client_test.go59
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")