From 0ce64a50b763d2b19f5ca254233370418f4b7658 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Thu, 6 Feb 2025 16:52:46 +0100 Subject: add json capability to most useful endpoints --- common/Cargo.toml | 2 +- common/src/api.rs | 36 ++++++++++++++++ common/src/lib.rs | 1 + import/src/lib.rs | 1 + import/src/matroska.rs | 6 +-- import/src/trakt.rs | 2 +- server/Cargo.toml | 2 +- server/src/routes/api.rs | 89 ++++++++++++++++++++++++++++++++++++++++ server/src/routes/api/mod.rs | 90 ---------------------------------------- server/src/routes/mod.rs | 9 ++-- server/src/routes/playersync.rs | 2 +- server/src/routes/ui/browser.rs | 57 +++++++++++++++----------- server/src/routes/ui/home.rs | 91 +++++++++++++++++++++-------------------- server/src/routes/ui/mod.rs | 31 ++++++++++++-- server/src/routes/ui/node.rs | 67 ++++++++++++++++++------------ server/src/routes/ui/search.rs | 63 +++++++++++++++++----------- 16 files changed, 326 insertions(+), 223 deletions(-) create mode 100644 common/src/api.rs create mode 100644 server/src/routes/api.rs delete mode 100644 server/src/routes/api/mod.rs diff --git a/common/Cargo.toml b/common/Cargo.toml index 066e79c..9038bc4 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -serde = { version = "1.0.217", features = ["derive"] } +serde = { version = "1.0.217", features = ["derive", "rc"] } bincode = { version = "2.0.0-rc.3", features = ["derive"] } rocket = { workspace = true, optional = true } chrono = { version = "0.4.39", features = ["serde"] } diff --git a/common/src/api.rs b/common/src/api.rs new file mode 100644 index 0000000..d0b1db7 --- /dev/null +++ b/common/src/api.rs @@ -0,0 +1,36 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin +*/ + +use crate::{user::NodeUserData, Node}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[derive(Serialize, Deserialize)] +pub struct ApiNodeResponse { + pub parents: Vec<(Arc, NodeUserData)>, + pub children: Vec<(Arc, NodeUserData)>, + pub node: Arc, + pub userdata: NodeUserData, +} + +#[derive(Serialize, Deserialize)] +pub struct ApiSearchResponse { + pub count: usize, + pub results: Vec<(Arc, NodeUserData)>, +} + +#[derive(Serialize, Deserialize)] +pub struct ApiItemsResponse { + pub count: usize, + pub pages: usize, + pub items: Vec<(Arc, NodeUserData)>, +} + +#[derive(Serialize, Deserialize)] +pub struct ApiHomeResponse { + pub toplevel: Vec<(Arc, NodeUserData)>, + pub categories: Vec<(String, Vec<(Arc, NodeUserData)>)>, +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 4b67054..74476fe 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -3,6 +3,7 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ +pub mod api; pub mod config; pub mod helpers; pub mod r#impl; diff --git a/import/src/lib.rs b/import/src/lib.rs index 0990ba1..7b19a61 100644 --- a/import/src/lib.rs +++ b/import/src/lib.rs @@ -410,6 +410,7 @@ fn import_media_file( NodeKind::Video }; node.title = Some(infojson.title); + node.subtitle = infojson.uploader; if let Some(desc) = infojson.description { node.description = Some(desc) } diff --git a/import/src/matroska.rs b/import/src/matroska.rs index 4694833..4ab1148 100644 --- a/import/src/matroska.rs +++ b/import/src/matroska.rs @@ -16,7 +16,7 @@ use jellybase::{ cache::{cache_file, cache_memory}, common::Asset, }; -use log::info; +use log::{info, warn}; use std::{ fs::File, io::{BufReader, ErrorKind, Read, Write}, @@ -41,7 +41,7 @@ pub(crate) fn matroska_metadata(path: &Path) -> Result Result { - eprintln!("unknown top-level element {id:x}"); + warn!("unknown top-level element {id:x}"); seg.consume()?; } } diff --git a/import/src/trakt.rs b/import/src/trakt.rs index 1daee77..52a5cb0 100644 --- a/import/src/trakt.rs +++ b/import/src/trakt.rs @@ -1,9 +1,9 @@ -use anyhow::Context; /* This file is part of jellything (https://codeberg.org/metamuffin/jellything) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ +use anyhow::Context; use bincode::{Decode, Encode}; use jellybase::{ cache::async_cache_memory, diff --git a/server/Cargo.toml b/server/Cargo.toml index eb0b036..a709a98 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ jellystream = { path = "../stream" } jellytranscoder = { path = "../transcoder" } jellyimport = { path = "../import" } -serde = { version = "1.0.217", features = ["derive"] } +serde = { version = "1.0.217", features = ["derive", "rc"] } bincode = { version = "2.0.0-rc.3", features = ["serde", "derive"] } serde_json = "1.0.138" diff --git a/server/src/routes/api.rs b/server/src/routes/api.rs new file mode 100644 index 0000000..f761a8f --- /dev/null +++ b/server/src/routes/api.rs @@ -0,0 +1,89 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin +*/ +use super::ui::{ + account::{login_logic, session::AdminSession}, + error::MyResult, +}; +use crate::database::Database; +use jellybase::assetfed::AssetInner; +use jellycommon::user::CreateSessionParams; +use rocket::{ + get, + http::MediaType, + outcome::Outcome, + post, + request::{self, FromRequest}, + response::Redirect, + serde::json::Json, + Request, State, +}; +use serde_json::{json, Value}; +use std::ops::Deref; + +#[get("/api")] +pub fn r_api_root() -> Redirect { + Redirect::moved("https://jellything.metamuffin.org/book/api.html#jellything-http-api") +} + +#[get("/api/version")] +pub fn r_api_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +#[post("/api/create_session", data = "")] +pub fn r_api_account_login( + database: &State, + data: Json, +) -> MyResult { + let token = login_logic( + database, + &data.username, + &data.password, + data.expire, + data.drop_permissions.clone(), + )?; + Ok(json!(token)) +} + +#[get("/api/asset_token_raw/")] +pub fn r_api_asset_token_raw(_admin: AdminSession, token: &str) -> MyResult> { + Ok(Json(AssetInner::deser(token)?)) +} + +pub struct AcceptJson(bool); +impl Deref for AcceptJson { + type Target = bool; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl<'r> FromRequest<'r> for AcceptJson { + type Error = (); + + fn from_request<'life0, 'async_trait>( + request: &'r Request<'life0>, + ) -> ::core::pin::Pin< + Box< + dyn ::core::future::Future> + + ::core::marker::Send + + 'async_trait, + >, + > + where + 'r: 'async_trait, + 'life0: 'async_trait, + Self: 'async_trait, + { + Box::pin(async move { + Outcome::Success(AcceptJson( + request + .accept() + .map(|a| a.preferred().exact_eq(&MediaType::JSON)) + .unwrap_or(false), + )) + }) + } +} diff --git a/server/src/routes/api/mod.rs b/server/src/routes/api/mod.rs deleted file mode 100644 index 065c136..0000000 --- a/server/src/routes/api/mod.rs +++ /dev/null @@ -1,90 +0,0 @@ -/* - This file is part of jellything (https://codeberg.org/metamuffin/jellything) - which is licensed under the GNU Affero General Public License (version 3); see /COPYING. - Copyright (C) 2025 metamuffin -*/ -use super::ui::{ - account::{login_logic, session::AdminSession}, - error::MyResult, -}; -use crate::database::Database; -use jellybase::assetfed::AssetInner; -use jellycommon::user::CreateSessionParams; -use rocket::{ - get, - http::MediaType, - outcome::Outcome, - post, - request::{self, FromRequest}, - response::Redirect, - serde::json::Json, - Request, State, -}; -use serde_json::{json, Value}; -use std::ops::Deref; - -#[get("/api")] -pub fn r_api_root() -> Redirect { - Redirect::moved("https://jellything.metamuffin.org/book/api.html#jellything-http-api") -} - -#[get("/api/version")] -pub fn r_api_version() -> &'static str { - env!("CARGO_PKG_VERSION") -} - -#[post("/api/create_session", data = "")] -pub fn r_api_account_login( - database: &State, - data: Json, -) -> MyResult { - let token = login_logic( - database, - &data.username, - &data.password, - data.expire, - data.drop_permissions.clone(), - )?; - Ok(json!(token)) -} - -#[get("/api/asset_token_raw/")] -pub fn r_api_asset_token_raw(admin: AdminSession, token: &str) -> MyResult> { - drop(admin); - Ok(Json(AssetInner::deser(token)?)) -} - -pub struct AcceptJson(bool); -impl Deref for AcceptJson { - type Target = bool; - fn deref(&self) -> &Self::Target { - &self.0 - } -} -impl<'r> FromRequest<'r> for AcceptJson { - type Error = (); - - fn from_request<'life0, 'async_trait>( - request: &'r Request<'life0>, - ) -> ::core::pin::Pin< - Box< - dyn ::core::future::Future> - + ::core::marker::Send - + 'async_trait, - >, - > - where - 'r: 'async_trait, - 'life0: 'async_trait, - Self: 'async_trait, - { - Box::pin(async move { - Outcome::Success(AcceptJson( - request - .accept() - .map(|a| a.preferred().exact_eq(&MediaType::JSON)) - .unwrap_or(false), - )) - }) - } -} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 371f088..e48b7cf 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -3,7 +3,7 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ -use self::playersync::{r_streamsync, PlayersyncChannels}; +use self::playersync::{r_playersync, PlayersyncChannels}; use crate::{database::Database, routes::ui::error::MyResult}; use api::{r_api_account_login, r_api_asset_token_raw, r_api_root, r_api_version}; use base64::Engine; @@ -58,9 +58,10 @@ use ui::{ assets::{r_asset, r_item_backdrop, r_item_poster, r_node_thumbnail, r_person_asset}, browser::r_all_items_filter, error::{r_api_catch, r_catch}, - home::{r_home, r_home_unpriv}, + home::r_home, node::r_library_node_filter, player::r_player, + r_index, search::r_search, style::{r_assets_font, r_assets_js, r_assets_js_map, r_assets_style}, }; @@ -154,8 +155,8 @@ pub fn build_rocket(database: Database, federation: Federation) -> Rocket r_assets_js, r_assets_style, r_favicon, - r_home_unpriv, r_home, + r_index, r_item_backdrop, r_item_poster, r_library_node_filter, @@ -168,7 +169,7 @@ pub fn build_rocket(database: Database, federation: Federation) -> Rocket r_player, r_search, r_stream, - r_streamsync, + r_playersync, // API r_api_account_login, r_api_asset_token_raw, diff --git a/server/src/routes/playersync.rs b/server/src/routes/playersync.rs index 1ef9d73..9eb6175 100644 --- a/server/src/routes/playersync.rs +++ b/server/src/routes/playersync.rs @@ -23,7 +23,7 @@ pub enum Packet { } #[get("/playersync/")] -pub fn r_streamsync( +pub fn r_playersync( ws: WebSocket, state: &State, channel: &str, diff --git a/server/src/routes/ui/browser.rs b/server/src/routes/ui/browser.rs index 7affbac..13f30c8 100644 --- a/server/src/routes/ui/browser.rs +++ b/server/src/routes/ui/browser.rs @@ -6,13 +6,13 @@ use super::{ account::session::Session, error::MyError, - layout::DynLayoutPage, + layout::{DynLayoutPage, LayoutPage}, node::NodeCard, sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty}, }; -use crate::{database::Database, uri}; -use jellycommon::Visibility; -use rocket::{get, State}; +use crate::{database::Database, routes::api::AcceptJson, uri}; +use jellycommon::{api::ApiItemsResponse, Visibility}; +use rocket::{get, serde::json::Json, Either, State}; /// This function is a stub and only useful for use in the uri! macro. #[get("/items")] @@ -22,9 +22,10 @@ pub fn r_all_items() {} pub fn r_all_items_filter( sess: Session, db: &State, + aj: AcceptJson, page: Option, filter: NodeFilterSort, -) -> Result, MyError> { +) -> Result, Json>, MyError> { let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?; items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); @@ -42,26 +43,34 @@ pub fn r_all_items_filter( let to = (offset + page_size).min(items.len()); let max_page = items.len().div_ceil(page_size); - Ok(super::layout::LayoutPage { - title: "All Items".to_owned(), - content: markup::new! { - .page.dir { - h1 { "All Items" } - @NodeFilterSortForm { f: &filter } - ul.children { @for (node, udata) in &items[from..to] { - li {@NodeCard { node, udata }} - }} - p.pagecontrols { - span.current { "Page " @{page + 1} " of " @max_page " " } - @if page > 0 { - a.prev[href=uri!(r_all_items_filter(Some(page - 1), filter.clone()))] { "Previous page" } " " - } - @if page + 1 < max_page { - a.next[href=uri!(r_all_items_filter(Some(page + 1), filter.clone()))] { "Next page" } + Ok(if *aj { + Either::Right(Json(ApiItemsResponse { + count: items.len(), + pages: max_page, + items: items[from..to].to_vec(), + })) + } else { + Either::Left(LayoutPage { + title: "All Items".to_owned(), + content: markup::new! { + .page.dir { + h1 { "All Items" } + @NodeFilterSortForm { f: &filter } + ul.children { @for (node, udata) in &items[from..to] { + li {@NodeCard { node, udata }} + }} + p.pagecontrols { + span.current { "Page " @{page + 1} " of " @max_page " " } + @if page > 0 { + a.prev[href=uri!(r_all_items_filter(Some(page - 1), filter.clone()))] { "Previous page" } " " + } + @if page + 1 < max_page { + a.next[href=uri!(r_all_items_filter(Some(page + 1), filter.clone()))] { "Next page" } + } } } - } - }, - ..Default::default() + }, + ..Default::default() + }) }) } diff --git a/server/src/routes/ui/home.rs b/server/src/routes/ui/home.rs index ee98bd2..3e002ee 100644 --- a/server/src/routes/ui/home.rs +++ b/server/src/routes/ui/home.rs @@ -10,17 +10,23 @@ use super::{ }; use crate::{ database::Database, - routes::ui::{error::MyResult, layout::DynLayoutPage}, + routes::{ + api::AcceptJson, + ui::{error::MyResult, layout::DynLayoutPage}, + }, }; use anyhow::Context; use chrono::{Datelike, Utc}; use jellybase::CONF; -use jellycommon::{user::WatchedState, NodeID, NodeKind, Rating, Visibility}; -use rocket::{get, State}; -use tokio::fs::read_to_string; - -#[get("/")] -pub fn r_home(sess: Session, db: &State) -> MyResult { +use jellycommon::{api::ApiHomeResponse, user::WatchedState, NodeID, NodeKind, Rating, Visibility}; +use rocket::{get, serde::json::Json, Either, State}; + +#[get("/home")] +pub fn r_home( + sess: Session, + db: &State, + aj: AcceptJson, +) -> MyResult>> { let mut items = db.list_nodes_with_udata(&sess.user.name)?; let mut toplevel = db @@ -31,10 +37,10 @@ pub fn r_home(sess: Session, db: &State) -> MyResult { .collect::>>()?; toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX)); - let mut categories = Vec::<(&'static str, Vec<_>)>::new(); + let mut categories = Vec::<(String, Vec<_>)>::new(); categories.push(( - "Continue Watching", + "Continue Watching".to_string(), items .iter() .filter(|(_, u)| matches!(u.watched, WatchedState::Progress(_))) @@ -42,7 +48,7 @@ pub fn r_home(sess: Session, db: &State) -> MyResult { .collect(), )); categories.push(( - "Your Watchlist", + "Your Watchlist".to_string(), items .iter() .filter(|(_, u)| matches!(u.watched, WatchedState::Pending)) @@ -55,7 +61,7 @@ pub fn r_home(sess: Session, db: &State) -> MyResult { items.sort_by_key(|(n, _)| n.release_date.map(|d| -d).unwrap_or(i64::MAX)); categories.push(( - "Latest in Videos", + "Latest in Videos".to_string(), items .iter() .filter(|(n, _)| matches!(n.kind, NodeKind::Video)) @@ -64,7 +70,7 @@ pub fn r_home(sess: Session, db: &State) -> MyResult { .collect(), )); categories.push(( - "Latest in Music", + "Latest in Music".to_string(), items .iter() .filter(|(n, _)| matches!(n.kind, NodeKind::Music)) @@ -73,7 +79,7 @@ pub fn r_home(sess: Session, db: &State) -> MyResult { .collect(), )); categories.push(( - "Latest in Short form", + "Latest in Short form".to_string(), items .iter() .filter(|(n, _)| matches!(n.kind, NodeKind::ShortFormVideo)) @@ -90,7 +96,7 @@ pub fn r_home(sess: Session, db: &State) -> MyResult { }); categories.push(( - "Top Rated", + "Top Rated".to_string(), items .iter() .take(16) @@ -107,7 +113,7 @@ pub fn r_home(sess: Session, db: &State) -> MyResult { }); categories.push(( - "Today's Picks", + "Today's Picks".to_string(), (0..16) .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) .collect(), @@ -117,7 +123,7 @@ pub fn r_home(sess: Session, db: &State) -> MyResult { let mut items = items.clone(); items.retain(|(_, u)| matches!(u.watched, WatchedState::Watched)); categories.push(( - "Watch again", + "Watch again".to_string(), (0..16) .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) .collect(), @@ -126,41 +132,36 @@ pub fn r_home(sess: Session, db: &State) -> MyResult { items.retain(|(n, _)| matches!(n.kind, NodeKind::Music)); categories.push(( - "Discover Music", + "Discover Music".to_string(), (0..16) .flat_map(|i| Some(items[cheap_daily_random(i).checked_rem(items.len())?].clone())) .collect(), )); - Ok(LayoutPage { - title: "Home".to_string(), - content: markup::new! { - h2 { "Explore " @CONF.brand } - ul.children.hlist {@for (node, udata) in &toplevel { - li { @NodeCard { node, udata } } - }} - @for (name, nodes) in &categories { - @if !nodes.is_empty() { - h2 { @name } - ul.children.hlist {@for (node, udata) in nodes { - li { @NodeCard { node, udata } } - }} + Ok(if *aj { + Either::Right(Json(ApiHomeResponse { + toplevel, + categories, + })) + } else { + Either::Left(LayoutPage { + title: "Home".to_string(), + content: markup::new! { + h2 { "Explore " @CONF.brand } + ul.children.hlist {@for (node, udata) in &toplevel { + li { @NodeCard { node, udata } } + }} + @for (name, nodes) in &categories { + @if !nodes.is_empty() { + h2 { @name } + ul.children.hlist {@for (node, udata) in nodes { + li { @NodeCard { node, udata } } + }} + } } - } - }, - ..Default::default() - }) -} - -#[get("/", rank = 2)] -pub async fn r_home_unpriv() -> MyResult> { - let front = read_to_string(CONF.asset_path.join("front.htm")).await?; - Ok(LayoutPage { - title: "Home".to_string(), - content: markup::new! { - @markup::raw(&front) - }, - ..Default::default() + }, + ..Default::default() + }) }) } diff --git a/server/src/routes/ui/mod.rs b/server/src/routes/ui/mod.rs index 812e2d1..bffdfdd 100644 --- a/server/src/routes/ui/mod.rs +++ b/server/src/routes/ui/mod.rs @@ -3,13 +3,19 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ +use account::session::Session; +use error::MyResult; +use home::rocket_uri_macro_r_home; +use jellybase::CONF; +use layout::{DynLayoutPage, LayoutPage}; use log::debug; use markup::Render; use rocket::{ futures::FutureExt, + get, http::{ContentType, Header, Status}, - response::{self, Responder}, - Request, Response, + response::{self, Redirect, Responder}, + Either, Request, Response, }; use std::{ collections::hash_map::DefaultHasher, @@ -20,7 +26,10 @@ use std::{ path::Path, pin::Pin, }; -use tokio::{fs::File, io::AsyncRead}; +use tokio::{ + fs::{read_to_string, File}, + io::AsyncRead, +}; pub mod account; pub mod admin; @@ -35,6 +44,22 @@ pub mod search; pub mod sort; pub mod style; +#[get("/")] +pub async fn r_index(sess: Option) -> MyResult>> { + if sess.is_some() { + Ok(Either::Left(Redirect::temporary(rocket::uri!(r_home())))) + } else { + let front = read_to_string(CONF.asset_path.join("front.htm")).await?; + Ok(Either::Right(LayoutPage { + title: "Home".to_string(), + content: markup::new! { + @markup::raw(&front) + }, + ..Default::default() + })) + } +} + pub struct HtmlTemplate<'a>(pub markup::DynRender<'a>); impl<'r> Responder<'r, 'static> for HtmlTemplate<'_> { diff --git a/server/src/routes/ui/node.rs b/server/src/routes/ui/node.rs index 75893a8..365182c 100644 --- a/server/src/routes/ui/node.rs +++ b/server/src/routes/ui/node.rs @@ -31,6 +31,7 @@ use crate::{ use anyhow::{anyhow, Result}; use chrono::DateTime; use jellycommon::{ + api::ApiNodeResponse, user::{NodeUserData, WatchedState}, Chapter, MediaInfo, Node, NodeID, NodeKind, PeopleGroup, Rating, SourceTrackKind, Visibility, }; @@ -43,34 +44,39 @@ pub fn r_library_node(id: String) { drop(id) } -#[get("/n/?")] +#[get("/n/?&&")] pub async fn r_library_node_filter<'a>( session: Session, slug: &'a str, db: &'a State, aj: AcceptJson, filter: NodeFilterSort, -) -> MyResult, Json>> { + parents: bool, + children: bool, +) -> MyResult, Json>> { let id = NodeID::from_slug(slug); let (node, udata) = db.get_node_with_userdata(id, &session)?; - if *aj { - return Ok(Either::Right(Json((*node).clone()))); - } + let mut children = if !*aj || children { + db.get_node_children(id)? + .into_iter() + .map(|c| db.get_node_with_userdata(c, &session)) + .collect::>>()? + } else { + Vec::new() + }; - let mut children = db - .get_node_children(id)? - .into_iter() - .map(|c| db.get_node_with_userdata(c, &session)) - .collect::>>()?; - children.retain(|(n, _)| n.visibility >= Visibility::Reduced); + let mut parents = if !*aj || parents { + node.parents + .iter() + .map(|pid| db.get_node_with_userdata(*pid, &session)) + .collect::>>()? + } else { + Vec::new() + }; - let mut parents = node - .parents - .iter() - .flat_map(|pid| db.get_node(*pid).transpose()) - .collect::, _>>()?; - parents.retain(|n| n.visibility >= Visibility::Reduced); + children.retain(|(n, _)| n.visibility >= Visibility::Reduced); + parents.retain(|(n, _)| n.visibility >= Visibility::Reduced); filter_and_sort_nodes( &filter, @@ -82,13 +88,22 @@ pub async fn r_library_node_filter<'a>( &mut children, ); - Ok(Either::Left(LayoutPage { - title: node.title.clone().unwrap_or_default(), - content: markup::new! { - @NodePage { node: &node, id: slug, udata: &udata, children: &children, parents: &parents, filter: &filter } - }, - ..Default::default() - })) + Ok(if *aj { + Either::Right(Json(ApiNodeResponse { + children, + parents, + node, + userdata: udata, + })) + } else { + Either::Left(LayoutPage { + title: node.title.clone().unwrap_or_default(), + content: markup::new! { + @NodePage { node: &node, id: slug, udata: &udata, children: &children, parents: &parents, filter: &filter } + }, + ..Default::default() + }) + }) } markup::define! { @@ -137,7 +152,7 @@ markup::define! { } } } - NodePage<'a>(id: &'a str, node: &'a Node, udata: &'a NodeUserData, children: &'a [(Arc, NodeUserData)], parents: &'a [Arc], filter: &'a NodeFilterSort) { + NodePage<'a>(id: &'a str, node: &'a Node, udata: &'a NodeUserData, children: &'a [(Arc, NodeUserData)], parents: &'a [(Arc, NodeUserData)], filter: &'a NodeFilterSort) { @if !matches!(node.kind, NodeKind::Collection) { img.backdrop[src=uri!(r_item_backdrop(id, Some(2048))), loading="lazy"]; } @@ -148,7 +163,7 @@ markup::define! { } .title { h1 { @node.title } - ul.parents { @for node in *parents { li { + ul.parents { @for (node, _) in *parents { li { a.component[href=uri!(r_library_node(&node.slug))] { @node.title } }}} @if node.media.is_some() { a.play[href=&uri!(r_player(id, PlayerConfig::default()))] { "Watch now" }} diff --git a/server/src/routes/ui/search.rs b/server/src/routes/ui/search.rs index d020e2e..c5944ec 100644 --- a/server/src/routes/ui/search.rs +++ b/server/src/routes/ui/search.rs @@ -1,53 +1,68 @@ +/* + This file is part of jellything (https://codeberg.org/metamuffin/jellything) + which is licensed under the GNU Affero General Public License (version 3); see /COPYING. + Copyright (C) 2025 metamuffin +*/ use super::{ account::session::Session, error::MyResult, layout::{DynLayoutPage, LayoutPage}, node::{DatabaseNodeUserDataExt, NodeCard}, }; +use crate::routes::api::AcceptJson; +use anyhow::anyhow; use jellybase::database::Database; -use jellycommon::Visibility; -use rocket::{get, State}; +use jellycommon::{api::ApiSearchResponse, Visibility}; +use rocket::{get, serde::json::Json, Either, State}; use std::time::Instant; #[get("/search?&")] pub async fn r_search<'a>( session: Session, db: &State, + aj: AcceptJson, query: Option<&str>, page: Option, -) -> MyResult> { - let timing = Instant::now(); +) -> MyResult, Json>> { let results = if let Some(query) = query { + let timing = Instant::now(); let (count, ids) = db.search(query, 32, page.unwrap_or_default() * 32)?; let mut nodes = ids .into_iter() .map(|id| db.get_node_with_userdata(id, &session)) .collect::, anyhow::Error>>()?; nodes.retain(|(n, _)| n.visibility >= Visibility::Reduced); - Some((count, nodes)) + let search_dur = timing.elapsed(); + Some((count, nodes, search_dur)) } else { None }; - let search_dur = timing.elapsed(); let query = query.unwrap_or_default().to_string(); - Ok(LayoutPage { - title: "Search".to_string(), - class: Some("search"), - content: markup::new! { - h1 { "Search" } - form[action="", method="GET"] { - input[type="text", name="query", placeholder="Search Term", value=&query]; - input[type="submit", value="Search"]; - } - @if let Some((count, results)) = &results { - h2 { "Results" } - p.stats { @format!("Found {count} nodes in {search_dur:?}.") } - ul.children {@for (node, udata) in results.iter() { - li { @NodeCard { node, udata } } - }} - // TODO pagination - } - }, + Ok(if *aj { + let Some((count, results, _)) = results else { + Err(anyhow!("no query"))? + }; + Either::Right(Json(ApiSearchResponse { count, results })) + } else { + Either::Left(LayoutPage { + title: "Search".to_string(), + class: Some("search"), + content: markup::new! { + h1 { "Search" } + form[action="", method="GET"] { + input[type="text", name="query", placeholder="Search Term", value=&query]; + input[type="submit", value="Search"]; + } + @if let Some((count, results, search_dur)) = &results { + h2 { "Results" } + p.stats { @format!("Found {count} nodes in {search_dur:?}.") } + ul.children {@for (node, udata) in results.iter() { + li { @NodeCard { node, udata } } + }} + // TODO pagination + } + }, + }) }) } -- cgit v1.2.3-70-g09d2