diff options
Diffstat (limited to 'logic/src')
-rw-r--r-- | logic/src/filter_sort.rs | 99 | ||||
-rw-r--r-- | logic/src/home.rs | 7 | ||||
-rw-r--r-- | logic/src/items.rs | 42 | ||||
-rw-r--r-- | logic/src/lib.rs | 7 | ||||
-rw-r--r-- | logic/src/node.rs | 112 | ||||
-rw-r--r-- | logic/src/search.rs | 31 | ||||
-rw-r--r-- | logic/src/session.rs | 112 | ||||
-rw-r--r-- | logic/src/stats.rs | 48 |
8 files changed, 455 insertions, 3 deletions
diff --git a/logic/src/filter_sort.rs b/logic/src/filter_sort.rs new file mode 100644 index 0000000..3ccbc0d --- /dev/null +++ b/logic/src/filter_sort.rs @@ -0,0 +1,99 @@ +/* + 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 <metamuffin.org> +*/ +use jellycommon::{ + Node, NodeKind, Rating, + api::{FilterProperty, NodeFilterSort, SortOrder, SortProperty}, + helpers::SortAnyway, + user::NodeUserData, +}; +use std::sync::Arc; + +pub fn filter_and_sort_nodes( + f: &NodeFilterSort, + default_sort: (SortProperty, SortOrder), + nodes: &mut Vec<(Arc<Node>, NodeUserData)>, +) { + let sort_prop = f.sort_by.unwrap_or(default_sort.0); + nodes.retain(|(node, _udata)| { + let mut o = true; + if let Some(prop) = &f.filter_kind { + o = false; + for p in prop { + o |= match p { + // FilterProperty::FederationLocal => node.federated.is_none(), + // FilterProperty::FederationRemote => node.federated.is_some(), + FilterProperty::KindMovie => node.kind == NodeKind::Movie, + FilterProperty::KindVideo => node.kind == NodeKind::Video, + FilterProperty::KindShortFormVideo => node.kind == NodeKind::ShortFormVideo, + FilterProperty::KindMusic => node.kind == NodeKind::Music, + FilterProperty::KindCollection => node.kind == NodeKind::Collection, + FilterProperty::KindChannel => node.kind == NodeKind::Channel, + FilterProperty::KindShow => node.kind == NodeKind::Show, + FilterProperty::KindSeries => node.kind == NodeKind::Series, + FilterProperty::KindSeason => node.kind == NodeKind::Season, + FilterProperty::KindEpisode => node.kind == NodeKind::Episode, + // FilterProperty::Watched => udata.watched == WatchedState::Watched, + // FilterProperty::Unwatched => udata.watched == WatchedState::None, + // FilterProperty::WatchProgress => { + // matches!(udata.watched, WatchedState::Progress(_)) + // } + _ => false, // TODO + } + } + } + match sort_prop { + SortProperty::ReleaseDate => o &= node.release_date.is_some(), + SortProperty::Duration => o &= node.media.is_some(), + _ => (), + } + o + }); + match sort_prop { + SortProperty::Duration => { + nodes.sort_by_key(|(n, _)| (n.media.as_ref().unwrap().duration * 1000.) as i64) + } + SortProperty::ReleaseDate => { + nodes.sort_by_key(|(n, _)| n.release_date.expect("asserted above")) + } + SortProperty::Title => nodes.sort_by(|(a, _), (b, _)| a.title.cmp(&b.title)), + SortProperty::Index => nodes.sort_by(|(a, _), (b, _)| { + a.index + .unwrap_or(usize::MAX) + .cmp(&b.index.unwrap_or(usize::MAX)) + }), + SortProperty::RatingRottenTomatoes => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::RottenTomatoes).unwrap_or(&0.)) + }), + SortProperty::RatingMetacritic => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::Metacritic).unwrap_or(&0.)) + }), + SortProperty::RatingImdb => nodes + .sort_by_cached_key(|(n, _)| SortAnyway(*n.ratings.get(&Rating::Imdb).unwrap_or(&0.))), + SortProperty::RatingTmdb => nodes + .sort_by_cached_key(|(n, _)| SortAnyway(*n.ratings.get(&Rating::Tmdb).unwrap_or(&0.))), + SortProperty::RatingYoutubeViews => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::YoutubeViews).unwrap_or(&0.)) + }), + SortProperty::RatingYoutubeLikes => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::YoutubeLikes).unwrap_or(&0.)) + }), + SortProperty::RatingYoutubeFollowers => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway(*n.ratings.get(&Rating::YoutubeFollowers).unwrap_or(&0.)) + }), + SortProperty::RatingLikesDivViews => nodes.sort_by_cached_key(|(n, _)| { + SortAnyway( + *n.ratings.get(&Rating::YoutubeLikes).unwrap_or(&0.) + / (1. + *n.ratings.get(&Rating::YoutubeViews).unwrap_or(&0.)), + ) + }), + SortProperty::RatingUser => nodes.sort_by_cached_key(|(_, u)| u.rating), + } + + match f.sort_order.unwrap_or(default_sort.1) { + SortOrder::Ascending => (), + SortOrder::Descending => nodes.reverse(), + } +} diff --git a/logic/src/home.rs b/logic/src/home.rs index f03173e..b774a9f 100644 --- a/logic/src/home.rs +++ b/logic/src/home.rs @@ -4,6 +4,7 @@ Copyright (C) 2025 metamuffin <metamuffin.org> */ +use crate::{node::DatabaseNodeUserDataExt, session::Session}; use anyhow::{Context, Result}; use jellybase::database::Database; use jellycommon::{ @@ -13,14 +14,14 @@ use jellycommon::{ user::WatchedState, }; -pub fn home(db: Database) -> Result<ApiHomeResponse> { - let mut items = db.list_nodes_with_udata(&sess.user.name)?; +pub fn home(db: &Database, session: &Session) -> Result<ApiHomeResponse> { + let mut items = db.list_nodes_with_udata(&session.user.name)?; let mut toplevel = db .get_node_children(NodeID::from_slug("library")) .context("root node missing")? .into_iter() - .map(|n| db.get_node_with_userdata(n, &sess)) + .map(|n| db.get_node_with_userdata(n, &session)) .collect::<anyhow::Result<Vec<_>>>()?; toplevel.sort_by_key(|(n, _)| n.index.unwrap_or(usize::MAX)); diff --git a/logic/src/items.rs b/logic/src/items.rs new file mode 100644 index 0000000..67c45cb --- /dev/null +++ b/logic/src/items.rs @@ -0,0 +1,42 @@ +/* + 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 <metamuffin.org> +*/ + +use crate::{filter_sort::filter_and_sort_nodes, session::Session}; +use anyhow::Result; +use jellybase::database::Database; +use jellycommon::{ + Visibility, + api::{ApiItemsResponse, NodeFilterSort, SortOrder, SortProperty}, +}; + +pub fn all_items( + db: &Database, + session: &Session, + page: Option<usize>, + filter: NodeFilterSort, +) -> Result<ApiItemsResponse> { + let mut items = db.list_nodes_with_udata(session.user.name.as_str())?; + + items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); + + filter_and_sort_nodes( + &filter, + (SortProperty::Title, SortOrder::Ascending), + &mut items, + ); + + let page_size = 100; + let page = page.unwrap_or(0); + let offset = page * page_size; + let from = offset.min(items.len()); + let to = (offset + page_size).min(items.len()); + let max_page = items.len().div_ceil(page_size); + Ok(ApiItemsResponse { + count: items.len(), + pages: max_page, + items: items[from..to].to_vec(), + }) +} diff --git a/logic/src/lib.rs b/logic/src/lib.rs index cc988d7..a47afc3 100644 --- a/logic/src/lib.rs +++ b/logic/src/lib.rs @@ -3,5 +3,12 @@ which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin <metamuffin.org> */ +#![feature(duration_constructors)] +pub mod filter_sort; pub mod home; +pub mod node; +pub mod search; +pub mod session; +pub mod stats; +pub mod items; diff --git a/logic/src/node.rs b/logic/src/node.rs new file mode 100644 index 0000000..8a53bec --- /dev/null +++ b/logic/src/node.rs @@ -0,0 +1,112 @@ +/* + 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 <metamuffin.org> +*/ +use crate::{filter_sort::filter_and_sort_nodes, session::Session}; +use anyhow::{Result, anyhow}; +use jellybase::database::Database; +use jellycommon::{ + Node, NodeID, NodeKind, Visibility, + api::{ApiNodeResponse, NodeFilterSort, SortOrder, SortProperty}, + user::NodeUserData, +}; +use std::{cmp::Reverse, collections::BTreeMap, sync::Arc}; + +pub fn get_node( + db: &Database, + id: NodeID, + session: &Session, + children: bool, + parents: bool, + filter: NodeFilterSort, +) -> Result<ApiNodeResponse> { + let (node, udata) = db.get_node_with_userdata(id, &session)?; + + let mut children = if children { + db.get_node_children(id)? + .into_iter() + .map(|c| db.get_node_with_userdata(c, &session)) + .collect::<anyhow::Result<Vec<_>>>()? + } else { + Vec::new() + }; + + let mut parents = if parents { + node.parents + .iter() + .map(|pid| db.get_node_with_userdata(*pid, &session)) + .collect::<anyhow::Result<Vec<_>>>()? + } else { + Vec::new() + }; + + let mut similar = get_similar_media(&node, db, &session)?; + + similar.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, + match node.kind { + NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending), + NodeKind::Season | NodeKind::Show => (SortProperty::Index, SortOrder::Ascending), + _ => (SortProperty::Title, SortOrder::Ascending), + }, + &mut children, + ); + + Ok(ApiNodeResponse { + children, + parents, + node, + userdata: udata, + }) +} + +pub fn get_similar_media( + node: &Node, + db: &Database, + session: &Session, +) -> Result<Vec<(Arc<Node>, NodeUserData)>> { + let this_id = NodeID::from_slug(&node.slug); + let mut ranking = BTreeMap::<NodeID, usize>::new(); + for tag in &node.tags { + let nodes = db.get_tag_nodes(tag)?; + let weight = 1_000_000 / nodes.len(); + for n in nodes { + if n != this_id { + *ranking.entry(n).or_default() += weight; + } + } + } + let mut ranking = ranking.into_iter().collect::<Vec<_>>(); + ranking.sort_by_key(|(_, k)| Reverse(*k)); + ranking + .into_iter() + .take(32) + .map(|(pid, _)| db.get_node_with_userdata(pid, session)) + .collect::<anyhow::Result<Vec<_>>>() +} + +pub trait DatabaseNodeUserDataExt { + fn get_node_with_userdata( + &self, + id: NodeID, + session: &Session, + ) -> Result<(Arc<Node>, NodeUserData)>; +} +impl DatabaseNodeUserDataExt for Database { + fn get_node_with_userdata( + &self, + id: NodeID, + session: &Session, + ) -> Result<(Arc<Node>, NodeUserData)> { + Ok(( + self.get_node(id)?.ok_or(anyhow!("node does not exist"))?, + self.get_node_udata(id, &session.user.name)? + .unwrap_or_default(), + )) + } +} diff --git a/logic/src/search.rs b/logic/src/search.rs new file mode 100644 index 0000000..8e41e27 --- /dev/null +++ b/logic/src/search.rs @@ -0,0 +1,31 @@ +/* + 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 <metamuffin.org> +*/ +use crate::{node::DatabaseNodeUserDataExt, session::Session}; +use anyhow::Result; +use jellybase::database::Database; +use jellycommon::{Visibility, api::ApiSearchResponse}; +use std::time::Instant; + +pub fn search( + db: &Database, + session: &Session, + query: &str, + page: Option<usize>, +) -> Result<ApiSearchResponse> { + let timing = Instant::now(); + let (count, ids) = db.search(query, 32, page.unwrap_or_default() * 32)?; + let mut results = ids + .into_iter() + .map(|id| db.get_node_with_userdata(id, &session)) + .collect::<Result<Vec<_>, anyhow::Error>>()?; + results.retain(|(n, _)| n.visibility >= Visibility::Reduced); + let duration = timing.elapsed(); + Ok(ApiSearchResponse { + count, + results, + duration, + }) +} diff --git a/logic/src/session.rs b/logic/src/session.rs new file mode 100644 index 0000000..bc7f137 --- /dev/null +++ b/logic/src/session.rs @@ -0,0 +1,112 @@ +/* + 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 <metamuffin.org> +*/ +use aes_gcm_siv::{ + KeyInit, + aead::{Aead, generic_array::GenericArray}, +}; +use anyhow::anyhow; +use base64::Engine; +use jellybase::SECRETS; +use jellycommon::{ + chrono::{DateTime, Utc}, + user::{PermissionSet, User}, +}; +use log::warn; +use serde::{Deserialize, Serialize}; +use std::{sync::LazyLock, time::Duration}; + +pub struct Session { + pub user: User, +} + +pub struct AdminSession(pub Session); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionData { + username: String, + expire: DateTime<Utc>, + permissions: PermissionSet, +} + +static SESSION_KEY: LazyLock<[u8; 32]> = LazyLock::new(|| { + if let Some(sk) = &SECRETS.session_key { + let r = base64::engine::general_purpose::STANDARD + .decode(sk) + .expect("key invalid; should be valid base64"); + r.try_into() + .expect("key has the wrong length; should be 32 bytes") + } else { + warn!("session_key not configured; generating a random one."); + [(); 32].map(|_| rand::random()) + } +}); + +pub fn create(username: String, permissions: PermissionSet, expire: Duration) -> String { + let session_data = SessionData { + expire: Utc::now() + expire, + username: username.to_owned(), + permissions, + }; + let mut plaintext = + bincode::serde::encode_to_vec(&session_data, bincode::config::standard()).unwrap(); + + while plaintext.len() % 16 == 0 { + plaintext.push(0); + } + + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let nonce = [(); 12].map(|_| rand::random()); + let mut ciphertext = cipher + .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) + .unwrap(); + ciphertext.extend(nonce); + + base64::engine::general_purpose::URL_SAFE.encode(&ciphertext) +} + +pub fn validate(token: &str) -> anyhow::Result<String> { + let ciphertext = base64::engine::general_purpose::URL_SAFE.decode(token)?; + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let (ciphertext, nonce) = ciphertext.split_at(ciphertext.len() - 12); + let plaintext = cipher + .decrypt(nonce.into(), ciphertext) + .map_err(|e| anyhow!("decryption failed: {e:?}"))?; + + let (session_data, _): (SessionData, _) = + bincode::serde::decode_from_slice(&plaintext, bincode::config::standard())?; + + if session_data.expire < Utc::now() { + Err(anyhow!("session expired"))? + } + + Ok(session_data.username) +} + +#[test] +fn test() { + jellybase::use_test_config(); + let tok = create( + "blub".to_string(), + jellycommon::user::PermissionSet::default(), + Duration::from_days(1), + ); + validate(&tok).unwrap(); +} + +#[test] +fn test_crypto() { + jellybase::use_test_config(); + let nonce = [(); 12].map(|_| rand::random()); + let cipher = aes_gcm_siv::Aes256GcmSiv::new_from_slice(&*SESSION_KEY).unwrap(); + let plaintext = b"testing stuff---"; + let ciphertext = cipher + .encrypt(&GenericArray::from(nonce), plaintext.as_slice()) + .unwrap(); + let plaintext2 = cipher + .decrypt((&nonce).into(), ciphertext.as_slice()) + .unwrap(); + assert_eq!(plaintext, plaintext2.as_slice()); +} diff --git a/logic/src/stats.rs b/logic/src/stats.rs new file mode 100644 index 0000000..2569180 --- /dev/null +++ b/logic/src/stats.rs @@ -0,0 +1,48 @@ +/* + 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 <metamuffin.org> +*/ + +use crate::session::Session; +use anyhow::Result; +use jellybase::database::Database; +use jellycommon::{ + Node, NodeKind, Visibility, + api::{ApiStatsResponse, StatsBin}, +}; +use std::collections::BTreeMap; + +pub fn stats(db: &Database, session: &Session) -> Result<ApiStatsResponse> { + let mut items = db.list_nodes_with_udata(session.user.name.as_str())?; + items.retain(|(n, _)| n.visibility >= Visibility::Reduced); + + trait BinExt { + fn update(&mut self, node: &Node); + } + impl BinExt for StatsBin { + fn update(&mut self, node: &Node) { + self.count += 1; + self.size += node.storage_size; + if node.storage_size > self.max_size.0 { + self.max_size = (node.storage_size, node.slug.clone()) + } + if let Some(m) = &node.media { + self.runtime += m.duration; + if m.duration > self.max_runtime.0 { + self.max_runtime = (m.duration, node.slug.clone()) + } + } + } + + } + + let mut total = StatsBin::default(); + let mut kinds = BTreeMap::<NodeKind, StatsBin>::new(); + for (i, _) in items { + total.update(&i); + kinds.entry(i.kind).or_default().update(&i); + } + + Ok(ApiStatsResponse { kinds, total }) +} |