aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--common/src/routes.rs2
-rw-r--r--locale/en.ini1
-rw-r--r--server/src/routes/mod.rs3
-rw-r--r--server/src/routes/stream.rs47
-rw-r--r--ui/src/components/node_page.rs55
5 files changed, 83 insertions, 25 deletions
diff --git a/common/src/routes.rs b/common/src/routes.rs
index 2880eeb..d8aa9b8 100644
--- a/common/src/routes.rs
+++ b/common/src/routes.rs
@@ -31,7 +31,7 @@ pub fn u_node_slug_person_asset(node: &str, group: &str, index: usize, size: usi
format!("/n/{node}/person/{index}/asset?group={group}&size={size}")
}
pub fn u_node_slug_thumbnail(node: &str, time: f64, size: usize) -> String {
- format!("/n/{node}/thumbnail?t={time}&size={size}")
+ format!("/n/{node}/thumbnail?t={time:.0}&size={size}")
}
pub fn u_node_slug_update_rating(node: &str) -> String {
format!("/n/{node}/update_rating")
diff --git a/locale/en.ini b/locale/en.ini
index 1d0829a..b1b295c 100644
--- a/locale/en.ini
+++ b/locale/en.ini
@@ -49,6 +49,7 @@ node.update_rating=Update Rating
node.credited=Featured
node.similar=Similar Media
+tag.chpt=Chapters
tag.cred.kind.arra=Arranger
tag.cred.kind.art1=Art
tag.cred.kind.came=Camera
diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs
index e93aac2..5b1c799 100644
--- a/server/src/routes/mod.rs
+++ b/server/src/routes/mod.rs
@@ -47,7 +47,7 @@ use self::{
};
use crate::{
State,
- routes::{admin::r_admin_debug, search::r_search},
+ routes::{admin::r_admin_debug, search::r_search, stream::r_thumbnail},
};
use rocket::{
Build, Config, Rocket, catchers, fairing::AdHoc, fs::FileServer, http::Header, routes,
@@ -120,6 +120,7 @@ pub(super) fn build_rocket(state: Arc<State>) -> Rocket<Build> {
r_image_fallback_person,
r_image,
r_index,
+ r_thumbnail,
r_items,
r_node,
r_player,
diff --git a/server/src/routes/stream.rs b/server/src/routes/stream.rs
index 45b86a9..0baaca6 100644
--- a/server/src/routes/stream.rs
+++ b/server/src/routes/stream.rs
@@ -3,11 +3,15 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2026 metamuffin <metamuffin.org>
*/
-use crate::{request_info::RequestInfo, routes::error::MyError};
+use crate::{
+ request_info::RequestInfo,
+ routes::error::{MyError, MyResult},
+};
use anyhow::{Result, anyhow};
-use jellycommon::{jellyobject::Path, stream::StreamSpec, *};
+use jellycommon::{jellyobject::Path, routes::u_image, stream::StreamSpec, *};
use jellydb::{Filter, Query};
use jellystream::SMediaInfo;
+use jellytranscoder::thumbnail::create_thumbnail;
use log::{info, warn};
use rocket::{
Either, Request, Response, get, head,
@@ -55,6 +59,7 @@ pub async fn r_stream(
range: Option<RequestRange>,
path: StringPath<'_>,
) -> Result<StreamResponse, MyError> {
+ ri.require_user()?;
let spec = StreamSpec::from_path(&path.0).map_err(|x| anyhow!("media path invalid: {x}"))?;
let mut node = None;
@@ -127,6 +132,44 @@ pub async fn r_stream(
})
}
+#[get("/n/<slug>/thumbnail?<t>&<size>")]
+pub async fn r_thumbnail(
+ ri: RequestInfo<'_>,
+ slug: &str,
+ t: f64,
+ size: usize,
+) -> MyResult<Redirect> {
+ ri.require_user()?;
+ let mut node = None;
+ ri.state.database.transaction(&mut |txn| {
+ if let Some(row) = txn.query_single(Query {
+ filter: Filter::Match(Path(vec![NO_SLUG.0]), slug.into()),
+ ..Default::default()
+ })? {
+ node = txn.get(row)?;
+ }
+ Ok(())
+ })?;
+
+ let Some(node) = node else {
+ Err(anyhow!("node not found"))?
+ };
+
+ let t = (t / 10.).floor() * 10.; // quantize to 10s
+
+ let media_path = node
+ .iter(NO_TRACK)
+ .filter(|t| t.get(TR_KIND) == Some(TRKIND_VIDEO))
+ .filter_map(|t| t.get(TR_SOURCE))
+ .filter_map(|ts| ts.get(TRSOURCE_LOCAL_PATH))
+ .next()
+ .ok_or(anyhow!("node has no suitable video track"))?;
+
+ let thumb_path = create_thumbnail(&ri.state.cache, media_path.as_ref(), t)?;
+
+ Ok(Redirect::temporary(u_image(&thumb_path, size)))
+}
+
pub struct RedirectResponse(String);
#[rocket::async_trait]
diff --git a/ui/src/components/node_page.rs b/ui/src/components/node_page.rs
index 53dd904..06d8b55 100644
--- a/ui/src/components/node_page.rs
+++ b/ui/src/components/node_page.rs
@@ -10,11 +10,12 @@ use crate::{
node_card::{NodeCard, NodeCardWide},
props::Props,
},
+ format::format_duration,
page,
};
use jellycommon::{
jellyobject::{EMPTY, Object, Tag, TypedTag},
- routes::{u_image, u_node_slug_player},
+ routes::{u_image, u_node_slug_player, u_node_slug_player_time, u_node_slug_thumbnail},
*,
};
use jellyui_locale::tr;
@@ -92,21 +93,6 @@ markup::define! {
@if let Some(description) = &node.get(NO_DESCRIPTION) {
p { @for line in description.lines() { @line br; } }
}
- // @if !media.chapters.is_empty() {
- // h2 { @trs(lang, "node.chapters") }
- // ul.children.hlist { @for chap in &media.chapters {
- // @let (inl, sub) = format_chapter(chap);
- // li { .card."aspect-thumb" {
- // .poster {
- // a[href=u_node_slug_player_time(&node.slug, chap.time_start.unwrap_or(0.))] {
- // img[src=u_node_slug_thumbnail(&node.slug, chapter_key_time(chap, media.duration), 1024), loading="lazy"];
- // }
- // .cardhover { .props { p { @inl } } }
- // }
- // .title { span { @sub } }
- // }}
- // }}
- // }
@if node.has(NO_TRACK.0) {
details {
summary { @tr(ri.lang, "tag.trak") }
@@ -157,6 +143,21 @@ markup::define! {
}
}
}
+ @if node.has(NO_CHAPTER.0) {
+ h2 { @tr(ri.lang, "tag.chpt") }
+ ul.nl.inline { @for chap in node.iter(NO_CHAPTER) {
+ @let (inl, sub) = format_chapter(chap);
+ li { .card."aspect-thumb" {
+ .poster {
+ a[href=u_node_slug_player_time(&slug, chap.get(CH_START).unwrap_or(0.))] {
+ img[src=u_node_slug_thumbnail(&slug, chapter_key_time(chap, node.get(NO_DURATION).unwrap_or(1.)), 512), loading="lazy"];
+ }
+ .overlay { .props { p { @inl } } }
+ }
+ .title { span { @sub } }
+ }}
+ }}
+ }
}
@for (cat, items) in *credits {
@@ -191,11 +192,23 @@ markup::define! {
}
}
-// fn chapter_key_time(c: Object, dur: f64) -> f64 {
-// let start = c.get(CH_START).unwrap_or(0.);
-// let end = c.get(CH_END).unwrap_or(dur);
-// start * 0.8 + end * 0.2
-// }
+fn chapter_key_time(c: &Object, dur: f64) -> f64 {
+ let start = c.get(CH_START).unwrap_or(0.);
+ let end = c.get(CH_END).unwrap_or(dur);
+ start * 0.8 + end * 0.2
+}
+fn format_chapter(c: &Object) -> (String, String) {
+ (
+ format!(
+ "{} - {}",
+ format_duration(c.get(CH_START).unwrap_or(0.)),
+ c.get(CH_END)
+ .map(|x| format_duration(x))
+ .unwrap_or_default(),
+ ),
+ c.get(CH_NAME).map(|s| s.to_string()).unwrap_or_default(),
+ )
+}
pub fn aspect_class(node: &Object) -> &'static str {
let kind = node.get(NO_KIND).unwrap_or(KIND_COLLECTION);