diff options
| -rw-r--r-- | common/src/routes.rs | 2 | ||||
| -rw-r--r-- | locale/en.ini | 1 | ||||
| -rw-r--r-- | server/src/routes/mod.rs | 3 | ||||
| -rw-r--r-- | server/src/routes/stream.rs | 47 | ||||
| -rw-r--r-- | ui/src/components/node_page.rs | 55 |
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); |