aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--import/src/lib.rs102
-rw-r--r--import/src/tmdb.rs32
-rw-r--r--import/src/trakt.rs127
-rw-r--r--server/src/routes/ui/node.rs33
-rw-r--r--server/src/routes/ui/sort.rs6
-rw-r--r--tool/src/add.rs2
-rw-r--r--web/style/nodecard.css4
7 files changed, 235 insertions, 71 deletions
diff --git a/import/src/lib.rs b/import/src/lib.rs
index 0d8a10a..0990ba1 100644
--- a/import/src/lib.rs
+++ b/import/src/lib.rs
@@ -18,6 +18,7 @@ use jellyclient::{Appearance, PeopleGroup, TmdbKind, TraktKind, Visibility};
use log::info;
use matroska::matroska_metadata;
use rayon::iter::{ParallelBridge, ParallelIterator};
+use regex::Regex;
use std::{
collections::{BTreeMap, HashMap},
fs::{read_to_string, File},
@@ -42,8 +43,8 @@ pub mod trakt;
static IMPORT_SEM: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(1));
pub static IMPORT_ERRORS: RwLock<Vec<String>> = RwLock::const_new(Vec::new());
-// static RE_EPISODE_FILENAME: LazyLock<Regex> =
-// LazyLock::new(|| Regex::new(r#"([sS](\d+))?([eE](\d+))( (.+))?"#).unwrap());
+static RE_EPISODE_FILENAME: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r#"([sS](?<season>\d+))?([eE](?<episode>\d+))( (.+))?"#).unwrap());
struct Apis {
trakt: Option<Trakt>,
@@ -296,11 +297,20 @@ fn import_media_file(
})
.unwrap_or_default();
- let filename = path
- .file_name()
- .ok_or(anyhow!("no file stem"))?
- .to_string_lossy()
- .to_string();
+ let filename = path.file_name().unwrap().to_string_lossy().to_string();
+
+ let mut episode_index = None;
+ if let Some(cap) = RE_EPISODE_FILENAME.captures(&filename) {
+ if let Some(episode) = cap.name("episode").map(|m| m.as_str()) {
+ let season = cap.name("season").map(|m| m.as_str());
+ let episode = episode.parse::<usize>().context("parse episode num")?;
+ let season = season
+ .unwrap_or("1")
+ .parse::<usize>()
+ .context("parse season num")?;
+ episode_index = Some((season, episode))
+ }
+ }
let mut filename_toks = filename.split(".");
let filepath_stem = filename_toks.next().unwrap();
@@ -309,17 +319,36 @@ fn import_media_file(
.infojson
.as_ref()
.map(|ij| format!("youtube-{}", ij.id))
- .unwrap_or(make_kebab(&filepath_stem));
+ .unwrap_or_else(|| {
+ if let Some((s, e)) = episode_index {
+ format!(
+ "{}-s{s}e{e}",
+ make_kebab(
+ &path
+ .parent()
+ .unwrap()
+ .file_name()
+ .unwrap_or_default()
+ .to_string_lossy()
+ )
+ )
+ } else {
+ make_kebab(&filepath_stem)
+ }
+ });
let node = NodeID::from_slug(&slug);
db.update_node_init(node, |node| {
node.slug = slug;
- node.title = info.title;
+ node.title = info.title.or(node.title.clone());
node.visibility = visibility;
node.poster = m.cover.clone();
- node.description = tags.remove("DESCRIPTION").or(tags.remove("SYNOPSIS"));
- node.tagline = tags.remove("COMMENT");
+ node.description = tags
+ .remove("DESCRIPTION")
+ .or(tags.remove("SYNOPSIS"))
+ .or(node.description.clone());
+ node.tagline = tags.remove("COMMENT").or(node.tagline.clone());
node.parents.insert(parent);
if let Some(ct) = tags.get("CONTENT_TYPE") {
@@ -440,6 +469,53 @@ fn import_media_file(
Ok(())
})?;
+ if let Some((season, episode)) = episode_index {
+ let mut trakt_id = None;
+ let flagspath = path.parent().unwrap().join("flags");
+ if flagspath.exists() {
+ for flag in read_to_string(flagspath)?.lines() {
+ if let Some(value) = flag.strip_prefix("trakt-").or(flag.strip_prefix("trakt=")) {
+ let (kind, id) = value.split_once(":").unwrap_or(("", value));
+ if kind == "show" {
+ trakt_id = Some(id.parse::<u64>()?);
+ }
+ }
+ }
+ }
+ if let Some(trakt_id) = trakt_id {
+ let trakt = apis.trakt.as_ref().ok_or(anyhow!("trakt required"))?;
+ let seasons = rthandle.block_on(trakt.show_seasons(trakt_id))?;
+ if seasons.iter().any(|x| x.number == season) {
+ let episodes = rthandle.block_on(trakt.show_season_episodes(trakt_id, season))?;
+ let mut poster = None;
+ if let Some(tmdb) = &apis.tmdb {
+ let trakt_details =
+ rthandle.block_on(trakt.lookup(TraktKind::Show, trakt_id))?;
+ if let Some(tmdb_id) = trakt_details.ids.tmdb {
+ let tmdb_details =
+ rthandle.block_on(tmdb.episode_details(tmdb_id, season, episode))?;
+ if let Some(still) = &tmdb_details.still_path {
+ poster = Some(
+ AssetInner::Cache(rthandle.block_on(tmdb.image(&still))?).ser(),
+ )
+ }
+ }
+ }
+ if let Some(episode) = episodes.get(episode.saturating_sub(1)) {
+ db.update_node_init(node, |node| {
+ node.kind = NodeKind::Episode;
+ node.index = Some(episode.number);
+ node.title = Some(episode.title.clone());
+ node.poster = poster.or(node.poster.clone());
+ node.description = episode.overview.clone().or(node.description.clone());
+ node.ratings.insert(Rating::Trakt, episode.rating);
+ Ok(())
+ })?
+ }
+ }
+ }
+ }
+
for tok in filename_toks {
apply_node_flag(db, rthandle, apis, node, tok)?;
}
@@ -510,10 +586,10 @@ fn apply_trakt_tmdb(
let trakt_id: u64 = trakt_id.parse().context("parse trakt id")?;
if let (Some(trakt), Some(tmdb)) = (&apis.trakt, &apis.tmdb) {
let data = rthandle
- .block_on(trakt.lookup(trakt_kind, trakt_id, true))
+ .block_on(trakt.lookup(trakt_kind, trakt_id))
.context("trakt lookup")?;
let people = rthandle
- .block_on(trakt.people(trakt_kind, trakt_id, true))
+ .block_on(trakt.people(trakt_kind, trakt_id))
.context("trakt people lookup")?;
let mut people_map = BTreeMap::<PeopleGroup, Vec<Appearance>>::new();
diff --git a/import/src/tmdb.rs b/import/src/tmdb.rs
index 678ce61..70e95ce 100644
--- a/import/src/tmdb.rs
+++ b/import/src/tmdb.rs
@@ -116,6 +116,26 @@ impl Tmdb {
})
.await
}
+
+ pub async fn episode_details(
+ &self,
+ series_id: u64,
+ season: usize,
+ episode: usize,
+ ) -> anyhow::Result<Arc<TmdbEpisode>> {
+ async_cache_memory(&["api-tmdb-episode-details", &format!("{series_id} {season} {episode}")], || async move {
+ info!("tmdb episode details {series_id} S={season} E={episode}");
+ Ok(self
+ .image_client
+ .get(&format!("https://api.themoviedb.org/3/tv/{series_id}/season/{season}/episode/{episode}?api_key={}", self.key))
+ .send()
+ .await?
+ .error_for_status()?
+ .json()
+ .await?)
+ })
+ .await
+ }
}
pub fn parse_release_date(d: &str) -> anyhow::Result<Option<i64>> {
@@ -143,6 +163,18 @@ pub fn parse_release_date(d: &str) -> anyhow::Result<Option<i64>> {
}
#[derive(Debug, Clone, Deserialize, Encode, Decode)]
+pub struct TmdbEpisode {
+ pub air_date: String,
+ pub overview: String,
+ pub name: String,
+ pub id: u64,
+ pub runtime: f64,
+ pub still_path: Option<String>,
+ pub vote_average: f64,
+ pub vote_count: usize,
+}
+
+#[derive(Debug, Clone, Deserialize, Encode, Decode)]
pub struct TmdbPersonImage {
pub id: u64,
pub profiles: Vec<TmdbPersonImageProfile>,
diff --git a/import/src/trakt.rs b/import/src/trakt.rs
index c7a25ad..1daee77 100644
--- a/import/src/trakt.rs
+++ b/import/src/trakt.rs
@@ -1,3 +1,4 @@
+use anyhow::Context;
/*
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.
@@ -46,76 +47,115 @@ impl Trakt {
&self,
kinds: &[TraktKind],
query: &str,
- extended: bool,
) -> anyhow::Result<Arc<Vec<TraktSearchResult>>> {
+ async_cache_memory(&["api-trakt-lookup", query], || async move {
+ let url = format!(
+ "https://api.trakt.tv/search/{}?query={}&extended=full",
+ kinds
+ .iter()
+ .map(|t| t.singular())
+ .collect::<Vec<_>>()
+ .join(","),
+ urlencoding::encode(query),
+ );
+ let res = self.client.get(url).send().await?.error_for_status()?;
+ Ok(res.json().await?)
+ })
+ .await
+ .context("trakt search")
+ }
+
+ pub async fn lookup(&self, kind: TraktKind, id: u64) -> anyhow::Result<Arc<TraktMediaObject>> {
async_cache_memory(
- &["api-trakt-lookup", query, if extended { "a" } else { "b" }],
+ &["api-trakt-lookup", &format!("{kind} {id}")],
|| async move {
- let url = format!(
- "https://api.trakt.tv/search/{}?query={}{}",
- kinds
- .iter()
- .map(|t| t.singular())
- .collect::<Vec<_>>()
- .join(","),
- urlencoding::encode(query),
- optext(extended)
- );
+ info!("trakt lookup {kind:?}:{id:?}");
+ let url = format!("https://api.trakt.tv/{}/{id}?extended=full", kind.plural());
let res = self.client.get(url).send().await?.error_for_status()?;
Ok(res.json().await?)
},
)
.await
+ .context("trakt lookup")
}
- pub async fn lookup(
- &self,
- kind: TraktKind,
- id: u64,
- extended: bool,
- ) -> anyhow::Result<Arc<TraktMediaObject>> {
+ pub async fn people(&self, kind: TraktKind, id: u64) -> anyhow::Result<Arc<TraktPeople>> {
async_cache_memory(
- &["api-trakt-lookup", &format!("{id} {extended}")],
+ &["api-trakt-people", &format!("{kind} {id}")],
|| async move {
- info!("trakt lookup {kind:?}:{id:?}");
+ info!("trakt people {kind:?}:{id:?}");
let url = format!(
- "https://api.trakt.tv/{}/{}{}",
- kind.plural(),
- id,
- optext2(extended)
+ "https://api.trakt.tv/{}/{id}/people?extended=full",
+ kind.plural()
);
let res = self.client.get(url).send().await?.error_for_status()?;
Ok(res.json().await?)
},
)
.await
+ .context("trakt people")
+ }
+
+ pub async fn show_seasons(&self, id: u64) -> anyhow::Result<Arc<Vec<TraktSeason>>> {
+ async_cache_memory(&["api-trakt-seasons", &id.to_string()], || async move {
+ info!("trakt seasons {id:?}");
+ let url = format!("https://api.trakt.tv/shows/{id}/seasons?extended=full");
+ let res = self.client.get(url).send().await?.error_for_status()?;
+ Ok(res.json().await?)
+ })
+ .await
+ .context("trakt show seasons")
}
- pub async fn people(
+ pub async fn show_season_episodes(
&self,
- kind: TraktKind,
id: u64,
- extended: bool,
- ) -> anyhow::Result<Arc<TraktPeople>> {
+ season: usize,
+ ) -> anyhow::Result<Arc<Vec<TraktEpisode>>> {
async_cache_memory(
- &["api-trakt-people", &format!("{id} {extended}")],
+ &["api-trakt-episodes", &id.to_string(), &season.to_string()],
|| async move {
- info!("trakt people {kind:?}:{id:?}");
- let url = format!(
- "https://api.trakt.tv/{}/{}/people{}",
- kind.plural(),
- id,
- optext2(extended)
- );
+ info!("trakt episodes {id:?} season={season}");
+ let url = format!("https://api.trakt.tv/shows/{id}/seasons/{season}?extended=full");
let res = self.client.get(url).send().await?.error_for_status()?;
Ok(res.json().await?)
},
)
.await
+ .context("trakt show season episodes")
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)]
+pub struct TraktSeason {
+ pub number: usize,
+ pub ids: ObjectIds,
+ pub rating: f64,
+ pub votes: usize,
+ pub episode_count: usize,
+ pub aired_count: Option<usize>,
+ pub title: String,
+ pub overview: Option<String>,
+ pub network: String,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)]
+pub struct TraktEpisode {
+ pub season: Option<usize>,
+ pub number: usize,
+ pub number_abs: Option<usize>,
+ pub ids: ObjectIds,
+ pub rating: f64,
+ pub votes: usize,
+ pub title: String,
+ pub runtime: f64,
+ pub overview: Option<String>,
+ pub available_translations: Vec<String>,
+ pub first_aired: Option<String>,
+ pub episode_type: String,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize, Default, Encode, Decode)]
pub struct TraktPeople {
#[serde(default)]
pub cast: Vec<TraktAppearance>,
@@ -138,21 +178,6 @@ pub struct TraktPerson {
pub ids: ObjectIds,
}
-fn optext(extended: bool) -> &'static str {
- if extended {
- "&extended=full"
- } else {
- ""
- }
-}
-fn optext2(extended: bool) -> &'static str {
- if extended {
- "?extended=full"
- } else {
- ""
- }
-}
-
#[derive(Debug, Serialize, Deserialize, Encode, Decode)]
pub struct TraktSearchResult {
pub r#type: TraktKind,
diff --git a/server/src/routes/ui/node.rs b/server/src/routes/ui/node.rs
index 337d249..f8b448f 100644
--- a/server/src/routes/ui/node.rs
+++ b/server/src/routes/ui/node.rs
@@ -63,17 +63,20 @@ pub async fn r_library_node_filter<'a>(
.into_iter()
.map(|c| db.get_node_with_userdata(c, &session))
.collect::<anyhow::Result<Vec<_>>>()?;
+ children.retain(|(n, _)| n.visibility >= Visibility::Reduced);
- let parents = node
+ let mut parents = node
.parents
.iter()
.flat_map(|pid| db.get_node(*pid).transpose())
.collect::<Result<Vec<_>, _>>()?;
+ parents.retain(|n| n.visibility >= Visibility::Reduced);
filter_and_sort_nodes(
&filter,
match node.kind {
NodeKind::Channel => (SortProperty::ReleaseDate, SortOrder::Descending),
+ NodeKind::Season | NodeKind::Show => (SortProperty::Index, SortOrder::Ascending),
_ => (SortProperty::Title, SortOrder::Ascending),
},
&mut children,
@@ -115,6 +118,26 @@ markup::define! {
}
}
}
+ NodeCardWide<'a>(node: &'a Node, udata: &'a NodeUserData) {
+ @let cls = format!("node card widecard poster {}", aspect_class(node.kind));
+ div[class=cls] {
+ .poster {
+ a[href=uri!(r_library_node(&node.slug))] {
+ img[src=uri!(r_item_poster(&node.slug, Some(1024))), loading="lazy"];
+ }
+ .cardhover.item {
+ @if node.media.is_some() {
+ a.play.icon[href=&uri!(r_player(&node.slug, PlayerConfig::default()))] { "play_arrow" }
+ }
+ }
+ }
+ div.details {
+ a.title[href=uri!(r_library_node(&node.slug))] { @node.title }
+ @Props { node, udata, full: false }
+ span.overview { @node.description }
+ }
+ }
+ }
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, NodeKind::Collection) {
img.backdrop[src=uri!(r_item_backdrop(id, Some(2048))), loading="lazy"];
@@ -218,15 +241,13 @@ markup::define! {
}
@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 } }
+ ol { @for (node, udata) in children.iter() {
+ li { @NodeCardWide { node, udata } }
}}
}
NodeKind::Collection | NodeKind::Channel | _ => {
ul.children {@for (node, udata) in children.iter() {
- @if node.visibility != Visibility::Hidden {
- li { @NodeCard { node, udata } }
- }
+ li { @NodeCard { node, udata } }
}}
}
}
diff --git a/server/src/routes/ui/sort.rs b/server/src/routes/ui/sort.rs
index 36250a9..6bee2ef 100644
--- a/server/src/routes/ui/sort.rs
+++ b/server/src/routes/ui/sort.rs
@@ -45,6 +45,7 @@ form_enum!(
enum SortProperty {
ReleaseDate = "release_date",
Title = "title",
+ Index = "index",
Duration = "duration",
RatingRottenTomatoes = "rating_rt",
RatingMetacritic = "rating_mc",
@@ -180,6 +181,11 @@ pub fn filter_and_sort_nodes(
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.))
}),
diff --git a/tool/src/add.rs b/tool/src/add.rs
index a0a3951..2179a40 100644
--- a/tool/src/add.rs
+++ b/tool/src/add.rs
@@ -45,7 +45,7 @@ pub async fn add(action: Action) -> anyhow::Result<()> {
.ok_or(anyhow!("no trakt api key configured"))?,
);
- let results = trakt.search(search_kinds, &name, false).await?;
+ let results = trakt.search(search_kinds, &name).await?;
if results.is_empty() {
warn!("no search results");
diff --git a/web/style/nodecard.css b/web/style/nodecard.css
index 4fd039e..011e415 100644
--- a/web/style/nodecard.css
+++ b/web/style/nodecard.css
@@ -118,6 +118,10 @@
left: 0px;
}
+.widecard {
+
+}
+
@media (max-width: 750px) {
nav .library {
display: none;