chore: turned feeds subscription fetch into background task 9da22af7
Steve · 2026-05-10 13:45 4 file(s) · +114 −4
apps/feeds/src/api.rs +84 −0
179 179
    (StatusCode::CREATED, Json(serde_json::json!({ "subscription": sub }))).into_response()
180 180
}
181 181
182 +
/// Same as `add_subscription` but inserts the row immediately and spawns a
183 +
/// background task to fetch the feed, discover the favicon, and seed items.
184 +
/// Returns instantly so the caller is not blocked on the HTTP round-trip.
185 +
pub async fn add_subscription_background(
186 +
    state: Arc<AppState>,
187 +
    body: CreateSubscriptionBody,
188 +
) -> Response {
189 +
    let feed_url = body.feed_url.trim().to_string();
190 +
    if feed_url.is_empty() {
191 +
        return err_json(StatusCode::BAD_REQUEST, "feed_url required");
192 +
    }
193 +
194 +
    if let Ok(Some(existing)) = fdb::get_subscription_by_url(&state.db, &feed_url) {
195 +
        return (
196 +
            StatusCode::CONFLICT,
197 +
            Json(serde_json::json!({
198 +
                "error": "already subscribed",
199 +
                "subscription": existing
200 +
            })),
201 +
        )
202 +
            .into_response();
203 +
    }
204 +
205 +
    let category_id =
206 +
        match resolve_category(&state, body.category_id, body.category_name.as_deref()) {
207 +
            Ok(id) => id,
208 +
            Err(resp) => return resp,
209 +
        };
210 +
211 +
    let title = body.title.clone().unwrap_or_else(|| feed_url.clone());
212 +
213 +
    let sub = match fdb::insert_subscription(&state.db, &feed_url, &title, None, category_id) {
214 +
        Ok(s) => s,
215 +
        Err(e) => return err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
216 +
    };
217 +
218 +
    let state = Arc::clone(&state);
219 +
    let user_title = body.title;
220 +
    let sub_id = sub.id;
221 +
    let sub_title = sub.title.clone();
222 +
223 +
    tokio::spawn(async move {
224 +
        match fetch_feed(&feed_url, None, None).await {
225 +
            Ok(result) => {
226 +
                let resolved_title = user_title.unwrap_or_else(|| {
227 +
                    result.title.unwrap_or_else(|| feed_url.clone())
228 +
                });
229 +
                if resolved_title != sub_title {
230 +
                    let _ = fdb::update_subscription_title(&state.db, sub_id, &resolved_title);
231 +
                }
232 +
233 +
                if let Some(site) = result.site_url.as_deref() {
234 +
                    let _ = fdb::update_subscription_site_url(&state.db, sub_id, Some(site));
235 +
                    if let Some(fav) = discover_favicon(site).await {
236 +
                        let _ = fdb::update_subscription_favicon(&state.db, sub_id, Some(&fav));
237 +
                    }
238 +
                }
239 +
240 +
                seed_subscription(
241 +
                    &state.db,
242 +
                    sub_id,
243 +
                    &result.entries,
244 +
                    result.etag.as_deref(),
245 +
                    result.last_modified.as_deref(),
246 +
                    state.item_cap,
247 +
                );
248 +
            }
249 +
            Err(e) => {
250 +
                let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
251 +
                let _ = fdb::update_subscription_meta(
252 +
                    &state.db,
253 +
                    sub_id,
254 +
                    None,
255 +
                    None,
256 +
                    &now,
257 +
                    Some(&e),
258 +
                );
259 +
            }
260 +
        }
261 +
    });
262 +
263 +
    (StatusCode::CREATED, Json(serde_json::json!({ "subscription": sub }))).into_response()
264 +
}
265 +
182 266
/// Insert probe entries into the new subscription, prune to the item cap, then
183 267
/// persist etag/last_modified. The order matters: persisting the conditional-fetch
184 268
/// metadata before seeding would let the next poller pass receive a 304 against an
apps/feeds/src/main.rs +3 −2
526 526
        category_id: None,
527 527
        category_name: form.category_name.filter(|s| !s.trim().is_empty()),
528 528
    };
529 -
    let resp = api::add_subscription(&state, &body).await;
529 +
    let resp = api::add_subscription_background(state, body).await;
530 530
    let status = resp.status();
531 531
    if status.is_success() {
532 -
        Redirect::to("/admin?success=Feed+added").into_response()
532 +
        Redirect::to("/admin?success=Feed+added+and+will+be+fetched+in+the+background")
533 +
            .into_response()
533 534
    } else if status == StatusCode::CONFLICT {
534 535
        Redirect::to("/admin?error=Already+subscribed").into_response()
535 536
    } else {
apps/feeds/src/templates/admin.html +14 −2
38 38
      <div id="discover-results" class="discover-results" style="display:none;"></div>
39 39
    </section>
40 40
41 -
    <form class="admin-form" method="POST" action="/admin/add-feed">
41 +
    <form class="admin-form" id="add-feed-form" method="POST" action="/admin/add-feed">
42 42
      <h3>Add Feed</h3>
43 43
      <label for="feed_url">Feed URL</label>
44 44
      <input type="url" id="feed_url" name="feed_url" placeholder="https://example.com/feed.xml" required />
49 49
        <option value="{{ c.name }}"></option>
50 50
        {% endfor %}
51 51
      </datalist>
52 -
      <button type="submit">Add Feed</button>
52 +
      <button type="submit" id="add-feed-submit"><span id="add-feed-label">Add Feed</span></button>
53 53
    </form>
54 54
55 55
    <form class="admin-form" id="opml-form" method="POST" action="/admin/import-opml" enctype="multipart/form-data">
111 111
    </section>
112 112
113 113
    <script>
114 +
      (function() {
115 +
        const form = document.getElementById('add-feed-form');
116 +
        if (!form) return;
117 +
        form.addEventListener('submit', function() {
118 +
          const btn = document.getElementById('add-feed-submit');
119 +
          const label = document.getElementById('add-feed-label');
120 +
          btn.disabled = true;
121 +
          btn.classList.add('loading');
122 +
          label.innerHTML = 'Adding <span class="spinner"></span>';
123 +
        });
124 +
      })();
125 +
114 126
      (function() {
115 127
        const form = document.getElementById('opml-form');
116 128
        if (!form) return;
crates/db/src/feeds.rs +13 −0
165 165
    Ok(())
166 166
}
167 167
168 +
pub fn update_subscription_site_url(
169 +
    db: &Db,
170 +
    id: i64,
171 +
    site_url: Option<&str>,
172 +
) -> Result<(), DbError> {
173 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
174 +
    conn.execute(
175 +
        "UPDATE subscriptions SET site_url = ?1 WHERE id = ?2",
176 +
        params![site_url, id],
177 +
    )?;
178 +
    Ok(())
179 +
}
180 +
168 181
// ── Categories ────────────────────────────────────────────────────────
169 182
170 183
pub fn list_categories(db: &Db) -> Result<Vec<Category>, DbError> {