feedsignal/crates/web/src/server.rs
Austin Schaefer d40c90552c
Some checks failed
CI / check (pull_request) Failing after 32s
CI / test (pull_request) Has been skipped
CI / audit (pull_request) Has been skipped
Add manual feed subscription
Adds a URL input + Subscribe button (dx-components Input/Button) to the
UI, wired to a new subscribe_feed server function that upserts the feed
row and does an immediate first fetch so the reader isn't empty until
the next scheduled poll. feedsignal_feeds::fetch_feed now also returns
the feed's title, used to replace the URL placeholder once fetched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJJQb2DWZZwQ1yPiaAQQoY
2026-08-21 15:15:50 +02:00

106 lines
3.7 KiB
Rust

use crate::app::ArticleView;
use anyhow::Result;
use feedsignal_core::{ReadingEvent, ReadingOutcome};
use feedsignal_db::Db;
use feedsignal_llm::Llm;
use std::sync::Arc;
use tokio::sync::OnceCell;
use uuid::Uuid;
pub mod pipeline;
pub fn init_tracing() {
tracing_subscriber::fmt::init();
}
static BACKGROUND_JOBS: OnceCell<()> = OnceCell::const_new();
/// Lazily starts the feed-polling/scoring scheduler on first use. Deferred
/// rather than started at process boot because `dioxus_server::launch_cfg`
/// owns the tokio runtime construction — this runs the first time a server
/// function executes, which is guaranteed to already be inside that runtime.
async fn ensure_background_jobs_started(db: Arc<Db>) {
BACKGROUND_JOBS
.get_or_init(|| async {
// TODO: move base_url/model names to config/env once there's a
// settings story; hardcoded to models already pulled locally.
let llm = Arc::new(
Llm::new(
"http://localhost:11434",
"nomic-embed-text",
768,
"gemma4-e4b",
)
.expect("failed to construct ollama client"),
);
if let Err(err) = pipeline::start_scheduler(db, llm).await {
tracing::error!(?err, "failed to start background job scheduler");
}
})
.await;
}
pub async fn db() -> Result<Db, dioxus::prelude::ServerFnError> {
// TODO: hold this in a `OnceCell`/app-wide state instead of reconnecting
// per request once the server-state story is wired up.
let db = Db::connect("feedsignal.db")
.await
.map_err(|e| dioxus::prelude::ServerFnError::new(e.to_string()))?;
ensure_background_jobs_started(Arc::new(db.clone())).await;
Ok(db)
}
pub async fn list_ranked_articles_impl(db: Db) -> Result<Vec<ArticleView>> {
Ok(db
.list_ranked_articles(100)
.await?
.into_iter()
.map(
|(id, title, url, summary, topics, final_score)| ArticleView {
id,
title,
url,
summary,
topics,
final_score,
},
)
.collect())
}
/// Subscribes to a feed by URL: registers it (or updates its title if
/// already subscribed) and does an immediate first fetch so the reader
/// isn't empty until the next scheduled poll. Returns the number of
/// articles pulled in on this fetch.
pub async fn subscribe_feed_impl(db: Db, url: String) -> Result<usize> {
let url = url.trim();
anyhow::ensure!(!url.is_empty(), "feed URL is required");
// Register under a placeholder title first so the feed exists even if
// the fetch below fails (e.g. transient network error) — it'll be
// picked up by a later poll once feed polling is wired in.
let feed_id = db.upsert_feed(url, url).await?;
let (title, articles) = feedsignal_feeds::fetch_feed(url, feed_id).await?;
db.upsert_feed(url, &title).await?;
let count = articles.len();
for article in &articles {
db.insert_article(article).await?;
}
Ok(count)
}
pub async fn mark_dismissed_impl(db: Db, article_id: String) -> Result<()> {
let event = ReadingEvent {
id: Uuid::new_v4(),
article_id: Uuid::parse_str(&article_id)?,
occurred_at: chrono::Utc::now(),
outcome: ReadingOutcome::Dismissed,
};
db.record_event(&event).await?;
// Affinity re-weighting from this event happens in the batched pipeline
// job (see `pipeline::apply_pending_feedback`) rather than inline here,
// so a burst of dismissals doesn't serialize on read-modify-write of
// the single affinities row.
Ok(())
}