diff options
Diffstat (limited to 'server/src/ui/sort.rs')
-rw-r--r-- | server/src/ui/sort.rs | 297 |
1 files changed, 297 insertions, 0 deletions
diff --git a/server/src/ui/sort.rs b/server/src/ui/sort.rs new file mode 100644 index 0000000..a241030 --- /dev/null +++ b/server/src/ui/sort.rs @@ -0,0 +1,297 @@ +/* + 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::ui::layout::trs; +use jellybase::locale::Language; +use jellycommon::{helpers::SortAnyway, user::NodeUserData, Node, NodeKind, Rating}; +use markup::RenderAttributeValue; +use rocket::{ + http::uri::fmt::{Query, UriDisplay}, + FromForm, FromFormField, UriDisplayQuery, +}; +use std::sync::Arc; + +#[derive(FromForm, UriDisplayQuery, Default, Clone)] +pub struct NodeFilterSort { + pub sort_by: Option<SortProperty>, + pub filter_kind: Option<Vec<FilterProperty>>, + pub sort_order: Option<SortOrder>, +} + +macro_rules! form_enum { + (enum $i:ident { $($vi:ident = $vk:literal),*, }) => { + #[derive(Debug, FromFormField, UriDisplayQuery, Clone, Copy, PartialEq, Eq)] + pub enum $i { $(#[field(value = $vk)] $vi),* } + impl $i { #[allow(unused)] const ALL: &'static [$i] = &[$($i::$vi),*]; } + }; +} + +form_enum!( + enum FilterProperty { + FederationLocal = "fed_local", + FederationRemote = "fed_remote", + Watched = "watched", + Unwatched = "unwatched", + WatchProgress = "watch_progress", + KindMovie = "kind_movie", + KindVideo = "kind_video", + KindShortFormVideo = "kind_short_form_video", + KindMusic = "kind_music", + KindCollection = "kind_collection", + KindChannel = "kind_channel", + KindShow = "kind_show", + KindSeries = "kind_series", + KindSeason = "kind_season", + KindEpisode = "kind_episode", + } +); + +form_enum!( + enum SortProperty { + ReleaseDate = "release_date", + Title = "title", + Index = "index", + Duration = "duration", + RatingRottenTomatoes = "rating_rt", + RatingMetacritic = "rating_mc", + RatingImdb = "rating_imdb", + RatingTmdb = "rating_tmdb", + RatingYoutubeViews = "rating_yt_views", + RatingYoutubeLikes = "rating_yt_likes", + RatingYoutubeFollowers = "rating_yt_followers", + RatingUser = "rating_user", + RatingLikesDivViews = "rating_loved", + } +); + +impl SortProperty { + const CATS: &'static [(&'static str, &'static [(SortProperty, &'static str)])] = { + use SortProperty::*; + &[ + ( + "filter_sort.sort.general", + &[(Title, "node.title"), (ReleaseDate, "node.release_date")], + ), + ("filter_sort.sort.media", &[(Duration, "media.runtime")]), + ( + "filter_sort.sort.rating", + &[ + (RatingImdb, "rating.imdb"), + (RatingTmdb, "rating.tmdb"), + (RatingMetacritic, "rating.metacritic"), + (RatingRottenTomatoes, "rating.rotten_tomatoes"), + (RatingYoutubeFollowers, "rating.youtube_followers"), + (RatingYoutubeLikes, "rating.youtube_likes"), + (RatingYoutubeViews, "rating.youtube_views"), + (RatingUser, "filter_sort.sort.rating.user"), + ( + RatingLikesDivViews, + "filter_sort.sort.rating.likes_div_views", + ), + ], + ), + ] + }; +} +impl FilterProperty { + const CATS: &'static [(&'static str, &'static [(FilterProperty, &'static str)])] = { + use FilterProperty::*; + &[ + ( + "filter_sort.filter.kind", + &[ + (KindMovie, "kind.movie"), + (KindVideo, "kind.video"), + (KindShortFormVideo, "kind.short_form_video"), + (KindMusic, "kind.music"), + (KindCollection, "kind.collection"), + (KindChannel, "kind.channel"), + (KindShow, "kind.show"), + (KindSeries, "kind.series"), + (KindSeason, "kind.season"), + (KindEpisode, "kind.episode"), + ], + ), + ( + "filter_sort.filter.federation", + &[ + (FederationLocal, "federation.local"), + (FederationRemote, "federation.remote"), + ], + ), + ( + "filter_sort.filter.watched", + &[ + (Watched, "watched.watched"), + (Unwatched, "watched.none"), + (WatchProgress, "watched.progress"), + ], + ), + ] + }; +} + +impl NodeFilterSort { + pub fn is_open(&self) -> bool { + self.filter_kind.is_some() || self.sort_by.is_some() + } +} + +#[rustfmt::skip] +#[derive(FromFormField, UriDisplayQuery, Clone, Copy, PartialEq, Eq)] +pub enum SortOrder { + #[field(value = "ascending")] Ascending, + #[field(value = "descending")] Descending, +} + +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(), + } +} + +markup::define! { + NodeFilterSortForm<'a>(f: &'a NodeFilterSort, lang: &'a Language) { + details.filtersort[open=f.is_open()] { + summary { "Filter and Sort" } + form[method="GET", action=""] { + fieldset.filter { + legend { "Filter" } + .categories { + @for (cname, cat) in FilterProperty::CATS { + .category { + h3 { @trs(lang, cname) } + @for (value, label) in *cat { + label { input[type="checkbox", name="filter_kind", value=value, checked=f.filter_kind.as_ref().map(|k|k.contains(value)).unwrap_or(true)]; @trs(lang, label) } br; + } + } + } + } + } + fieldset.sortby { + legend { "Sort" } + .categories { + @for (cname, cat) in SortProperty::CATS { + .category { + h3 { @trs(lang, cname) } + @for (value, label) in *cat { + label { input[type="radio", name="sort_by", value=value, checked=Some(value)==f.sort_by.as_ref()]; @trs(lang, label) } br; + } + } + } + } + } + fieldset.sortorder { + legend { "Sort Order" } + @use SortOrder::*; + @for (value, label) in [(Ascending, "filter_sort.order.asc"), (Descending, "filter_sort.order.desc")] { + label { input[type="radio", name="sort_order", value=value, checked=Some(value)==f.sort_order]; @trs(lang, label) } br; + } + } + input[type="submit", value="Apply"]; a[href="?"] { "Clear" } + } + } + } +} + +impl markup::Render for SortProperty { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) + } +} +impl markup::Render for SortOrder { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) + } +} +impl markup::Render for FilterProperty { + fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result { + writer.write_fmt(format_args!("{}", self as &dyn UriDisplay<Query>)) + } +} +impl RenderAttributeValue for SortOrder {} +impl RenderAttributeValue for FilterProperty {} +impl RenderAttributeValue for SortProperty {} |