aboutsummaryrefslogtreecommitdiff
path: root/logic
diff options
context:
space:
mode:
Diffstat (limited to 'logic')
-rw-r--r--logic/Cargo.toml8
-rw-r--r--logic/src/filter_sort.rs99
-rw-r--r--logic/src/home.rs7
-rw-r--r--logic/src/items.rs42
-rw-r--r--logic/src/lib.rs7
-rw-r--r--logic/src/node.rs112
-rw-r--r--logic/src/search.rs31
-rw-r--r--logic/src/session.rs112
-rw-r--r--logic/src/stats.rs48
9 files changed, 462 insertions, 4 deletions
diff --git a/logic/Cargo.toml b/logic/Cargo.toml
index 4107c14..bd8a28d 100644
--- a/logic/Cargo.toml
+++ b/logic/Cargo.toml
@@ -1,5 +1,5 @@
[package]
-name = "logic"
+name = "jellylogic"
version = "0.1.0"
edition = "2024"
@@ -8,3 +8,9 @@ jellybase = { path = "../base" }
jellycommon = { path = "../common" }
log = "0.4.27"
anyhow = "1.0.98"
+base64 = "0.22.1"
+argon2 = "0.5.3"
+aes-gcm-siv = "0.11.1"
+serde = { version = "1.0.217", features = ["derive", "rc"] }
+bincode = { version = "2.0.0-rc.3", features = ["serde", "derive"] }
+rand = "0.9.0"
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 })
+}