Patreon creators split into their shows; filters follow settings
A Patreon token pasted into Add feed, or a creator link without &show=, becomes a folder of that creator's shows, found through Patreon's web API and kept in step like a subscribed OPML (sync_group, split out of sync_opml). A creator already read as one feed is split too: each show takes over the files and read state it held (Db::adopt). A creator with one show stays a plain feed. Filter verdicts are judged again every scan, so turning on Allow explicit brings skipped items back. Add feed has an explicit box. Feeds in a group follow your settings on the group, as its dialog said. A new feed no longer takes the id of a removed one at a different URL and shows its old items. See CHANGELOG.md [Unreleased] and docs/history.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wi22VSVrkAvqNj61eqHsm9
This commit is contained in:
134
src/db.rs
134
src/db.rs
@@ -920,14 +920,20 @@ impl Db {
|
||||
|
||||
/// Everyone's settings for one feed. The scanner merges these into what it fetches
|
||||
/// and downloads, since one file serves the lot.
|
||||
pub fn subscribers(&self, feed_id: &str) -> Result<Vec<Sub>> {
|
||||
/// In a group, whatever someone has not set on the feed itself comes from their
|
||||
/// subscription to the group, as the group's settings dialog has always said it does.
|
||||
pub fn subscribers(&self, feed_id: &str, group: Option<&str>) -> Result<Vec<Sub>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT keywords, auto_download, allow_explicit, max_new_per_check
|
||||
FROM subscriptions WHERE feed_id = ?1",
|
||||
"SELECT coalesce(c.keywords, p.keywords), coalesce(c.auto_download, p.auto_download),
|
||||
coalesce(c.allow_explicit, p.allow_explicit),
|
||||
coalesce(c.max_new_per_check, p.max_new_per_check)
|
||||
FROM subscriptions c
|
||||
LEFT JOIN subscriptions p ON p.user_id = c.user_id AND p.feed_id = ?2
|
||||
WHERE c.feed_id = ?1",
|
||||
)?;
|
||||
let out = stmt
|
||||
.query_map([feed_id], |r| {
|
||||
.query_map(params![feed_id, group], |r| {
|
||||
Ok(Sub {
|
||||
feed_id: feed_id.to_string(),
|
||||
keywords: r
|
||||
@@ -1325,6 +1331,71 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Empties a feed of its items, leaving its files alone.
|
||||
pub fn clear_entries(&self, feed_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM entries WHERE feed_id = ?1", [feed_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every feed the database holds rows for, as (id, url), removed ones included.
|
||||
pub fn feed_urls(&self) -> Result<Vec<(String, String)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, coalesce(url, '') FROM feeds")?;
|
||||
let out = stmt
|
||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Hands a feed in a group the enclosures its parent holds, as (guid, url), with everyone's
|
||||
/// read state for them. A Patreon creator read as one feed before it was split into shows
|
||||
/// owns every show's files, and `enclosures.url` is unique, so without this each show would
|
||||
/// list its items with nothing to play.
|
||||
pub fn adopt(&self, parent: &str, child: &str, listed: &[(&str, &str)]) -> Result<()> {
|
||||
let mut conn = self.conn.lock().unwrap();
|
||||
let holds: bool = conn.query_row(
|
||||
"SELECT EXISTS (SELECT 1 FROM enclosures WHERE feed_id = ?1)",
|
||||
[parent],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !holds {
|
||||
return Ok(()); // An OPML, or a creator already shared out.
|
||||
}
|
||||
let tx = conn.transaction()?;
|
||||
for &(guid, url) in listed {
|
||||
let moved = tx.execute(
|
||||
"UPDATE enclosures SET feed_id = ?3, guid = ?4 WHERE url = ?1 AND feed_id = ?2",
|
||||
params![url, parent, child, guid],
|
||||
)?;
|
||||
if moved == 1 {
|
||||
// Patreon gives a post the same guid in every feed it appears in.
|
||||
tx.execute(
|
||||
"UPDATE OR IGNORE entry_state SET feed_id = ?2 WHERE feed_id = ?1 AND guid = ?3",
|
||||
params![parent, child, guid],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A feed's enclosures skipped by one of its filters, by URL, with the reason: the verdicts a
|
||||
/// change of settings can overturn. A torrent held back while torrents are off is not a
|
||||
/// filter's call.
|
||||
pub fn skipped_by_filter(&self, feed_id: &str) -> Result<std::collections::HashMap<String, String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT url, last_error FROM enclosures
|
||||
WHERE feed_id = ?1 AND state = 'skipped' AND last_error IS NOT NULL
|
||||
AND last_error != 'torrents disabled'",
|
||||
)?;
|
||||
let out = stmt
|
||||
.query_map([feed_id], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn set_orphaned(&self, feed_id: &str, on: bool) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
@@ -1561,6 +1632,61 @@ mod tests {
|
||||
assert_eq!(state(4), "done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_show_takes_over_what_its_creator_held() {
|
||||
let db = Db::memory().unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0);
|
||||
INSERT INTO enclosures (id, feed_id, guid, url, state, path, last_error) VALUES
|
||||
(1,'creator','a','u1','done','/x/a.mp3',NULL),
|
||||
(2,'creator','b','u2','skipped',NULL,'explicit'),
|
||||
(3,'creator','c','u3','skipped',NULL,'explicit'),
|
||||
(4,'other','d','u4','skipped',NULL,'torrents disabled');
|
||||
INSERT INTO entry_state (user_id, feed_id, guid, read) VALUES (1,'creator','a',1);",
|
||||
)
|
||||
.unwrap();
|
||||
db.adopt("creator", "show", &[("a", "u1"), ("b", "u2"), ("d", "u4")]).unwrap();
|
||||
{
|
||||
let conn = db.conn.lock().unwrap();
|
||||
let owner = |id: i64| -> String {
|
||||
conn.query_row("SELECT feed_id FROM enclosures WHERE id = ?1", [id], |r| r.get(0)).unwrap()
|
||||
};
|
||||
assert_eq!(owner(1), "show", "a downloaded file moves with its item");
|
||||
assert_eq!(owner(2), "show");
|
||||
assert_eq!(owner(3), "creator", "this show does not list it");
|
||||
assert_eq!(owner(4), "other", "only the parent's are taken");
|
||||
let read: String = conn
|
||||
.query_row("SELECT feed_id FROM entry_state WHERE user_id = 1 AND guid = 'a'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(read, "show", "what you had read stays read");
|
||||
}
|
||||
|
||||
// Only a filter's verdict can be overturned by a change of settings.
|
||||
let skipped = db.skipped_by_filter("show").unwrap();
|
||||
assert_eq!(skipped.get("u2").map(String::as_str), Some("explicit"));
|
||||
assert_eq!(skipped.len(), 1);
|
||||
assert!(db.skipped_by_filter("other").unwrap().is_empty(), "torrents disabled is not a filter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_in_a_group_follows_your_settings_on_the_group() {
|
||||
let db = Db::memory().unwrap();
|
||||
db.exec_for_test(
|
||||
"INSERT INTO users (id, name, is_admin, created) VALUES (1,'ray',1,0),(2,'sam',0,0);
|
||||
INSERT INTO subscriptions (user_id, feed_id, allow_explicit, created) VALUES
|
||||
(1,'group',1,0),(1,'show',NULL,0),(2,'group',1,0),(2,'show',0,0);",
|
||||
)
|
||||
.unwrap();
|
||||
let explicit = |group| -> Vec<Option<bool>> {
|
||||
let mut v: Vec<_> =
|
||||
db.subscribers("show", group).unwrap().into_iter().map(|s| s.allow_explicit).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
assert_eq!(explicit(Some("group")), [Some(false), Some(true)], "ray inherits; sam's own choice on the show wins");
|
||||
assert_eq!(explicit(None), [None, Some(false)], "outside a group nothing is inherited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosure_url_is_the_dedupe_key() {
|
||||
let db = Db::memory().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user