Per-user read state and subscriptions

Read, starred and position move to entry_state; subscriptions carry each
person's keywords, auto-download, explicit and per-scan limit. The feed
list and unread counts are per person, and the existing library is
adopted by the admin on first start.

The feed URL, folder and schedule stay shared and admin-only: one file
serves everyone, so they describe the file rather than a preference.
Scanning merges subscribers' wants -- anyone wanting an item is enough --
via merge_policy, which is pure and tested.

Also: the test fixture wiped its data directory from every Playwright
worker, deleting the database out from under the running daemon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-11 02:17:16 +00:00
parent 4810bb5cfb
commit d46ec73261
7 changed files with 707 additions and 93 deletions

View File

@@ -344,6 +344,16 @@ async fn daemon(
);
}
// A library that predates accounts belongs to whoever was using it: the admin.
if let Some(admin) = ctx.db.users()?.into_iter().find(|u| u.is_admin) {
let catalogue: Vec<String> = ctx.cfg().feeds.keys().cloned().collect();
match ctx.db.adopt_existing_library(admin.id, &catalogue) {
Ok(0) => {}
Ok(n) => tracing::info!(user = %admin.name, entries = n, "adopted the existing library"),
Err(e) => tracing::error!(error = %e, "could not adopt the existing library"),
}
}
match migrate_opml_children(&ctx) {
Ok(n) if n > 0 => tracing::info!(count = n, "moved OPML feeds out of config.toml into the database"),
Ok(_) => {}
@@ -957,6 +967,7 @@ async fn scan_one(
parsed.image.as_deref(),
)?;
let policy = policy_for(ctx, id, feed_cfg)?;
let mut scan = Scan::default();
for entry in &parsed.entries {
if ctx.db.record_entry(id, entry)? {
@@ -968,18 +979,14 @@ async fn scan_one(
}
// Filters run once, at discovery, and are recorded in `state`. The download
// queue below is then just "everything still pending".
if let Some(reason) = reject(&ctx.cfg(), feed_cfg, entry, enc) {
if let Some(reason) = reject(&ctx.cfg(), feed_cfg, &policy, entry, enc) {
ctx.db.mark_enclosure(&enc.url, "skipped", Some(reason))?;
}
}
}
// An unset per-feed cap follows the global one; 0 there means unlimited.
let budget = feed_cfg.max_new_per_check.unwrap_or_else(|| {
let g = ctx.cfg().general.max_new_per_check;
if g == 0 { usize::MAX } else { g }
});
if feed_cfg.auto_download && budget > 0 {
let budget = policy.budget;
if policy.auto_download && budget > 0 {
let cfg = ctx.cfg();
let folder = download::folder_for(&cfg, id, feed_cfg, parsed.title.as_deref());
let dest_dir = cfg.general.download_dir.join(&folder);
@@ -1099,6 +1106,23 @@ async fn sync_opml(
added.push(id);
}
// Whoever subscribes to the OPML subscribes to what it lists: that is what taking a
// subscription means. Their own feeds are untouched.
for id in ctx
.db
.managed_feeds()?
.iter()
.filter(|m| m.group_id == parent_id)
.map(|m| m.id.clone())
.chain(std::iter::once(parent_id.to_string()))
{
for user in ctx.db.users()? {
if ctx.db.subscription(user.id, parent_id)?.is_some() {
ctx.db.subscribe(user.id, &id)?;
}
}
}
// Anything in this group the OPML no longer lists.
let mut removed = 0;
let mut kept = 0;
@@ -1125,11 +1149,12 @@ async fn sync_opml(
fn reject(
cfg: &config::Config,
feed_cfg: &config::Feed,
policy: &Policy,
entry: &feed::Entry,
enc: &feed::Enclosure,
) -> Option<&'static str> {
let url = enc.url.as_str();
if !feed_cfg.auto_download {
if !policy.auto_download {
return Some("auto_download is off");
}
// Blog feeds put the article's header image in an <enclosure>; without this a text
@@ -1141,7 +1166,7 @@ fn reject(
if !config::wanted_media(enc.mime.as_deref(), wanted) {
return Some("not audio or video");
}
if entry.explicit && !feed_cfg.allow_explicit {
if entry.explicit && !policy.allow_explicit {
return Some("explicit");
}
let categories = entry.categories.join(" ");
@@ -1151,12 +1176,79 @@ fn reject(
entry.description.as_deref().unwrap_or(""),
categories.as_str(),
];
if !download::matches_keywords(&feed_cfg.keywords, &haystacks) {
// One file serves everyone subscribed, so an item is wanted if it is wanted by
// anyone: any one person's keyword set matching is enough.
let wanted_by_someone = policy.keyword_sets.is_empty()
|| policy
.keyword_sets
.iter()
.any(|set| download::matches_keywords(set, &haystacks));
if !wanted_by_someone {
return Some("no keyword match");
}
None
}
/// What the scanner should do for a feed, merged across everyone subscribed to it. The
/// feed is fetched once and its files are downloaded once, so the merge is a union: if
/// one person wants a thing, it is fetched, and everyone else simply sees it listed.
///
/// With no subscribers at all -- a hand-written config entry nobody has claimed yet --
/// the feed's own settings stand, which is how a single-user install behaves.
pub struct Policy {
pub auto_download: bool,
pub allow_explicit: bool,
/// Empty means take everything. Otherwise one set per subscriber who filters.
pub keyword_sets: Vec<Vec<String>>,
pub budget: usize,
}
fn policy_for(ctx: &Ctx, id: &str, feed_cfg: &config::Feed) -> Result<Policy> {
let global = ctx.cfg().general.max_new_per_check;
Ok(merge_policy(&ctx.db.subscribers(id)?, feed_cfg, global))
}
fn merge_policy(subs: &[db::Sub], feed_cfg: &config::Feed, global: usize) -> Policy {
let cap = |n: Option<usize>| n.unwrap_or(if global == 0 { usize::MAX } else { global });
if subs.is_empty() {
return Policy {
auto_download: feed_cfg.auto_download,
allow_explicit: feed_cfg.allow_explicit,
keyword_sets: if feed_cfg.keywords.is_empty() {
vec![]
} else {
vec![feed_cfg.keywords.clone()]
},
budget: cap(feed_cfg.max_new_per_check),
};
}
let mut policy = Policy {
auto_download: false,
allow_explicit: false,
keyword_sets: vec![],
budget: 0,
};
for sub in subs {
if !sub.auto_download.unwrap_or(feed_cfg.auto_download) {
continue; // Not fetching for this person, so their wants add nothing.
}
policy.auto_download = true;
policy.allow_explicit |= sub.allow_explicit.unwrap_or(feed_cfg.allow_explicit);
policy.budget = policy
.budget
.max(cap(sub.max_new_per_check.map(|n| n as usize).or(feed_cfg.max_new_per_check)));
let kw = sub.keywords.clone().unwrap_or_else(|| feed_cfg.keywords.clone());
if kw.is_empty() {
// Somebody takes everything, so no filter can apply to the shared copy.
return Policy { keyword_sets: vec![], ..policy };
}
policy.keyword_sets.push(kw);
}
policy
}
async fn fetch_one(
ctx: &Arc<Ctx>,
feed_id: &str,
@@ -1352,3 +1444,79 @@ fn duration(secs: u64) -> String {
s => format!("{}d", s / 86_400),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn feed() -> config::Feed {
// Whatever `ipx add` would write, which is the shape every code path sees.
let mut cfg = config::Config::default();
let f = add_one_cfg(&mut cfg, "http://x/f.xml", None, vec![]);
f
}
/// The feed entry `add` builds, without the network round trip it does for a title.
fn add_one_cfg(
_cfg: &mut config::Config,
url: &str,
folder: Option<String>,
keywords: Vec<String>,
) -> config::Feed {
config::Feed {
url: url.into(),
folder,
keywords,
allow_explicit: false,
auto_download: true,
group: None,
media_types: None,
schedule: None,
max_new_per_check: None,
username: None,
password: None,
password_env: None,
}
}
fn sub(kw: Option<&[&str]>, auto: Option<bool>, max: Option<i64>) -> db::Sub {
db::Sub {
feed_id: "f".into(),
keywords: kw.map(|k| k.iter().map(|s| s.to_string()).collect()),
auto_download: auto,
allow_explicit: None,
max_new_per_check: max,
}
}
#[test]
fn a_shared_feed_is_fetched_for_whoever_wants_the_most() {
// Nobody subscribed: the feed's own settings stand, as in a single-user install.
let p = merge_policy(&[], &feed(), 3);
assert!(p.auto_download);
assert_eq!(p.budget, 3);
assert!(p.keyword_sets.is_empty());
// Two filters: an item wanted by either of them is fetched, since one file serves
// both. The larger per-scan cap wins for the same reason.
let p = merge_policy(
&[sub(Some(&["rust"]), None, Some(2)), sub(Some(&["sqlite"]), None, Some(9))],
&feed(),
3,
);
assert_eq!(p.keyword_sets.len(), 2);
assert_eq!(p.budget, 9);
// One person taking everything removes the filter for the shared copy.
let p = merge_policy(&[sub(Some(&["rust"]), None, None), sub(Some(&[]), None, None)], &feed(), 3);
assert!(p.keyword_sets.is_empty());
// Everyone has auto-download off: nothing is fetched automatically.
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(false), None)], &feed(), 3);
assert!(!p.auto_download);
// One of them wants it, so it is fetched.
let p = merge_policy(&[sub(None, Some(false), None), sub(None, Some(true), None)], &feed(), 3);
assert!(p.auto_download);
}
}