apps/feeds/subscriptions.go 6.7 K raw
1
package main
2
3
import (
4
	"context"
5
	"database/sql"
6
	"fmt"
7
	"io"
8
	"net/http"
9
	"strings"
10
	"time"
11
)
12
13
const errAlreadySubscribed = "already subscribed"
14
15
func (a *App) createSubscription(ctx context.Context, body createSubscriptionBody, background bool) (*Subscription, error) {
16
	feedURL := strings.TrimSpace(body.FeedURL)
17
	if feedURL == "" {
18
		return nil, fmt.Errorf("feed_url required")
19
	}
20
	existing, err := getSubscriptionByURL(a.DB, feedURL)
21
	if err != nil {
22
		return nil, err
23
	}
24
	if existing != nil {
25
		return nil, fmt.Errorf(errAlreadySubscribed)
26
	}
27
	categoryID, err := a.resolveCategory(body.CategoryID, body.CategoryName)
28
	if err != nil {
29
		return nil, err
30
	}
31
	title := firstNonEmpty(body.Title, feedURL)
32
	if background {
33
		return a.createSubscriptionInBackground(feedURL, title, body, categoryID)
34
	}
35
	return a.createSubscriptionImmediately(ctx, feedURL, title, body, categoryID)
36
}
37
38
func (a *App) createSubscriptionInBackground(feedURL, title string, body createSubscriptionBody, categoryID *int64) (*Subscription, error) {
39
	sub, err := insertSubscription(a.DB, feedURL, title, nil, categoryID)
40
	if err != nil {
41
		return nil, err
42
	}
43
	go func(subID int64, originalTitle string) {
44
		res, err := fetchFeed(context.Background(), feedURL, "", "")
45
		if err != nil {
46
			msg := err.Error()
47
			_ = updateSubscriptionMeta(a.DB, subID, nil, nil, &msg)
48
			return
49
		}
50
		resolvedTitle := firstNonEmpty(body.Title, res.Title, feedURL)
51
		if resolvedTitle != originalTitle {
52
			_ = updateSubscriptionTitle(a.DB, subID, resolvedTitle)
53
		}
54
		if res.SiteURL != "" {
55
			_ = updateSubscriptionSiteURL(a.DB, subID, &res.SiteURL)
56
			if fav := discoverFavicon(context.Background(), res.SiteURL); fav != "" {
57
				_ = updateSubscriptionFavicon(a.DB, subID, &fav)
58
			}
59
		}
60
		_ = a.seedSubscription(subID, res)
61
	}(sub.ID, sub.Title)
62
	return sub, nil
63
}
64
65
func (a *App) createSubscriptionImmediately(ctx context.Context, feedURL, title string, body createSubscriptionBody, categoryID *int64) (*Subscription, error) {
66
	res, err := fetchFeed(ctx, feedURL, "", "")
67
	if err != nil {
68
		return nil, fmt.Errorf("feed not reachable: %w", err)
69
	}
70
	resolvedTitle := firstNonEmpty(body.Title, res.Title, feedURL)
71
	siteURL := stringPtr(res.SiteURL)
72
	sub, err := insertSubscription(a.DB, feedURL, resolvedTitle, siteURL, categoryID)
73
	if err != nil {
74
		return nil, err
75
	}
76
	if res.SiteURL != "" {
77
		if fav := discoverFavicon(ctx, res.SiteURL); fav != "" {
78
			_ = updateSubscriptionFavicon(a.DB, sub.ID, &fav)
79
			sub.FaviconURL = sql.NullString{String: fav, Valid: true}
80
		}
81
	}
82
	if err := a.seedSubscription(sub.ID, res); err != nil {
83
		return nil, err
84
	}
85
	return getSubscription(a.DB, sub.ID)
86
}
87
88
func (a *App) seedSubscription(subID int64, res *FetchResult) error {
89
	for _, entry := range res.Entries {
90
		if strings.TrimSpace(entry.Link) == "" {
91
			continue
92
		}
93
		_, err := insertItemIgnoreDup(a.DB, NewItem{SubscriptionID: subID, GUID: entry.GUID, Title: entry.Title, Link: entry.Link, Author: entry.Author, PublishedAt: entry.PublishedAt})
94
		if err != nil {
95
			a.Log.Warn("seed insert failed", "sub_id", subID, "err", err)
96
		}
97
	}
98
	if err := pruneSubscription(a.DB, subID, a.ItemCap); err != nil {
99
		return err
100
	}
101
	return updateSubscriptionMeta(a.DB, subID, stringPtr(res.ETag), stringPtr(res.LastModified), nil)
102
}
103
104
func (a *App) resolveCategory(id *int64, name string) (*int64, error) {
105
	if id != nil {
106
		return id, nil
107
	}
108
	if strings.TrimSpace(name) == "" {
109
		return nil, nil
110
	}
111
	cat, err := getOrCreateCategory(a.DB, name)
112
	if err != nil || cat == nil {
113
		return nil, err
114
	}
115
	return &cat.ID, nil
116
}
117
118
func (a *App) resolveSubscriptionCategory(body updateSubscriptionBody) (*int64, error) {
119
	if body.ClearCategory {
120
		return nil, nil
121
	}
122
	return a.resolveCategory(body.CategoryID, body.CategoryName)
123
}
124
125
func (a *App) readAndImportOPML(r *http.Request) (*importSummary, error) {
126
	if err := r.ParseMultipartForm(8 << 20); err != nil {
127
		return nil, err
128
	}
129
	file, _, err := r.FormFile("file")
130
	if err != nil {
131
		return nil, err
132
	}
133
	defer file.Close()
134
	body, err := io.ReadAll(file)
135
	if err != nil {
136
		return nil, err
137
	}
138
	return a.importOPMLString(r.Context(), string(body)), nil
139
}
140
141
func (a *App) importOPMLString(ctx context.Context, content string) *importSummary {
142
	entries := parseOPML(content)
143
	summary := &importSummary{}
144
	for _, entry := range entries {
145
		existing, _ := getSubscriptionByURL(a.DB, entry.XMLURL)
146
		if existing != nil {
147
			summary.Skipped++
148
			continue
149
		}
150
		body := createSubscriptionBody{FeedURL: entry.XMLURL, Title: entry.Title, CategoryName: entry.Category}
151
		if _, err := a.createSubscription(ctx, body, true); err != nil {
152
			summary.Failed = append(summary.Failed, fmt.Sprintf("%s: %v", entry.XMLURL, err))
153
			continue
154
		}
155
		summary.Imported++
156
	}
157
	return summary
158
}
159
160
func (a *App) poller(ctx context.Context) {
161
	time.Sleep(3 * time.Second)
162
	for {
163
		mins := a.pollIntervalMinutes()
164
		a.Log.Info("poller pass starting", "interval_minutes", mins)
165
		subs, err := listSubscriptions(a.DB)
166
		if err == nil {
167
			for _, sub := range subs {
168
				if err := a.pollOne(ctx, sub); err != nil {
169
					msg := err.Error()
170
					_ = updateSubscriptionMeta(a.DB, sub.ID, nullStringPointer(sub.ETag), nullStringPointer(sub.LastModified), &msg)
171
					a.Log.Warn("feed poll failed", "feed_url", sub.FeedURL, "err", err)
172
				}
173
			}
174
		}
175
		time.Sleep(time.Duration(mins) * time.Minute)
176
	}
177
}
178
179
func (a *App) pollOne(ctx context.Context, sub Subscription) error {
180
	res, err := fetchFeed(ctx, sub.FeedURL, nullStringValue(sub.ETag), nullStringValue(sub.LastModified))
181
	if err != nil {
182
		return err
183
	}
184
	inserted := 0
185
	if res.Status != http.StatusNotModified {
186
		for _, entry := range res.Entries {
187
			ok, err := insertItemIgnoreDup(a.DB, NewItem{SubscriptionID: sub.ID, GUID: entry.GUID, Title: entry.Title, Link: entry.Link, Author: entry.Author, PublishedAt: entry.PublishedAt})
188
			if err != nil {
189
				a.Log.Warn("insert item failed", "feed_url", sub.FeedURL, "err", err)
190
				continue
191
			}
192
			if ok {
193
				inserted++
194
			}
195
		}
196
		if res.Title != "" && sub.Title == sub.FeedURL && res.Title != sub.Title {
197
			_ = updateSubscriptionTitle(a.DB, sub.ID, res.Title)
198
		}
199
		if err := pruneSubscription(a.DB, sub.ID, a.ItemCap); err != nil {
200
			return err
201
		}
202
	}
203
	if err := updateSubscriptionMeta(a.DB, sub.ID, stringPtr(res.ETag), stringPtr(res.LastModified), nil); err != nil {
204
		return err
205
	}
206
	a.Log.Info("feed polled", "feed_url", sub.FeedURL, "status", res.Status, "new_items", inserted, "entries", len(res.Entries))
207
	return nil
208
}
209
210
func (a *App) pollIntervalMinutes() int {
211
	if value, ok, err := getSetting(a.DB, "poll_interval_minutes"); err == nil && ok {
212
		if mins, err := parsePositiveInt(value); err == nil {
213
			return mins
214
		}
215
	}
216
	return a.DefaultPollMinutes
217
}
218
219
func isAlreadySubscribedError(err error) bool {
220
	return err != nil && strings.Contains(err.Error(), errAlreadySubscribed)
221
}