chore: add favicon to db and api
60504388
4 file(s) · +88 −9
| 12 | 12 | use andromeda_db::Db; |
|
| 13 | 13 | ||
| 14 | 14 | use crate::auth::ApiAuth; |
|
| 15 | - | use crate::feeds::{discover_feeds, fetch_feed, parse_opml, ParsedEntry}; |
|
| 15 | + | use crate::feeds::{discover_favicon, discover_feeds, fetch_feed, parse_opml, ParsedEntry}; |
|
| 16 | 16 | use crate::poller::POLL_INTERVAL_KEY; |
|
| 17 | 17 | use crate::AppState; |
|
| 18 | 18 | ||
| 151 | 151 | Err(resp) => return resp, |
|
| 152 | 152 | }; |
|
| 153 | 153 | ||
| 154 | - | let sub = match fdb::insert_subscription( |
|
| 154 | + | let mut sub = match fdb::insert_subscription( |
|
| 155 | 155 | &state.db, |
|
| 156 | 156 | feed_url, |
|
| 157 | 157 | &title, |
|
| 161 | 161 | Ok(s) => s, |
|
| 162 | 162 | Err(e) => return err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), |
|
| 163 | 163 | }; |
|
| 164 | + | ||
| 165 | + | if let Some(site) = site_url.as_deref() { |
|
| 166 | + | if let Some(fav) = discover_favicon(site).await { |
|
| 167 | + | let _ = fdb::update_subscription_favicon(&state.db, sub.id, Some(&fav)); |
|
| 168 | + | sub.favicon_url = Some(fav); |
|
| 169 | + | } |
|
| 170 | + | } |
|
| 164 | 171 | ||
| 165 | 172 | seed_subscription( |
|
| 166 | 173 | &state.db, |
|
| 396 | 403 | imported += 1; |
|
| 397 | 404 | let state_cloned = Arc::clone(&state); |
|
| 398 | 405 | let sem_cloned = Arc::clone(&sem); |
|
| 406 | + | let site = site_url.clone(); |
|
| 399 | 407 | seed_handles.push(tokio::spawn(async move { |
|
| 400 | 408 | let _permit = match sem_cloned.acquire().await { |
|
| 401 | 409 | Ok(p) => p, |
|
| 402 | 410 | Err(_) => return None, |
|
| 403 | 411 | }; |
|
| 412 | + | if let Some(site) = site.as_deref() { |
|
| 413 | + | if let Some(fav) = discover_favicon(site).await { |
|
| 414 | + | let _ = fdb::update_subscription_favicon( |
|
| 415 | + | &state_cloned.db, |
|
| 416 | + | sub.id, |
|
| 417 | + | Some(&fav), |
|
| 418 | + | ); |
|
| 419 | + | } |
|
| 420 | + | } |
|
| 404 | 421 | crate::poller::poll_one(&state_cloned, &sub) |
|
| 405 | 422 | .await |
|
| 406 | 423 | .err() |
|
| 300 | 300 | entries |
|
| 301 | 301 | } |
|
| 302 | 302 | ||
| 303 | + | /// Best-effort favicon URL for a site. Parses `<link rel="icon">` from the |
|
| 304 | + | /// page HTML, falls back to `/favicon.ico` at the site root. Returns None if |
|
| 305 | + | /// the URL is invalid. |
|
| 306 | + | pub async fn discover_favicon(site_url: &str) -> Option<String> { |
|
| 307 | + | let parsed = Url::parse(site_url).ok()?; |
|
| 308 | + | let client = build_client(); |
|
| 309 | + | ||
| 310 | + | if let Ok(resp) = client.get(site_url).send().await { |
|
| 311 | + | if let Ok(body) = resp.text().await { |
|
| 312 | + | let document = Html::parse_document(&body); |
|
| 313 | + | let selector = Selector::parse( |
|
| 314 | + | r#"link[rel="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]"#, |
|
| 315 | + | ) |
|
| 316 | + | .ok()?; |
|
| 317 | + | if let Some(href) = document |
|
| 318 | + | .select(&selector) |
|
| 319 | + | .find_map(|el| el.attr("href")) |
|
| 320 | + | { |
|
| 321 | + | if let Ok(resolved) = parsed.join(href) { |
|
| 322 | + | return Some(resolved.to_string()); |
|
| 323 | + | } |
|
| 324 | + | } |
|
| 325 | + | } |
|
| 326 | + | } |
|
| 327 | + | ||
| 328 | + | parsed.join("/favicon.ico").ok().map(|u| u.to_string()) |
|
| 329 | + | } |
|
| 330 | + | ||
| 303 | 331 | pub async fn discover_feeds(base_url: &str) -> Result<Vec<String>, String> { |
|
| 304 | 332 | let parsed = Url::parse(base_url).map_err(|e| format!("Invalid URL: {e}"))?; |
|
| 305 | 333 | let client = build_client(); |
| 564 | 564 | conn.execute_batch(SESSION_SCHEMA).expect("session schema"); |
|
| 565 | 565 | conn.execute_batch(fdb::FEEDS_SCHEMA).expect("feeds schema"); |
|
| 566 | 566 | let db: Db = Arc::new(Mutex::new(conn)); |
|
| 567 | + | fdb::migrate_feeds(&db).expect("feeds migration"); |
|
| 567 | 568 | ||
| 568 | 569 | let cookie_secure = std::env::var("COOKIE_SECURE") |
|
| 569 | 570 | .map(|v| v.eq_ignore_ascii_case("true")) |
| 15 | 15 | feed_url TEXT NOT NULL UNIQUE, |
|
| 16 | 16 | title TEXT NOT NULL, |
|
| 17 | 17 | site_url TEXT, |
|
| 18 | + | favicon_url TEXT, |
|
| 18 | 19 | category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, |
|
| 19 | 20 | etag TEXT, |
|
| 20 | 21 | last_modified TEXT, |
|
| 59 | 60 | pub feed_url: String, |
|
| 60 | 61 | pub title: String, |
|
| 61 | 62 | pub site_url: Option<String>, |
|
| 63 | + | pub favicon_url: Option<String>, |
|
| 62 | 64 | pub category_id: Option<i64>, |
|
| 63 | 65 | pub etag: Option<String>, |
|
| 64 | 66 | pub last_modified: Option<String>, |
|
| 121 | 123 | feed_url: row.get(1)?, |
|
| 122 | 124 | title: row.get(2)?, |
|
| 123 | 125 | site_url: row.get(3)?, |
|
| 124 | - | category_id: row.get(4)?, |
|
| 125 | - | etag: row.get(5)?, |
|
| 126 | - | last_modified: row.get(6)?, |
|
| 127 | - | last_fetched_at: row.get(7)?, |
|
| 128 | - | last_error: row.get(8)?, |
|
| 129 | - | added_at: row.get(9)?, |
|
| 126 | + | favicon_url: row.get(4)?, |
|
| 127 | + | category_id: row.get(5)?, |
|
| 128 | + | etag: row.get(6)?, |
|
| 129 | + | last_modified: row.get(7)?, |
|
| 130 | + | last_fetched_at: row.get(8)?, |
|
| 131 | + | last_error: row.get(9)?, |
|
| 132 | + | added_at: row.get(10)?, |
|
| 130 | 133 | }) |
|
| 131 | 134 | } |
|
| 132 | 135 | ||
| 133 | - | const SUB_COLS: &str = "id, feed_url, title, site_url, category_id, etag, last_modified, last_fetched_at, last_error, added_at"; |
|
| 136 | + | const SUB_COLS: &str = "id, feed_url, title, site_url, favicon_url, category_id, etag, last_modified, last_fetched_at, last_error, added_at"; |
|
| 137 | + | ||
| 138 | + | /// Add columns introduced after the initial schema. Idempotent. |
|
| 139 | + | pub fn migrate_feeds(db: &Db) -> Result<(), DbError> { |
|
| 140 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 141 | + | let has_favicon: bool = conn |
|
| 142 | + | .query_row( |
|
| 143 | + | "SELECT 1 FROM pragma_table_info('subscriptions') WHERE name = 'favicon_url'", |
|
| 144 | + | [], |
|
| 145 | + | |_| Ok(true), |
|
| 146 | + | ) |
|
| 147 | + | .optional()? |
|
| 148 | + | .unwrap_or(false); |
|
| 149 | + | if !has_favicon { |
|
| 150 | + | conn.execute("ALTER TABLE subscriptions ADD COLUMN favicon_url TEXT", [])?; |
|
| 151 | + | } |
|
| 152 | + | Ok(()) |
|
| 153 | + | } |
|
| 154 | + | ||
| 155 | + | pub fn update_subscription_favicon( |
|
| 156 | + | db: &Db, |
|
| 157 | + | id: i64, |
|
| 158 | + | favicon_url: Option<&str>, |
|
| 159 | + | ) -> Result<(), DbError> { |
|
| 160 | + | let conn = db.lock().map_err(|_| DbError::LockPoisoned)?; |
|
| 161 | + | conn.execute( |
|
| 162 | + | "UPDATE subscriptions SET favicon_url = ?1 WHERE id = ?2", |
|
| 163 | + | params![favicon_url, id], |
|
| 164 | + | )?; |
|
| 165 | + | Ok(()) |
|
| 166 | + | } |
|
| 134 | 167 | ||
| 135 | 168 | // ── Categories ──────────────────────────────────────────────────────── |
|
| 136 | 169 | ||