aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-02-02 00:25:45 +0100
committermetamuffin <metamuffin@disroot.org>2025-02-02 00:25:45 +0100
commit1534bb6d8f88d83c1ce9c89d007af04dcc3291f1 (patch)
tree4df67a6ad396a97094afd7dd9e57775320075655
parent4993f189870a96a328bdda5838d1d184c1bbdb67 (diff)
downloadjellything-1534bb6d8f88d83c1ce9c89d007af04dcc3291f1.tar
jellything-1534bb6d8f88d83c1ce9c89d007af04dcc3291f1.tar.bz2
jellything-1534bb6d8f88d83c1ce9c89d007af04dcc3291f1.tar.zst
node visibility
-rw-r--r--common/src/lib.rs28
-rw-r--r--import/src/lib.rs200
-rw-r--r--server/src/routes/ui/assets.rs4
-rw-r--r--server/src/routes/ui/browser.rs3
-rw-r--r--server/src/routes/ui/home.rs6
-rw-r--r--server/src/routes/ui/node.rs27
-rw-r--r--server/src/routes/ui/sort.rs22
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 => {