chore: add favicon to db and api 60504388
Steve · 2026-05-02 09:29 4 file(s) · +88 −9
apps/feeds/src/api.rs +19 −2
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()
apps/feeds/src/feeds.rs +28 −0
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();
apps/feeds/src/main.rs +1 −0
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"))
crates/db/src/feeds.rs +40 −7
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