diff options
author | metamuffin <metamuffin@disroot.org> | 2025-02-02 00:25:45 +0100 |
---|---|---|
committer | metamuffin <metamuffin@disroot.org> | 2025-02-02 00:25:45 +0100 |
commit | 1534bb6d8f88d83c1ce9c89d007af04dcc3291f1 (patch) | |
tree | 4df67a6ad396a97094afd7dd9e57775320075655 | |
parent | 4993f189870a96a328bdda5838d1d184c1bbdb67 (diff) | |
download | jellything-1534bb6d8f88d83c1ce9c89d007af04dcc3291f1.tar jellything-1534bb6d8f88d83c1ce9c89d007af04dcc3291f1.tar.bz2 jellything-1534bb6d8f88d83c1ce9c89d007af04dcc3291f1.tar.zst |
node visibility
-rw-r--r-- | common/src/lib.rs | 28 | ||||
-rw-r--r-- | import/src/lib.rs | 200 | ||||
-rw-r--r-- | server/src/routes/ui/assets.rs | 4 | ||||
-rw-r--r-- | server/src/routes/ui/browser.rs | 3 | ||||
-rw-r--r-- | server/src/routes/ui/home.rs | 6 | ||||
-rw-r--r-- | server/src/routes/ui/node.rs | 27 | ||||
-rw-r--r-- | server/src/routes/ui/sort.rs | 22 |
7 files changed, 157 insertions, 133 deletions
diff --git a/common/src/lib.rs b/common/src/lib.rs index 9d22eec..46d543d 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -27,10 +27,11 @@ pub struct NodeID(pub [u8; 32]); #[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)] pub struct Node { + #[serde(default)] pub slug: String, #[serde(default)] pub parents: BTreeSet<NodeID>, - pub kind: Option<NodeKind>, + pub kind: NodeKind, pub poster: Option<Asset>, pub backdrop: Option<Asset>, pub title: Option<String>, @@ -47,6 +48,8 @@ pub struct Node { pub people: BTreeMap<PeopleGroup, Vec<Appearance>>, #[serde(default)] pub external_ids: BTreeMap<String, String>, + #[serde(default)] + pub visibility: Visibility, } #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Encode, Decode)] @@ -96,10 +99,33 @@ pub enum PeopleGroup { CreatedBy, } +#[derive( + Debug, + Clone, + Copy, + Deserialize, + Serialize, + PartialEq, + Eq, + PartialOrd, + Ord, + Encode, + Decode, + Default, +)] +#[serde(rename_all = "snake_case")] +pub enum Visibility { + Hidden, + Reduced, + #[default] + Visible, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default, Encode, Decode)] #[serde(rename_all = "snake_case")] pub enum NodeKind { #[default] + Unknown, Movie, Video, Music, diff --git a/import/src/lib.rs b/import/src/lib.rs index 4be2151..c70d357 100644 --- a/import/src/lib.rs +++ b/import/src/lib.rs @@ -14,14 +14,15 @@ use jellybase::{ database::Database, CONF, SECRETS, }; +use jellyclient::Visibility; +use log::warn; use matroska::matroska_metadata; -use rayon::iter::{ParallelDrainRange, ParallelIterator}; +use rayon::iter::{ParallelBridge, ParallelIterator}; use std::{ collections::HashMap, fs::{read_to_string, File}, io::BufReader, - mem::swap, - path::{Path, PathBuf}, + path::Path, sync::LazyLock, time::UNIX_EPOCH, }; @@ -68,55 +69,71 @@ pub async fn import_wrap(db: Database, incremental: bool) -> Result<()> { } fn import(db: &Database, incremental: bool) -> Result<()> { - let mut queue_prev = vec![(CONF.media_path.clone(), vec![])]; - let mut queue_next; - let apis = Apis { trakt: SECRETS.api.trakt.as_ref().map(|key| Trakt::new(key)), tmdb: SECRETS.api.tmdb.as_ref().map(|key| Tmdb::new(key)), }; drop((apis.tmdb, apis.trakt)); - while !queue_prev.is_empty() { - queue_next = queue_prev - .par_drain(..) - .flat_map_iter(move |(path, slugs)| { - match import_iter_inner(&path, db, slugs, incremental) { - Ok(ch) => ch, - Err(e) => { - IMPORT_ERRORS.blocking_write().push(format!("{e:#}")); - Vec::new() - } - } - }) - .collect::<Vec<_>>(); - swap(&mut queue_next, &mut queue_prev); - } + import_traverse( + &CONF.media_path, + db, + incremental, + NodeID::MIN, + "", + Visibility::Visible, + )?; + Ok(()) } -fn import_iter_inner( +fn import_traverse( path: &Path, db: &Database, - mut slugs: Vec<String>, incremental: bool, -) -> Result<Vec<(PathBuf, Vec<String>)>> { + parent: NodeID, + parent_slug_fragment: &str, + mut visibility: Visibility, +) -> Result<()> { if path.is_dir() { - let mut o = Vec::new(); - let child_slug = if path == CONF.media_path { + let slug_fragment = if path == CONF.media_path { "library".to_string() } else { - path.file_name() - .ok_or(anyhow!("parent no filename"))? - .to_string_lossy() - .to_string() + path.file_name().unwrap().to_string_lossy().to_string() }; - slugs.push(child_slug); - for e in path.read_dir()? { - let path = e?.path(); - o.push((path, slugs.clone())); + let slug = if parent_slug_fragment.is_empty() { + slug_fragment.clone() + } else { + format!("{parent_slug_fragment}-{slug_fragment}") + }; + let id = NodeID::from_slug(&slug); + + if let Ok(content) = read_to_string(path.join("flags")) { + for flag in content.lines() { + match flag.trim() { + "hidden" => visibility = visibility.min(Visibility::Hidden), + "reduced" => visibility = visibility.min(Visibility::Reduced), + _ => warn!("unknown flag {flag:?}"), + } + } } - return Ok(o); + + db.update_node_init(id, |n| { + n.parents.insert(parent); + n.slug = slug; + n.visibility = visibility; + Ok(()) + })?; + + path.read_dir()?.par_bridge().try_for_each(|e| { + let path = e?.path(); + if let Err(e) = import_traverse(&path, db, incremental, id, &slug_fragment, visibility) + { + IMPORT_ERRORS.blocking_write().push(format!("{e:#}")); + } + Ok::<_, anyhow::Error>(()) + })?; + return Ok(()); } if path.is_file() { let meta = path.metadata()?; @@ -125,72 +142,43 @@ fn import_iter_inner( if incremental { if let Some(last_mtime) = db.get_import_file_mtime(&path)? { if last_mtime >= mtime { - return Ok(Vec::new()); + return Ok(()); } } } - let (slug, parent_slug) = if slugs.len() > 2 { - ( - format!("{}-{}", slugs[slugs.len() - 2], slugs[slugs.len() - 1]), - Some(format!( - "{}-{}", - slugs[slugs.len() - 3], - slugs[slugs.len() - 2] - )), - ) - } else if slugs.len() > 1 { - ( - format!("{}-{}", slugs[slugs.len() - 2], slugs[slugs.len() - 1]), - Some(slugs[slugs.len() - 2].to_string()), - ) - } else { - (slugs[0].to_string(), None) - }; - - import_file(&db, &path, slug, parent_slug).context(anyhow!("{path:?}"))?; + import_file(&db, &path, parent, visibility).context(anyhow!("{path:?}"))?; db.set_import_file_mtime(&path, mtime)?; } - return Ok(Vec::new()); + return Ok(()); } -fn import_file( - db: &Database, - path: &Path, - slug: String, - parent_slug: Option<String>, -) -> Result<()> { - let id = NodeID::from_slug(&slug); - let parent_id = parent_slug.map(|e| NodeID::from_slug(&e)); - +fn import_file(db: &Database, path: &Path, parent: NodeID, visibility: Visibility) -> Result<()> { let filename = path.file_name().unwrap().to_string_lossy(); match filename.as_ref() { "poster.jpeg" | "poster.webp" | "poster.png" => { - db.update_node_init(id, |node| { - node.slug = slug.to_string(); + db.update_node_init(parent, |node| { node.poster = Some(AssetInner::Media(path.to_owned()).ser()); Ok(()) })?; } "backdrop.jpeg" | "backdrop.webp" | "backdrop.png" => { - db.update_node_init(id, |node| { - node.slug = slug.to_string(); + db.update_node_init(parent, |node| { node.backdrop = Some(AssetInner::Media(path.to_owned()).ser()); Ok(()) })?; } "node.yaml" => { - let raw = format!("slug: {slug}\n{}", read_to_string(path)?); - let data = serde_yaml::from_str::<Node>(&raw)?; - db.update_node_init(id, |node| { - node.parents.extend(parent_id); - node.slug = slug.to_string(); + let data = serde_yaml::from_str::<Node>(&read_to_string(path)?)?; + db.update_node_init(parent, |node| { fn merge_option<T>(a: &mut Option<T>, b: Option<T>) { if b.is_some() { *a = b; } } - merge_option(&mut node.kind, data.kind); + if data.kind != NodeKind::Unknown { + node.kind = data.kind; + } merge_option(&mut node.title, data.title); merge_option(&mut node.tagline, data.tagline); merge_option(&mut node.description, data.description); @@ -203,10 +191,8 @@ fn import_file( } "channel.info.json" => { let data = serde_json::from_reader::<_, YVideo>(BufReader::new(File::open(path)?))?; - db.update_node_init(id, |node| { - node.parents.extend(parent_id); - node.kind = Some(NodeKind::Channel); - node.slug = slug.to_string(); + db.update_node_init(parent, |node| { + node.kind = NodeKind::Channel; let mut title = data.title.as_str(); title = title.strip_suffix(" - Videos").unwrap_or(title); title = title.strip_suffix(" - Topic").unwrap_or(title); @@ -229,13 +215,18 @@ fn import_file( Ok(()) })?; } - _ => import_media_file(db, path, id).context("media file")?, + _ => import_media_file(db, path, parent, visibility).context("media file")?, } Ok(()) } -fn import_media_file(db: &Database, path: &Path, parent: NodeID) -> Result<()> { +fn import_media_file( + db: &Database, + path: &Path, + parent: NodeID, + visibility: Visibility, +) -> Result<()> { let Some(m) = (*matroska_metadata(path)?).to_owned() else { return Ok(()); }; @@ -269,6 +260,7 @@ fn import_media_file(db: &Database, path: &Path, parent: NodeID) -> Result<()> { db.update_node_init(NodeID::from_slug(&slug), |node| { node.slug = slug; node.title = info.title; + node.visibility = visibility; node.poster = m.cover.clone(); node.description = tags.remove("DESCRIPTION"); node.tagline = tags.remove("COMMENT"); @@ -312,20 +304,18 @@ fn import_media_file(db: &Database, path: &Path, parent: NodeID) -> Result<()> { .collect::<Vec<_>>(); if let Some(infojson) = m.infojson { - node.kind = Some( - if !tracks - .iter() - .any(|t| matches!(t.kind, SourceTrackKind::Video { .. })) - { - NodeKind::Music - } else if infojson.duration.unwrap_or(0.) < 600. - && infojson.aspect_ratio.unwrap_or(2.) < 1. - { - NodeKind::ShortFormVideo - } else { - NodeKind::Video - }, - ); + node.kind = if !tracks + .iter() + .any(|t| matches!(t.kind, SourceTrackKind::Video { .. })) + { + NodeKind::Music + } else if infojson.duration.unwrap_or(0.) < 600. + && infojson.aspect_ratio.unwrap_or(2.) < 1. + { + NodeKind::ShortFormVideo + } else { + NodeKind::Video + }; node.title = Some(infojson.title); if let Some(desc) = infojson.description { node.description = Some(desc) @@ -336,23 +326,23 @@ fn import_media_file(db: &Database, path: &Path, parent: NodeID) -> Result<()> { Some(infojson::parse_upload_date(date).context("parsing upload date")?); } match infojson.extractor.as_str() { - "youtube" => drop( + "youtube" => { node.external_ids - .insert("youtube:video".to_string(), infojson.id), - ), + .insert("youtube:video".to_string(), infojson.id); + node.ratings.insert( + Rating::YoutubeViews, + infojson.view_count.unwrap_or_default() as f64, + ); + if let Some(lc) = infojson.like_count { + node.ratings.insert(Rating::YoutubeLikes, lc as f64); + } + } "Bandcamp" => drop( node.external_ids .insert("bandcamp".to_string(), infojson.id), ), _ => (), } - node.ratings.insert( - Rating::YoutubeViews, - infojson.view_count.unwrap_or_default() as f64, - ); - if let Some(lc) = infojson.like_count { - node.ratings.insert(Rating::YoutubeLikes, lc as f64); - } } node.media = Some(MediaInfo { chapters: m diff --git a/server/src/routes/ui/assets.rs b/server/src/routes/ui/assets.rs index ac808b1..a01c8bc 100644 --- a/server/src/routes/ui/assets.rs +++ b/server/src/routes/ui/assets.rs @@ -80,7 +80,7 @@ pub async fn r_item_poster( } }; let asset = asset.unwrap_or_else(|| { - AssetInner::Assets(format!("fallback-{:?}.avif", node.kind.unwrap_or_default()).into()) + AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()) .ser() }); Ok(Redirect::temporary(rocket::uri!(r_asset(asset.0, width)))) @@ -105,7 +105,7 @@ pub async fn r_item_backdrop( } }; let asset = asset.unwrap_or_else(|| { - AssetInner::Assets(format!("fallback-{:?}.avif", node.kind.unwrap_or_default()).into()) + AssetInner::Assets(format!("fallback-{:?}.avif", node.kind).into()) .ser() }); Ok(Redirect::temporary(rocket::uri!(r_asset(asset.0, width)))) diff --git a/server/src/routes/ui/browser.rs b/server/src/routes/ui/browser.rs index a15dc27..7affbac 100644 --- a/server/src/routes/ui/browser.rs +++ b/server/src/routes/ui/browser.rs @@ -11,6 +11,7 @@ use super::{ sort::{filter_and_sort_nodes, NodeFilterSort, NodeFilterSortForm, SortOrder, SortProperty}, }; use crate::{database::Database, uri}; +use jellycommon::Visibility; use rocket::{get, State}; /// This function is a stub and only useful for use in the uri! macro. @@ -26,6 +27,8 @@ pub fn r_all_items_filter( ) -> Result<DynLayoutPage<'_>, MyError> { let mut items = db.list_nodes_with_udata(sess.user.name.as_str())?; + items.retain(|(n, _)| matches!(n.visibility, Visibility::Visible)); + filter_and_sort_nodes( &filter, (SortProperty::Title, SortOrder::Ascending), diff --git a/server/src/routes/ui/home.rs b/server/src/routes/ui/home.rs index 321d184..1a4f20a 100644 --- a/server/src/routes/ui/home.rs +++ b/server/src/routes/ui/home.rs @@ -15,7 +15,7 @@ use crate::{ use anyhow::Context; use chrono::{Datelike, Utc}; use jellybase::CONF; -use jellycommon::{user::WatchedState, NodeID, NodeKind, Rating}; +use jellycommon::{user::WatchedState, NodeID, NodeKind, Rating, Visibility}; use rocket::{get, State}; use tokio::fs::read_to_string; @@ -45,8 +45,8 @@ pub fn r_home(sess: Session, db: &State<Database>) -> MyResult<DynLayoutPage> { items.retain(|(n, _)| { matches!( n.kind, - Some(NodeKind::Video | NodeKind::Movie | NodeKind::Episode | NodeKind::Music) - ) + NodeKind::Video | NodeKind::Movie | NodeKind::Episode | NodeKind::Music + ) && matches!(n.visibility, Visibility::Visible) }); let random = (0..16) diff --git a/server/src/routes/ui/node.rs b/server/src/routes/ui/node.rs index 2df78b4..9882e9f 100644 --- a/server/src/routes/ui/node.rs +++ b/server/src/routes/ui/node.rs @@ -32,7 +32,7 @@ use anyhow::{anyhow, Result}; use chrono::DateTime; use jellycommon::{ user::{NodeUserData, WatchedState}, - Chapter, MediaInfo, Node, NodeID, NodeKind, PeopleGroup, Rating, SourceTrackKind, + Chapter, MediaInfo, Node, NodeID, NodeKind, PeopleGroup, Rating, SourceTrackKind, Visibility, }; use rocket::{get, serde::json::Json, Either, State}; use std::sync::Arc; @@ -72,7 +72,7 @@ pub async fn r_library_node_filter<'a>( filter_and_sort_nodes( &filter, - match node.kind.unwrap_or(NodeKind::Collection) { + match node.kind { NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending), _ => (SortProperty::Title, SortOrder::Ascending), }, @@ -91,7 +91,7 @@ pub async fn r_library_node_filter<'a>( markup::define! { NodeCard<'a>(node: &'a Node, udata: &'a NodeUserData) { - @let cls = format!("node card poster {}", aspect_class(node.kind.unwrap_or_default())); + @let cls = format!("node card poster {}", aspect_class(node.kind)); div[class=cls] { .poster { a[href=uri!(r_library_node(&node.slug))] { @@ -117,12 +117,12 @@ markup::define! { } } NodePage<'a>(id: &'a str, node: &'a Node, udata: &'a NodeUserData, children: &'a [(Arc<Node>, NodeUserData)], parents: &'a [Arc<Node>], filter: &'a NodeFilterSort) { - @if !matches!(node.kind.unwrap_or_default(), NodeKind::Collection) { + @if !matches!(node.kind, NodeKind::Collection) { img.backdrop[src=uri!(r_item_backdrop(id, Some(2048))), loading="lazy"]; } .page.node { - @if !matches!(node.kind.unwrap_or_default(), NodeKind::Collection) { - @let cls = format!("bigposter {}", aspect_class(node.kind.unwrap_or_default())); + @if !matches!(node.kind, NodeKind::Collection) { + @let cls = format!("bigposter {}", aspect_class(node.kind)); div[class=cls] { img[src=uri!(r_item_poster(id, Some(2048))), loading="lazy"]; } } .title { @@ -131,7 +131,7 @@ markup::define! { 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" }} - @if !matches!(node.kind.unwrap_or_default(), NodeKind::Collection | NodeKind::Channel) { + @if !matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { @if matches!(udata.watched, WatchedState::None | WatchedState::Pending | WatchedState::Progress(_)) { form.mark_watched[method="POST", action=uri!(r_node_userdata_watched(id, UrlWatchedState::Watched))] { input[type="submit", value="Mark Watched"]; @@ -214,10 +214,10 @@ markup::define! { } } } - @if matches!(node.kind.unwrap_or_default(), NodeKind::Collection | NodeKind::Channel) { + @if matches!(node.kind, NodeKind::Collection | NodeKind::Channel) { @NodeFilterSortForm { f: filter } } - @match node.kind.unwrap_or_default() { + @match node.kind { NodeKind::Show | NodeKind::Series | NodeKind::Season => { ol { @for (c, _) in children.iter() { li { a[href=uri!(r_library_node(&c.slug))] { @c.title } } @@ -225,7 +225,9 @@ markup::define! { } NodeKind::Collection | NodeKind::Channel | _ => { ul.children {@for (node, udata) in children.iter() { - li { @NodeCard { node, udata } } + @if node.visibility != Visibility::Hidden { + li { @NodeCard { node, udata } } + } }} } } @@ -245,6 +247,11 @@ markup::define! { @DateTime::from_timestamp_millis(*d).unwrap().date_naive().to_string() }} } + @match node.visibility { + Visibility::Visible => {} + Visibility::Reduced => {p{"Reduced visibility"}} + Visibility::Hidden => {p{"Hidden"}} + } // TODO // @if !node.children.is_empty() { // p { @format!("{} items", node.children.len()) } diff --git a/server/src/routes/ui/sort.rs b/server/src/routes/ui/sort.rs index 6f1eade..3831431 100644 --- a/server/src/routes/ui/sort.rs +++ b/server/src/routes/ui/sort.rs @@ -150,18 +150,16 @@ pub fn filter_and_sort_nodes( o &= !match p { FilterProperty::FederationLocal => node.federated.is_none(), FilterProperty::FederationRemote => node.federated.is_some(), - FilterProperty::KindMovie => node.kind == Some(NodeKind::Movie), - FilterProperty::KindVideo => node.kind == Some(NodeKind::Video), - FilterProperty::KindShortFormVideo => { - node.kind == Some(NodeKind::ShortFormVideo) - } - FilterProperty::KindMusic => node.kind == Some(NodeKind::Music), - FilterProperty::KindCollection => node.kind == Some(NodeKind::Collection), - FilterProperty::KindChannel => node.kind == Some(NodeKind::Channel), - FilterProperty::KindShow => node.kind == Some(NodeKind::Show), - FilterProperty::KindSeries => node.kind == Some(NodeKind::Series), - FilterProperty::KindSeason => node.kind == Some(NodeKind::Season), - FilterProperty::KindEpisode => node.kind == Some(NodeKind::Episode), + 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 => { |