aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2026-02-26 03:02:24 +0100
committermetamuffin <metamuffin@disroot.org>2026-02-26 03:02:24 +0100
commiteb6648770e7de66ccafe44d114ecbb2c1eaf444d (patch)
tree2bce9f579b3ea7313f84df94b27fad813c66e9e1
parent7f7deec27e69ed110c52caddaa3a0c04430e71d9 (diff)
downloadjellything-eb6648770e7de66ccafe44d114ecbb2c1eaf444d.tar
jellything-eb6648770e7de66ccafe44d114ecbb2c1eaf444d.tar.bz2
jellything-eb6648770e7de66ccafe44d114ecbb2c1eaf444d.tar.zst
implement application-side continuation tokens
-rw-r--r--common/src/api.rs1
-rw-r--r--common/src/routes.rs5
-rw-r--r--database/src/kv/tests.rs10
-rw-r--r--database/src/lib.rs1
-rw-r--r--database/src/query_syntax.rs6
-rw-r--r--import/src/helpers.rs4
-rw-r--r--import/src/lib.rs8
-rw-r--r--locale/en.ini1
-rw-r--r--server/src/auth.rs4
-rw-r--r--server/src/compat/youtube.rs4
-rw-r--r--server/src/logic/stream.rs4
-rw-r--r--server/src/main.rs4
-rw-r--r--server/src/routes.rs2
-rw-r--r--server/src/ui/account/mod.rs4
-rw-r--r--server/src/ui/account/settings.rs4
-rw-r--r--server/src/ui/admin/users.rs6
-rw-r--r--server/src/ui/items.rs69
-rw-r--r--server/src/ui/mod.rs1
-rw-r--r--server/src/ui/node.rs4
-rw-r--r--server/src/ui/player.rs4
-rw-r--r--ui/src/components/node_list.rs5
21 files changed, 94 insertions, 57 deletions
diff --git a/common/src/api.rs b/common/src/api.rs
index f791cd8..89cfe16 100644
--- a/common/src/api.rs
+++ b/common/src/api.rs
@@ -49,6 +49,7 @@ fields! {
NODELIST_TITLE: &str = b"titl";
NODELIST_DISPLAYSTYLE: Tag = b"dsty";
NODELIST_ITEM: Object = b"item"; // multi
+ NODELIST_CONTINUATION: &str = b"cont";
MESSAGE_KIND: &str = b"kind";
MESSAGE_TEXT: &str = b"text";
diff --git a/common/src/routes.rs b/common/src/routes.rs
index b76fc98..96f36e2 100644
--- a/common/src/routes.rs
+++ b/common/src/routes.rs
@@ -42,9 +42,8 @@ pub fn u_node_slug_progress(node: &str, time: f64) -> String {
pub fn u_items() -> String {
"/items".to_string()
}
-pub fn u_items_filter(page: usize) -> String {
- // TODO
- format!("/items?page={page}")
+pub fn u_items_cont(cont: &str) -> String {
+ format!("/items?cont={cont}")
}
pub fn u_admin_users() -> String {
"/admin/users".to_string()
diff --git a/database/src/kv/tests.rs b/database/src/kv/tests.rs
index 85ee5d7..e3e6b3b 100644
--- a/database/src/kv/tests.rs
+++ b/database/src/kv/tests.rs
@@ -1,5 +1,5 @@
use crate::{
- Database, Filter, Query, Sort, Value,
+ Database, Filter, Query, Value,
test_shared::{AGE, NAME, new_alice, new_bob, new_charlie},
};
use anyhow::Result;
@@ -68,7 +68,7 @@ pub fn query_match_int() -> Result<()> {
db.transaction(&mut |txn| {
result = txn.query_single(Query {
filter: Filter::Match(Path(vec![AGE.0]), Value::U32(35)),
- sort: Sort::None,
+ ..Default::default()
})?;
Ok(())
})?;
@@ -95,7 +95,7 @@ pub fn query_match_str() -> Result<()> {
db.transaction(&mut |txn| {
result = txn.query_single(Query {
filter: Filter::Match(Path(vec![NAME.0]), "Alice".into()),
- sort: Sort::None,
+ ..Default::default()
})?;
Ok(())
})?;
@@ -114,7 +114,7 @@ pub fn query_match_str_after() -> Result<()> {
db.transaction(&mut |txn| {
result = txn.query_single(Query {
filter: Filter::Match(Path(vec![NAME.0]), "Alice".into()),
- sort: Sort::None,
+ ..Default::default()
})?;
Ok(())
})?;
@@ -132,7 +132,7 @@ pub fn query_match_str_after() -> Result<()> {
db.transaction(&mut |txn| {
result = txn.query_single(Query {
filter: Filter::Match(Path(vec![NAME.0]), "Alice".into()),
- sort: Sort::None,
+ ..Default::default()
})?;
Ok(())
})?;
diff --git a/database/src/lib.rs b/database/src/lib.rs
index 1f491de..13e4a0d 100644
--- a/database/src/lib.rs
+++ b/database/src/lib.rs
@@ -35,6 +35,7 @@ pub trait Transaction {
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Query<'a> {
+ pub continuation: Option<Vec<u8>>,
pub filter: Filter<'a>,
pub sort: Sort,
}
diff --git a/database/src/query_syntax.rs b/database/src/query_syntax.rs
index f3d1166..9efc3d5 100644
--- a/database/src/query_syntax.rs
+++ b/database/src/query_syntax.rs
@@ -91,16 +91,17 @@ impl FromStr for Query<'static> {
Ok(Self {
filter: Filter::from_str(filter)?,
sort: Sort::from_str(sort)?,
+ ..Default::default()
})
} else if let Some(sort) = s.strip_prefix("SORT ") {
Ok(Self {
- filter: Filter::True,
sort: Sort::from_str(sort)?,
+ ..Default::default()
})
} else if let Some(filter) = s.strip_prefix("FILTER") {
Ok(Self {
filter: Filter::from_str(filter)?,
- sort: Sort::None,
+ ..Default::default()
})
} else {
bail!("invalid query")
@@ -192,6 +193,7 @@ fn test_parse() {
multi: MultiBehaviour::First,
offset: None,
}),
+ ..Default::default()
}
)
}
diff --git a/import/src/helpers.rs b/import/src/helpers.rs
index 5245a50..e42ea8e 100644
--- a/import/src/helpers.rs
+++ b/import/src/helpers.rs
@@ -9,12 +9,12 @@ use jellycommon::{
NO_SLUG,
jellyobject::{ObjectBuffer, Path},
};
-use jellydb::{Filter, Query, RowNum, Sort, Transaction};
+use jellydb::{Filter, Query, RowNum, Transaction};
pub fn get_or_insert_slug(txn: &mut dyn Transaction, slug: &str) -> Result<RowNum> {
match txn.query_single(Query {
filter: Filter::Match(Path(vec![NO_SLUG.0]), slug.into()),
- sort: Sort::None,
+ ..Default::default()
})? {
Some(r) => Ok(r),
None => txn.insert(ObjectBuffer::new(&mut [(NO_SLUG.0, &slug)])),
diff --git a/import/src/lib.rs b/import/src/lib.rs
index 232d9e0..684ef07 100644
--- a/import/src/lib.rs
+++ b/import/src/lib.rs
@@ -21,7 +21,7 @@ use jellycommon::{
jellyobject::{self, ObjectBuffer, Path as TagPath, Tag},
*,
};
-use jellydb::{Database, Filter, Query, RowNum, Sort};
+use jellydb::{Database, Filter, Query, RowNum};
use jellyremuxer::{
demuxers::create_demuxer_autodetect,
matroska::{self, AttachedFile, Segment},
@@ -84,7 +84,7 @@ pub struct ImportConfig {
fn node_slug_query<'a>(slug: &'a str) -> Query<'a> {
Query {
filter: Filter::Match(jellyobject::Path(vec![NO_SLUG.0]), slug.into()),
- sort: Sort::None,
+ ..Default::default()
}
}
@@ -455,7 +455,7 @@ fn compare_mtime(dba: &ImportConfig, path: &Path) -> Result<bool> {
TagPath(vec![IM_PATH.0]),
path.to_string_lossy().to_string().into(),
),
- sort: Sort::None,
+ ..Default::default()
})? {
None => was_changed = true,
Some(row) => {
@@ -478,7 +478,7 @@ fn update_mtime(dba: &ImportConfig, path: &Path) -> Result<()> {
TagPath(vec![IM_PATH.0]),
path.to_string_lossy().to_string().into(),
),
- sort: Sort::None,
+ ..Default::default()
})? {
Some(row) => row,
None => txn.insert(ObjectBuffer::new(&mut [(
diff --git a/locale/en.ini b/locale/en.ini
index 048886f..c0edecb 100644
--- a/locale/en.ini
+++ b/locale/en.ini
@@ -81,6 +81,7 @@ tag.iden.isrc=ISRC
tag.iden.mbar=MusicBrainz Artist
tag.iden.mbrc=MusicBrainz Recording
tag.iden.mbrg=MusicBrainz Release Group
+tag.iden.mbrl=MusicBrainz Release
tag.iden.omdb=OMDB
tag.iden.tmmv=TMDB (Movie)
tag.iden.tmpe=TMDB (Person)
diff --git a/server/src/auth.rs b/server/src/auth.rs
index 26da82b..d973dfc 100644
--- a/server/src/auth.rs
+++ b/server/src/auth.rs
@@ -11,7 +11,7 @@ use jellycommon::{
jellyobject::{ObjectBuffer, Path},
*,
};
-use jellydb::{Filter, Query, Sort};
+use jellydb::{Filter, Query};
pub fn token_to_user(state: &State, token: &str) -> Result<ObjectBuffer> {
let user_row = token::validate(&state.session_key, token)?;
@@ -38,7 +38,7 @@ pub fn login(
state.database.transaction(&mut |txn| {
user_row = txn.query_single(Query {
filter: Filter::Match(Path(vec![USER_LOGIN.0]), username.into()),
- sort: Sort::None,
+ ..Default::default()
})?;
if let Some(ur) = user_row {
user = txn.get(ur)?;
diff --git a/server/src/compat/youtube.rs b/server/src/compat/youtube.rs
index a5f540b..4ba3dc8 100644
--- a/server/src/compat/youtube.rs
+++ b/server/src/compat/youtube.rs
@@ -8,7 +8,7 @@ use anyhow::anyhow;
use jellycommon::{
IDENT_YOUTUBE_VIDEO, NO_IDENTIFIERS, NO_SLUG, jellyobject::Path, routes::u_node_id,
};
-use jellydb::{Filter, Query, Sort};
+use jellydb::{Filter, Query};
use rocket::{get, response::Redirect};
#[get("/watch?<v>")]
@@ -23,7 +23,7 @@ pub fn r_youtube_watch(ri: RequestInfo<'_>, v: &str) -> MyResult<Redirect> {
Path(vec![NO_IDENTIFIERS.0, IDENT_YOUTUBE_VIDEO.0]),
v.into(),
),
- sort: Sort::None,
+ ..Default::default()
})? {
res = txn.get(row)?;
}
diff --git a/server/src/logic/stream.rs b/server/src/logic/stream.rs
index 6f0fdc4..e332811 100644
--- a/server/src/logic/stream.rs
+++ b/server/src/logic/stream.rs
@@ -9,7 +9,7 @@ use jellycommon::{
NO_SLUG, NO_TITLE, NO_TRACK, TR_SOURCE, TRSOURCE_LOCAL_PATH, jellyobject::Path,
stream::StreamSpec,
};
-use jellydb::{Filter, Query, Sort};
+use jellydb::{Filter, Query};
use jellystream::SMediaInfo;
use log::{info, warn};
use rocket::{
@@ -58,7 +58,7 @@ pub async fn r_stream(
ri.state.database.transaction(&mut |txn| {
if let Some(row) = txn.query_single(Query {
filter: Filter::Match(Path(vec![NO_SLUG.0]), slug.into()),
- sort: Sort::None,
+ ..Default::default()
})? {
node = txn.get(row)?;
}
diff --git a/server/src/main.rs b/server/src/main.rs
index cbad704..f3eabcf 100644
--- a/server/src/main.rs
+++ b/server/src/main.rs
@@ -17,7 +17,7 @@ use jellycommon::{
USER_ADMIN, USER_LOGIN, USER_PASSWORD,
jellyobject::{ObjectBuffer, Path},
};
-use jellydb::{Database, Filter, Query, Sort};
+use jellydb::{Database, Filter, Query};
use log::{error, info};
use routes::build_rocket;
use serde::Deserialize;
@@ -102,7 +102,7 @@ fn create_admin_user(state: &State) -> Result<()> {
state.database.transaction(&mut |txn| {
let admin_row = txn.query_single(Query {
filter: Filter::Match(Path(vec![USER_LOGIN.0]), "admin".into()),
- sort: Sort::None,
+ ..Default::default()
})?;
if admin_row.is_none() {
info!("Creating new admin user");
diff --git a/server/src/routes.rs b/server/src/routes.rs
index f17952a..fc1d5e6 100644
--- a/server/src/routes.rs
+++ b/server/src/routes.rs
@@ -25,6 +25,7 @@ use crate::{
assets::{r_image, r_image_fallback_person},
error::{r_api_catch, r_catch},
home::r_home,
+ items::r_items,
node::r_node,
player::r_player,
r_favicon, r_index,
@@ -101,6 +102,7 @@ pub(super) fn build_rocket(state: Arc<State>) -> Rocket<Build> {
r_image_fallback_person,
r_image,
r_index,
+ r_items,
r_node,
r_player,
r_playersync,
diff --git a/server/src/ui/account/mod.rs b/server/src/ui/account/mod.rs
index 837d49a..ab5093d 100644
--- a/server/src/ui/account/mod.rs
+++ b/server/src/ui/account/mod.rs
@@ -20,7 +20,7 @@ use jellycommon::{
routes::{u_account_login, u_home},
*,
};
-use jellydb::{Filter, Query, Sort};
+use jellydb::{Filter, Query};
use rocket::{
Either, FromForm,
form::{Contextual, Form},
@@ -74,7 +74,7 @@ pub fn r_account_login_post(
ri.state.database.transaction(&mut |txn| {
let user_row = txn.query_single(Query {
filter: Filter::Match(Path(vec![USER_LOGIN.0]), form.username.clone().into()),
- sort: Sort::None,
+ ..Default::default()
})?;
if let Some(ur) = user_row {
let mut user = txn.get(ur)?.unwrap();
diff --git a/server/src/ui/account/settings.rs b/server/src/ui/account/settings.rs
index bb4b323..c1068f6 100644
--- a/server/src/ui/account/settings.rs
+++ b/server/src/ui/account/settings.rs
@@ -13,7 +13,7 @@ use jellycommon::{
routes::u_account_settings,
*,
};
-use jellydb::{Filter, Query, Sort};
+use jellydb::{Filter, Query};
use jellyui::tr;
use rocket::{
FromForm,
@@ -112,7 +112,7 @@ fn update_user(ri: &RequestInfo, update: impl Fn(Object) -> ObjectBuffer) -> MyR
let user_row = txn
.query_single(Query {
filter: Filter::Match(Path(vec![USER_LOGIN.0]), login.into()),
- sort: Sort::None,
+ ..Default::default()
})?
.ok_or(anyhow!("user vanished"))?;
diff --git a/server/src/ui/admin/users.rs b/server/src/ui/admin/users.rs
index 654a6b9..172facc 100644
--- a/server/src/ui/admin/users.rs
+++ b/server/src/ui/admin/users.rs
@@ -12,7 +12,7 @@ use jellycommon::{
routes::u_admin_users,
*,
};
-use jellydb::{Filter, Query, Sort};
+use jellydb::{Filter, Query};
use jellyui::tr;
use rand::random;
use rocket::{
@@ -88,8 +88,8 @@ pub fn r_admin_user(ri: RequestInfo<'_>, name: &str) -> MyResult<UiResponse> {
let mut page = OBB::new();
ri.state.database.transaction(&mut |txn| {
if let Some(row) = txn.query_single(Query {
- sort: Sort::None,
filter: Filter::Match(Path(vec![USER_LOGIN.0]), name.into()),
+ ..Default::default()
})? {
let user = txn.get(row)?.unwrap();
page = OBB::new();
@@ -106,8 +106,8 @@ pub fn r_admin_user_remove(ri: RequestInfo<'_>, name: &str) -> MyResult<Flash<Re
ri.require_admin()?;
ri.state.database.transaction(&mut |txn| {
if let Some(row) = txn.query_single(Query {
- sort: Sort::None,
filter: Filter::Match(Path(vec![USER_LOGIN.0]), name.into()),
+ ..Default::default()
})? {
txn.remove(row)?;
}
diff --git a/server/src/ui/items.rs b/server/src/ui/items.rs
index cc8c18f..6383830 100644
--- a/server/src/ui/items.rs
+++ b/server/src/ui/items.rs
@@ -3,27 +3,52 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2026 metamuffin <metamuffin.org>
*/
-use super::error::MyError;
-use rocket::{Either, get, response::content::RawHtml, serde::json::Json};
-#[get("/items?<page>&<filter..>")]
-pub fn r_items(
- ri: RequestInfo,
- page: Option<usize>,
- filter: ANodeFilterSort,
-) -> Result<Either<RawHtml<String>, Json<ApiItemsResponse>>, MyError> {
- let r = all_items(&ri.session, page, filter.clone().into())?;
- Ok(if matches!(ri.accept, Accept::Json) {
- Either::Right(Json(r))
- } else {
- Either::Left(RawHtml(render_page(
- &ItemsPage {
- lang: &ri.lang,
- r,
- filter: &filter.clone().into(),
- page: page.unwrap_or(0),
- },
- ri.render_info(),
- )))
- })
+use crate::{request_info::RequestInfo, ui::error::MyResult, ui_responder::UiResponse};
+use anyhow::anyhow;
+use base64::{Engine, prelude::BASE64_URL_SAFE};
+use jellycommon::{
+ jellyobject::{OBB, Path},
+ *,
+};
+use jellydb::{Filter, Query};
+use rocket::get;
+
+#[get("/items?<cont>")]
+pub fn r_items(ri: RequestInfo, cont: Option<&str>) -> MyResult<UiResponse> {
+ let cont = cont
+ .map(|s| BASE64_URL_SAFE.decode(s))
+ .transpose()
+ .map_err(|_| anyhow!("invalid contination token"))?;
+
+ let mut page = OBB::new();
+ ri.state.database.transaction(&mut |txn| {
+ let rows = txn
+ .query(Query {
+ filter: Filter::Has(Path(vec![NO_SLUG.0])),
+ continuation: cont.clone(),
+ ..Default::default()
+ })?
+ .take(64)
+ .collect::<Result<Vec<_>, _>>()?;
+
+ let mut list = OBB::new()
+ .with(NODELIST_DISPLAYSTYLE, NLSTYLE_GRID)
+ .with(NODELIST_TITLE, "items");
+
+ let mut iterstate = Vec::new();
+ for (r, is) in rows {
+ let node = txn.get(r)?.unwrap();
+ let nku = OBB::new().with(NKU_NODE, node.as_object()).finish();
+ list.push(NODELIST_ITEM, nku.as_object());
+ iterstate = is;
+ }
+ list.push(NODELIST_CONTINUATION, &BASE64_URL_SAFE.encode(iterstate));
+
+ page = OBB::new();
+ page.push(VIEW_NODE_LIST, list.finish().as_object());
+
+ Ok(())
+ })?;
+ Ok(ri.respond_ui(page))
}
diff --git a/server/src/ui/mod.rs b/server/src/ui/mod.rs
index 27535fa..6cb3cd2 100644
--- a/server/src/ui/mod.rs
+++ b/server/src/ui/mod.rs
@@ -18,6 +18,7 @@ pub mod home;
pub mod node;
pub mod player;
pub mod style;
+pub mod items;
#[get("/")]
pub async fn r_index(ri: RequestInfo<'_>) -> MyResult<Redirect> {
diff --git a/server/src/ui/node.rs b/server/src/ui/node.rs
index 509e9ae..55a1d09 100644
--- a/server/src/ui/node.rs
+++ b/server/src/ui/node.rs
@@ -23,7 +23,7 @@ pub fn r_node(ri: RequestInfo<'_>, slug: &str) -> MyResult<UiResponse> {
ri.state.database.transaction(&mut |txn| {
if let Some(row) = txn.query_single(Query {
filter: Filter::Match(Path(vec![NO_SLUG.0]), slug.into()),
- sort: Sort::None,
+ ..Default::default()
})? {
let n = txn.get(row)?.unwrap();
let nku = Object::EMPTY.insert(NKU_NODE, n.as_object());
@@ -78,6 +78,7 @@ fn c_children(
Filter::Match(Path(vec![NO_VISIBILITY.0]), VISI_VISIBLE.into()),
Filter::Match(Path(vec![NO_PARENT.0]), row.into()),
]),
+ ..Default::default()
})?
.collect::<Result<Vec<_>>>()?;
@@ -163,6 +164,7 @@ fn c_credited(page: &mut ObjectBufferBuilder, txn: &mut dyn Transaction, row: u6
Filter::Match(Path(vec![NO_VISIBILITY.0]), VISI_VISIBLE.into()),
Filter::Match(Path(vec![NO_CREDIT.0, CR_NODE.0]), row.into()),
]),
+ ..Default::default()
})?
.collect::<Result<Vec<_>>>()?;
diff --git a/server/src/ui/player.rs b/server/src/ui/player.rs
index 4c592e4..f0d6dea 100644
--- a/server/src/ui/player.rs
+++ b/server/src/ui/player.rs
@@ -9,7 +9,7 @@ use jellycommon::{
jellyobject::{OBB, Object, Path},
*,
};
-use jellydb::{Filter, Query, Sort};
+use jellydb::{Filter, Query};
use rocket::get;
// fn jellynative_url(action: &str, seek: f64, secret: &str, node: &str, session: &str) -> String {
@@ -34,7 +34,7 @@ pub fn r_player(ri: RequestInfo<'_>, t: Option<f64>, slug: &str) -> MyResult<UiR
ri.state.database.transaction(&mut |txn| {
if let Some(row) = txn.query_single(Query {
filter: Filter::Match(Path(vec![NO_SLUG.0]), slug.into()),
- sort: Sort::None,
+ ..Default::default()
})? {
let n = txn.get(row)?.unwrap();
let nku = Object::EMPTY.insert(NKU_NODE, n.as_object());
diff --git a/ui/src/components/node_list.rs b/ui/src/components/node_list.rs
index 679a11d..df405ea 100644
--- a/ui/src/components/node_list.rs
+++ b/ui/src/components/node_list.rs
@@ -8,7 +8,7 @@ use crate::{
RenderInfo,
components::node_card::{NodeCard, NodeCardHightlight, NodeCardWide},
};
-use jellycommon::{jellyobject::Object, *};
+use jellycommon::{jellyobject::Object, routes::u_items_cont, *};
use jellyui_locale::tr;
markup::define! {
@@ -40,5 +40,8 @@ markup::define! {
}
_ => {}
}
+ @if let Some(cont) = nl.get(NODELIST_CONTINUATION) {
+ a[href=u_items_cont(cont)] { button { "Show more" } }
+ }
}
}