chore: turned feeds subscription fetch into background task
9da22af7
4 file(s) · +114 −4
| 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 |
| 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 { |
| 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; |
|
| 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> { |