aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2024-01-21 19:29:17 +0100
committermetamuffin <metamuffin@disroot.org>2024-01-21 19:29:17 +0100
commita8fe841aaefe904121d936e608572a1422191167 (patch)
tree0d4bb04670af5a6212938664ec70470841ac8399
parent06d5c0d961c85abb3dd645b65b4447936fe7690f (diff)
downloadjellything-a8fe841aaefe904121d936e608572a1422191167.tar
jellything-a8fe841aaefe904121d936e608572a1422191167.tar.bz2
jellything-a8fe841aaefe904121d936e608572a1422191167.tar.zst
trakt import
-rw-r--r--Cargo.lock31
-rw-r--r--base/src/cache.rs66
-rw-r--r--base/src/lib.rs10
-rw-r--r--common/src/config.rs2
-rw-r--r--common/src/lib.rs52
-rw-r--r--import/src/lib.rs69
-rw-r--r--import/src/mod.rs319
-rw-r--r--import/src/trakt.rs191
-rw-r--r--server/src/routes/ui/node.rs1
-rw-r--r--tool/Cargo.toml3
-rw-r--r--tool/src/add.rs181
-rw-r--r--tool/src/main.rs75
12 files changed, 537 insertions, 463 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 7f9c23f..d9e9955 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -705,6 +705,20 @@ dependencies = [
]
[[package]]
+name = "dialoguer"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
+dependencies = [
+ "console",
+ "fuzzy-matcher",
+ "shell-words",
+ "tempfile",
+ "thiserror",
+ "zeroize",
+]
+
+[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -963,6 +977,15 @@ dependencies = [
]
[[package]]
+name = "fuzzy-matcher"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94"
+dependencies = [
+ "thread_local",
+]
+
+[[package]]
name = "generator"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1462,11 +1485,13 @@ dependencies = [
"base64",
"bincode",
"clap",
+ "dialoguer",
"env_logger",
"indicatif",
"jellybase",
"jellyclient",
"jellycommon",
+ "jellyimport",
"log",
"rand 0.8.5",
"reqwest",
@@ -2738,6 +2763,12 @@ dependencies = [
]
[[package]]
+name = "shell-words"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
+
+[[package]]
name = "signal-hook-registry"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/base/src/cache.rs b/base/src/cache.rs
index e580130..d1c3e4d 100644
--- a/base/src/cache.rs
+++ b/base/src/cache.rs
@@ -24,7 +24,10 @@ use std::{
},
time::Instant,
};
-use tokio::sync::Mutex;
+use tokio::{
+ io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt},
+ sync::Mutex,
+};
pub fn cache_location(seed: &[&str]) -> (usize, AssetLocation) {
use sha2::Digest;
@@ -169,6 +172,67 @@ where
Ok(object)
}
+pub async fn async_cache_memory<Fun, Fut, T>(
+ seed: &[&str],
+ mut generate: Fun,
+) -> Result<Arc<T>, anyhow::Error>
+where
+ Fun: FnMut() -> Fut,
+ Fut: Future<Output = Result<T, anyhow::Error>>,
+ T: Encode + Decode + Send + Sync + 'static,
+{
+ let (_, location) = cache_location(seed);
+ {
+ let mut g = CACHE_IN_MEMORY_OBJECTS.write().unwrap();
+ if let Some(entry) = g.get_mut(&location) {
+ entry.last_access = Instant::now();
+ let object = entry
+ .object
+ .clone()
+ .downcast::<T>()
+ .map_err(|_| anyhow!("inconsistent types for in-memory cache"))?;
+ return Ok(object);
+ }
+ }
+
+ let location = async_cache_file(seed, move |mut file| async move {
+ let object = generate().await?;
+ let data = bincode::encode_to_vec(&object, bincode::config::standard())
+ .context("encoding cache object")?;
+
+ file.write_all(&data).await?;
+
+ Ok(())
+ })
+ .await?;
+ let mut file = tokio::fs::File::open(location.path()).await?;
+ let mut data = Vec::new();
+ file.read_to_end(&mut data)
+ .await
+ .context("reading cache object")?;
+ let (object, _) = bincode::decode_from_slice::<T, _>(&data, bincode::config::standard())
+ .context("decoding cache object")?;
+ let object = Arc::new(object);
+ let size = file.stream_position().await? as usize; // this is an approximation mainly since varint is used in bincode
+
+ {
+ let mut g = CACHE_IN_MEMORY_OBJECTS.write().unwrap();
+ g.insert(
+ location,
+ InMemoryCacheEntry {
+ size,
+ last_access: Instant::now(),
+ object: object.clone(),
+ },
+ );
+ CACHE_IN_MEMORY_SIZE.fetch_add(size, Ordering::Relaxed);
+ }
+
+ cleanup_cache();
+
+ Ok(object)
+}
+
pub fn cleanup_cache() {
let current_size = CACHE_IN_MEMORY_SIZE.load(Ordering::Relaxed);
if current_size < CONF.max_in_memory_cache_size {
diff --git a/base/src/lib.rs b/base/src/lib.rs
index 0001caa..48d3b37 100644
--- a/base/src/lib.rs
+++ b/base/src/lib.rs
@@ -19,9 +19,13 @@ use std::{fs::File, path::PathBuf, sync::LazyLock};
pub static CONF: LazyLock<GlobalConfig> = LazyLock::new(|| {
serde_yaml::from_reader(
File::open(std::env::var("JELLYTHING_CONFIG").unwrap_or_else(|_| {
- std::env::args().nth(1).expect(
- "First argument or JELLYTHING_CONFIG must specify the configuration to use.",
- )
+ if std::env::args().nth(0) == Some("jellything".to_string()) {
+ std::env::args().nth(1).expect(
+ "First argument or JELLYTHING_CONFIG must specify the configuration to use.",
+ )
+ } else {
+ panic!("JELLYTHING_CONFIG variable is required.")
+ }
}))
.expect("config cannot be read"),
)
diff --git a/common/src/config.rs b/common/src/config.rs
index 4e90cf0..d9f2a8e 100644
--- a/common/src/config.rs
+++ b/common/src/config.rs
@@ -46,9 +46,11 @@ pub struct FederationAccount {
#[serde(default = "return_true")]
pub tls: bool,
}
+
#[derive(Serialize, Deserialize, Debug)]
pub struct ApiSecrets {
pub tmdb: Option<String>,
+ pub tvdb: Option<String>,
pub imdb: Option<String>,
pub omdb: Option<String>,
pub fanart_tv: Option<String>,
diff --git a/common/src/lib.rs b/common/src/lib.rs
index 5387679..57b210b 100644
--- a/common/src/lib.rs
+++ b/common/src/lib.rs
@@ -67,6 +67,10 @@ pub enum ImportSource {
Tmdb {
id: u64,
},
+ Trakt {
+ kind: TraktKind,
+ id: u64,
+ },
AutoChildren {
path: Option<PathBuf>,
},
@@ -163,6 +167,7 @@ pub enum Rating {
YoutubeViews,
YoutubeLikes,
YoutubeFollowers,
+ Trakt,
}
#[derive(Debug, Clone, Deserialize, Serialize, Encode, Decode)]
@@ -188,3 +193,50 @@ pub enum AssetRole {
#[cfg_attr(feature = "rocket", field(value = "poster"))] Poster,
#[cfg_attr(feature = "rocket", field(value = "backdrop"))] Backdrop,
}
+
+#[derive(Debug, Serialize, Deserialize, Clone, Copy, Encode, Decode)]
+#[serde(rename_all = "snake_case")]
+pub enum TraktKind {
+ Movie,
+ Show,
+ Season,
+ Episode,
+ Person,
+ User,
+}
+
+impl TraktKind {
+ pub fn singular(self) -> &'static str {
+ match self {
+ TraktKind::Movie => "movie",
+ TraktKind::Show => "show",
+ TraktKind::Season => "season",
+ TraktKind::Episode => "episode",
+ TraktKind::Person => "person",
+ TraktKind::User => "user",
+ }
+ }
+ pub fn plural(self) -> &'static str {
+ match self {
+ TraktKind::Movie => "movies",
+ TraktKind::Show => "shows",
+ TraktKind::Season => "seasons",
+ TraktKind::Episode => "episodes",
+ TraktKind::Person => "people",
+ TraktKind::User => "users", // //! not used in API
+ }
+ }
+}
+impl ToString for TraktKind {
+ fn to_string(&self) -> String {
+ match self {
+ TraktKind::Movie => "Movie",
+ TraktKind::Show => "Show",
+ TraktKind::Season => "Season",
+ TraktKind::Episode => "Episode",
+ TraktKind::Person => "Person",
+ TraktKind::User => "User",
+ }
+ .to_string()
+ }
+}
diff --git a/import/src/lib.rs b/import/src/lib.rs
index ab74ecb..d6eb54f 100644
--- a/import/src/lib.rs
+++ b/import/src/lib.rs
@@ -40,9 +40,14 @@ use std::{
};
use tmdb::{parse_release_date, tmdb_image};
use tokio::{io::AsyncWriteExt, sync::Semaphore, task::spawn_blocking};
+use trakt::Trakt;
static IMPORT_SEM: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(1));
+struct Apis {
+ trakt: Option<Trakt>,
+}
+
pub async fn import(db: &DataAcid, fed: &Federation) -> anyhow::Result<()> {
let permit = IMPORT_SEM.try_acquire()?;
@@ -56,8 +61,13 @@ pub async fn import(db: &DataAcid, fed: &Federation) -> anyhow::Result<()> {
drop(table);
txn.commit()?;
}
+
+ let ap = Apis {
+ trakt: SECRETS.api.trakt.as_ref().map(|key| Trakt::new(key)),
+ };
+
info!("loading sources...");
- import_path(CONF.library_path.clone(), vec![], db, fed)
+ import_path(CONF.library_path.clone(), vec![], db, fed, &ap)
.await
.context("indexing")?;
info!("removing old nodes...");
@@ -155,11 +165,12 @@ fn compare_index_path(x: &[usize], y: &[usize]) -> Ordering {
}
#[async_recursion]
-pub async fn import_path(
+async fn import_path(
path: PathBuf,
index_path: Vec<usize>,
db: &DataAcid,
fed: &Federation,
+ ap: &Apis,
) -> anyhow::Result<()> {
if path.is_dir() {
let mut children_paths = path
@@ -192,6 +203,7 @@ pub async fn import_path(
},
db,
fed,
+ ap,
)
})
.collect();
@@ -209,7 +221,7 @@ pub async fn import_path(
};
for s in opts.sources {
- process_source(opts.id.clone(), s, &path, &index_path, db, fed).await?;
+ process_source(opts.id.clone(), s, &path, &index_path, db, fed, ap).await?;
}
}
Ok(())
@@ -225,6 +237,7 @@ async fn process_source(
index_path: &[usize],
db: &DataAcid,
fed: &Federation,
+ ap: &Apis,
) -> anyhow::Result<()> {
let insert_node = move |id: &str, n: Node| -> anyhow::Result<()> {
let txn = db.inner.begin_write()?;
@@ -240,6 +253,42 @@ async fn process_source(
};
match s {
ImportSource::Override(n) => insert_node(&id, n)?,
+ ImportSource::Trakt { id: tid, kind } => {
+ let trakt_object = ap
+ .trakt
+ .as_ref()
+ .ok_or(anyhow!("trakt api key is required"))?
+ .lookup(kind, tid, true)
+ .await?;
+
+ let mut node = Node::default();
+ node.public.title = Some(trakt_object.title.to_owned());
+ if let Some(overview) = &trakt_object.overview {
+ node.public.description = Some(overview.to_owned())
+ }
+ if let Some(tagline) = &trakt_object.tagline {
+ node.public.tagline = Some(tagline.to_owned())
+ }
+ if let Some(rating) = &trakt_object.rating {
+ node.public.ratings.insert(Rating::Trakt, *rating);
+ }
+ insert_node(&id, node)?;
+
+ if let Some(tid) = trakt_object.ids.tmdb {
+ let mut index_path = index_path.to_vec();
+ index_path.push(1);
+ process_source(
+ id,
+ ImportSource::Tmdb { id: tid },
+ path,
+ &index_path,
+ db,
+ fed,
+ ap,
+ )
+ .await?;
+ }
+ }
ImportSource::Tmdb { id: tid } => {
info!("tmdb lookup {id}");
let key = SECRETS
@@ -324,6 +373,7 @@ async fn process_source(
index_path,
db,
fed,
+ ap,
)
.await
.context(anyhow!("recursive media import: {:?}", f.path()))?;
@@ -624,16 +674,3 @@ async fn cache_federation_asset(
)
.await
}
-
-// fn make_ident(s: &str) -> String {
-// let mut out = String::new();
-// for s in s.chars() {
-// match s {
-// 'a'..='z' | '0'..='9' => out.push(s),
-// 'A'..='Z' => out.push(s.to_ascii_lowercase()),
-// '-' | ' ' | '_' | ':' => out.push('-'),
-// _ => (),
-// }
-// }
-// out
-// }
diff --git a/import/src/mod.rs b/import/src/mod.rs
deleted file mode 100644
index 0c43cde..0000000
--- a/import/src/mod.rs
+++ /dev/null
@@ -1,319 +0,0 @@
-/*
- 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.
- Copyright (C) 2023 metamuffin <metamuffin.org>
-*/
-pub mod infojson;
-pub mod tmdb;
-
-use crate::{make_ident, ok_or_warn, Action};
-use anyhow::Context;
-use infojson::YVideo;
-use jellycommon::{
- AssetLocation, LocalTrack, MediaInfo, Node, NodeKind, NodePrivate, NodePublic, Rating,
- TrackSource,
-};
-use jellymatroska::read::EbmlReader;
-use jellyremuxer::import::import_metadata;
-use log::{debug, info, warn};
-use std::{
- collections::BTreeMap,
- fs::{remove_file, File},
- io::{stdin, BufReader, Write},
-};
-use tmdb::{tmdb_details, tmdb_image};
-
-pub(crate) fn import(action: Action, dry: bool) -> anyhow::Result<()> {
- match action {
- Action::New {
- path,
- tmdb_id,
- tmdb_search,
- input,
- series,
- ident_prefix,
- ignore_attachments,
- copy,
- video,
- ignore_metadata,
- r#move,
- title,
- skip_existing,
- } => {
- if std::env::current_dir().unwrap().file_name().unwrap() != "library" {
- warn!("new command can only be used in the library directory; what you are doing right now probably wont work.")
- }
-
- if skip_existing {
- if let Some(input) = &input {
- let guessed_path = path.join(input.file_stem().unwrap_or(input.as_os_str()));
- if guessed_path.exists() {
- info!("guessed output ({guessed_path:?}) exists, skipping import");
- return Ok(());
- } else {
- debug!("guessed output ({guessed_path:?}) missing");
- }
- }
- }
-
- let tmdb_kind = if series { "tv" } else { "movie" };
- let tmdb_id = if let Some(id) = tmdb_id {
- Some(id.parse().unwrap())
- } else if let Some(title) = tmdb_search {
- let tmdb_key = std::env::var("TMDB_API_KEY").context("tmdb api key required")?;
- let results = tmdb::tmdb_search(tmdb_kind, &title, &tmdb_key)?;
- info!("results:");
- for (i, r) in results.results.iter().enumerate() {
- info!(
- "\t[{i}] {}: {} ({})",
- r.id,
- r.title.as_ref().or(r.name.as_ref()).unwrap(),
- r.overview.chars().take(100).collect::<String>()
- );
- }
- let res_index = if results.results.len() > 1 {
- stdin()
- .lines()
- .next()
- .unwrap()
- .unwrap()
- .parse::<usize>()
- .unwrap()
- } else {
- 0
- };
- Some(results.results[res_index].id)
- } else {
- None
- };
-
- let tmdb_details = tmdb_id
- .map(|id| {
- let tmdb_key =
- std::env::var("TMDB_API_KEY").context("tmdb api key required")?;
- let td = tmdb_details(tmdb_kind, id, &tmdb_key)
- .context("fetching details")
- .unwrap();
- Ok::<_, anyhow::Error>(td)
- })
- .transpose()?;
-
- let mut kind = NodeKind::Series;
- let mut file_meta = None;
- let mut infojson = None;
-
- if let Some(input_path) = &input {
- file_meta = Some({
- let input = BufReader::new(File::open(&input_path).unwrap());
- let mut input = EbmlReader::new(input);
- import_metadata(&mut input)?
- });
- if ignore_attachments {
- let file_meta = file_meta.as_mut().unwrap();
- file_meta.cover = None;
- file_meta.infojson = None;
- }
- if ignore_metadata {
- let file_meta = file_meta.as_mut().unwrap();
- file_meta.description = None;
- file_meta.tagline = None;
- file_meta.title = None;
- }
-
- if let Some(ij) = &file_meta.as_ref().unwrap().infojson {
- infojson =
- Some(serde_json::from_str::<YVideo>(ij).context("parsing info.json")?);
- }
-
- kind = if video {
- NodeKind::Video
- } else {
- NodeKind::Movie
- };
- }
-
- let title = title
- .or(tmdb_details
- .as_ref()
- .map(|d| d.title.clone().or(d.name.clone()))
- .flatten())
- .or(file_meta.as_ref().map(|m| m.title.clone()).flatten())
- .expect("no title detected");
-
- let ident = format!(
- "{}{}",
- ident_prefix.unwrap_or(String::new()),
- make_ident(
- &infojson
- .as_ref()
- .map(|i| i.id.clone())
- .unwrap_or(title.clone())
- ),
- );
- let path = path.join(&ident);
- let source_path = input.as_ref().map(|_| path.join(format!("source.mkv")));
-
- let (mut poster, mut backdrop) = (None, None);
- if !dry {
- std::fs::create_dir_all(&path)?;
-
- poster = file_meta
- .as_ref()
- .map(|m| {
- m.cover
- .as_ref()
- .map(|(mime, data)| {
- let pu = path.join(format!(
- "cover.{}",
- match mime.as_str() {
- "image/webp" => "webp",
- "image/jpeg" => "jpeg",
- "image/png" => "png",
- _ => {
- warn!("unknown mime, just using webp");
- "webp"
- }
- }
- ));
- if !pu.exists() {
- let mut f = File::create(&pu)?;
- f.write_all(&data)?;
- }
- Ok::<_, anyhow::Error>(pu)
- })
- .transpose()
- })
- .transpose()?
- .flatten()
- .or(tmdb_details
- .as_ref()
- .map(|d| {
- d.poster_path
- .as_ref()
- .map(|p| {
- let pu = path.join("poster.jpeg");
- let mut f = File::create(&pu)?;
- tmdb_image(&p, &mut f)?;
- Ok::<_, anyhow::Error>(pu)
- })
- .transpose()
- })
- .transpose()?
- .flatten());
-
- backdrop = tmdb_details
- .as_ref()
- .map(|d| {
- d.backdrop_path
- .as_ref()
- .map(|p| {
- let pu = path.join("backdrop.jpeg");
- let mut f = File::create(&pu)?;
- tmdb_image(&p, &mut f)?;
- Ok::<_, anyhow::Error>(pu)
- })
- .transpose()
- })
- .transpose()?
- .flatten();
- }
-
- let mut ratings = BTreeMap::new();
-
- ratings.extend(
- infojson
- .as_ref()
- .map(|i| (Rating::YoutubeViews, i.view_count as f64)),
- );
- ratings.extend(
- infojson
- .as_ref()
- .map(|i| i.like_count.map(|l| (Rating::YoutubeLikes, l as f64)))
- .flatten(),
- );
- ratings.extend(
- tmdb_details
- .as_ref()
- .map(|d| (Rating::Tmdb, d.vote_average)),
- );
-
- let node = Node {
- private: NodePrivate {
- id: Some(ident.clone()),
- backdrop: backdrop.clone().map(AssetLocation::Library),
- poster: poster.clone().map(AssetLocation::Library),
- source: file_meta.as_ref().map(|m| MediaSource::Local {
- tracks: m
- .track_sources
- .clone()
- .into_iter()
- .map(|t| LocalTrack {
- path: source_path.clone().unwrap(),
- ..t
- })
- .collect(),
- }),
- },
- public: NodePublic {
- federated: None,
- ratings,
- description: file_meta
- .as_ref()
- .map(|m| m.description.clone())
- .flatten()
- .or(tmdb_details.as_ref().map(|d| d.overview.to_owned())),
- tagline: file_meta.as_ref().map(|m| m.tagline.clone()).flatten().or(
- tmdb_details
- .as_ref()
- .map(|d| d.tagline.to_owned())
- .flatten(),
- ),
- title: Some(title),
- index: None,
- kind: Some(kind),
- children: Vec::new(),
- media: file_meta.as_ref().map(|m| MediaInfo {
- chapters: m.chapters.clone(),
- duration: m.duration,
- tracks: m.tracks.clone(),
- }),
- release_date: tmdb_details
- .as_ref()
- .map(|d| tmdb::parse_release_date(&d.release_date.clone()?).ok())
- .flatten()
- .or(infojson
- .as_ref()
- .and_then(|j| ok_or_warn(infojson::parse_upload_date(&j.upload_date)))),
- ..Default::default()
- },
- };
-
- if dry {
- println!("{}", serde_json::to_string_pretty(&node)?);
- } else {
- if let Some(source_path) = source_path {
- let input = input.clone().unwrap();
- if r#move {
- std::fs::rename(&input, &source_path)?;
- } else if copy {
- std::fs::copy(&input, &source_path)?;
- } else {
- if source_path.is_symlink() {
- remove_file(&source_path)?;
- }
- std::os::unix::fs::symlink(&input, &source_path)?;
- }
- }
- let f = File::create(path.join(if series {
- "directory.json"
- } else {
- "item.jelly"
- }))?;
- serde_json::to_writer_pretty(f, &node)?;
- }
-
- Ok(())
- }
- _ => unreachable!(),
- }
-}
diff --git a/import/src/trakt.rs b/import/src/trakt.rs
index e142eb6..9674351 100644
--- a/import/src/trakt.rs
+++ b/import/src/trakt.rs
@@ -1,8 +1,12 @@
+use bincode::{Decode, Encode};
+use jellybase::cache::async_cache_memory;
+use jellycommon::TraktKind;
use reqwest::{
header::{HeaderMap, HeaderName, HeaderValue},
Client, ClientBuilder,
};
use serde::{Deserialize, Serialize};
+use std::{fmt::Display, sync::Arc};
pub struct Trakt {
client: Client,
@@ -32,25 +36,50 @@ impl Trakt {
pub async fn search(
&self,
- types: &[TraktKind],
+ kinds: &[TraktKind],
query: &str,
extended: bool,
- ) -> anyhow::Result<Vec<TraktSearchResult>> {
- let res = self
- .client
- .get(format!(
- "https://api.trakt.tv/search/{}?query={}{}",
- types
- .iter()
- .map(|t| serde_json::to_string(t).unwrap())
- .collect::<Vec<_>>()
- .join(","),
- urlencoding::encode(query),
- optext(extended)
- ))
- .send()
- .await?;
- Ok(res.json().await?)
+ ) -> anyhow::Result<Arc<Vec<TraktSearchResult>>> {
+ async_cache_memory(
+ &["api-trakt-lookup", query, if extended { "a" } else { "b" }],
+ || 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)
+ );
+ let res = self.client.get(url).send().await?.error_for_status()?;
+ Ok(res.json().await?)
+ },
+ )
+ .await
+ }
+
+ pub async fn lookup(
+ &self,
+ kind: TraktKind,
+ id: u64,
+ extended: bool,
+ ) -> anyhow::Result<Arc<TraktMediaObject>> {
+ async_cache_memory(
+ &["api-trakt-lookup", &format!("{id} {extended}")],
+ || async move {
+ let url = format!(
+ "https://api.trakt.tv/{}/{}{}",
+ kind.plural(),
+ id,
+ optext2(extended)
+ );
+ let res = self.client.get(url).send().await?.error_for_status()?;
+ Ok(res.json().await?)
+ },
+ )
+ .await
}
}
@@ -61,49 +90,23 @@ fn optext(extended: bool) -> &'static str {
""
}
}
-
-#[derive(Debug, Serialize, Deserialize, Clone, Copy)]
-#[serde(rename_all = "snake_case")]
-pub enum TraktKind {
- Movie,
- Show,
- Season,
- Episode,
- Person,
- User,
-}
-
-impl TraktKind {
- pub fn singular(self) -> &'static str {
- match self {
- TraktKind::Movie => "movie",
- TraktKind::Show => "show",
- TraktKind::Season => "season",
- TraktKind::Episode => "episode",
- TraktKind::Person => "person",
- TraktKind::User => "user",
- }
- }
- pub fn plural(self) -> &'static str {
- match self {
- TraktKind::Movie => "movies",
- TraktKind::Show => "shows",
- TraktKind::Season => "seasons",
- TraktKind::Episode => "episodes",
- TraktKind::Person => "people",
- TraktKind::User => "user", // //! not used in API
- }
+fn optext2(extended: bool) -> &'static str {
+ if extended {
+ "?extended=full"
+ } else {
+ ""
}
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, Encode, Decode)]
pub struct TraktSearchResult {
- r#type: TraktKind,
- score: f64,
- inner: TraktKindObject,
+ pub r#type: TraktKind,
+ pub score: f64,
+ #[serde(flatten)]
+ pub inner: TraktKindObject,
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, Encode, Decode)]
#[serde(rename_all = "snake_case")]
pub enum TraktKindObject {
Movie(TraktMediaObject),
@@ -114,18 +117,80 @@ pub enum TraktKindObject {
User(TraktMediaObject),
}
-#[derive(Debug, Serialize, Deserialize)]
+impl TraktKindObject {
+ pub fn inner(&self) -> &TraktMediaObject {
+ match self {
+ TraktKindObject::Movie(x)
+ | TraktKindObject::Show(x)
+ | TraktKindObject::Season(x)
+ | TraktKindObject::Episode(x)
+ | TraktKindObject::Person(x)
+ | TraktKindObject::User(x) => x,
+ }
+ }
+}
+
+#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)]
pub struct TraktMediaObject {
- title: String,
- year: Option<u32>,
- ids: TraktMediaObjectIds,
+ pub title: String,
+ pub year: Option<u32>,
+ pub ids: TraktMediaObjectIds,
+
+ pub tagline: Option<String>,
+ pub overview: Option<String>,
+ pub released: Option<String>,
+ pub runtime: Option<usize>,
+ pub country: Option<String>,
+ pub trailer: Option<String>,
+ pub homepage: Option<String>,
+ pub status: Option<String>,
+ pub rating: Option<f64>,
+ pub votes: Option<usize>,
+ pub comment_count: Option<usize>,
+ pub language: Option<String>,
+ pub available_translations: Option<Vec<String>>,
+ pub genres: Option<Vec<String>>,
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)]
pub struct TraktMediaObjectIds {
- trakt: u64,
- slug: String,
+ pub trakt: u64,
+ pub slug: Option<String>,
+ pub imdb: Option<String>,
+ pub tmdb: Option<u64>,
+ pub omdb: Option<u64>,
+ pub tvdb: Option<u64>,
+}
- imdb: Option<String>,
- tmdb: Option<u64>,
+impl Display for TraktSearchResult {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_fmt(format_args!(
+ "{}: {} ({}) \x1b[2m[{}]\x1b[0m",
+ self.r#type.to_string(),
+ self.inner.inner().title,
+ self.inner.inner().year.unwrap_or(0),
+ self.inner.inner().ids
+ ))
+ }
+}
+impl Display for TraktMediaObjectIds {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("trakt")?;
+ if self.slug.is_some() {
+ f.write_str(",slug")?;
+ }
+ if self.tmdb.is_some() {
+ f.write_str(",tmdb")?;
+ }
+ if self.imdb.is_some() {
+ f.write_str(",imdb")?;
+ }
+ if self.tvdb.is_some() {
+ f.write_str(",tvdb")?;
+ }
+ if self.omdb.is_some() {
+ f.write_str(",omdb")?;
+ }
+ Ok(())
+ }
}
diff --git a/server/src/routes/ui/node.rs b/server/src/routes/ui/node.rs
index 4b3f861..3820b14 100644
--- a/server/src/routes/ui/node.rs
+++ b/server/src/routes/ui/node.rs
@@ -207,6 +207,7 @@ markup::define! {
Rating::Metacritic => {p{ "Metacritic Score: " @value }}
Rating::Imdb => {p.rating{ "IMDb " @value }}
Rating::Tmdb => {p.rating{ "TMDB " @value }}
+ Rating::Trakt => {p.rating{ "Trakt " @value }}
}
}
@if let Some(f) = &node.federated {
diff --git a/tool/Cargo.toml b/tool/Cargo.toml
index 9f72df5..079cc1f 100644
--- a/tool/Cargo.toml
+++ b/tool/Cargo.toml
@@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
jellycommon = { path = "../common" }
jellybase = { path = "../base" }
+jellyimport = { path = "../import" }
jellyclient = { path = "../client" }
log = { workspace = true }
@@ -22,3 +23,5 @@ serde_yaml = "0.9.27"
bincode = { version = "2.0.0-rc.3", features = ["serde"] }
base64 = "0.21.5"
rand = "0.8.5"
+
+dialoguer = { version = "0.11.0", features = ["fuzzy-select"] }
diff --git a/tool/src/add.rs b/tool/src/add.rs
new file mode 100644
index 0000000..9cb1180
--- /dev/null
+++ b/tool/src/add.rs
@@ -0,0 +1,181 @@
+use std::{
+ fmt::Display,
+ path::{Path, PathBuf},
+};
+
+use crate::Action;
+use anyhow::{anyhow, bail, Context};
+use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, MultiSelect};
+use jellybase::{CONF, SECRETS};
+use jellycommon::{AssetLocation, ImportOptions, ImportSource, TraktKind};
+use jellyimport::trakt::Trakt;
+use tokio::{fs::File, io::AsyncWriteExt};
+
+pub(crate) async fn add(action: Action) -> anyhow::Result<()> {
+ match action {
+ Action::Add {
+ id,
+ media,
+ library_path,
+ } => {
+ let theme = ColorfulTheme::default();
+
+ let possible_kinds = [
+ TraktKind::Movie,
+ TraktKind::Season,
+ TraktKind::Show,
+ TraktKind::Episode,
+ ];
+ let trakt_kind: Vec<usize> = MultiSelect::with_theme(&theme)
+ .with_prompt("Media Kind")
+ .items(&possible_kinds)
+ .defaults(&[true, false, false, false])
+ .interact()
+ .unwrap();
+ let search_kinds = trakt_kind
+ .iter()
+ .map(|&i| possible_kinds[i])
+ .collect::<Vec<_>>();
+
+ let library_path = if let Some(library_path) = library_path {
+ library_path
+ } else {
+ let mut directories = Vec::new();
+ find_folders(&CONF.library_path, &PathBuf::new(), &mut directories)
+ .context("listing library directories")?;
+
+ let target_dir_index = FuzzySelect::with_theme(&theme)
+ .items(&directories)
+ .interact()
+ .unwrap();
+ directories[target_dir_index].0.clone()
+ };
+
+ let (last_search, trakt_object, trakt_kind) = loop {
+ let name: String = Input::with_theme(&theme)
+ .with_prompt("Search by title")
+ .default(media.as_ref().map(path_to_query).unwrap_or_default())
+ .interact_text()
+ .unwrap();
+
+ let trakt = Trakt::new(
+ SECRETS
+ .api
+ .trakt
+ .as_ref()
+ .ok_or(anyhow!("no trakt api key configured"))?,
+ );
+
+ let results = trakt.search(&search_kinds, &name, false).await?;
+
+ let correct = FuzzySelect::with_theme(&theme)
+ .items(&results)
+ .with_prompt("Metadata Source")
+ .interact_opt()
+ .unwrap();
+
+ if let Some(o) = correct {
+ break (name, results[o].inner.inner().to_owned(), results[o].r#type);
+ }
+ };
+
+ let id = id.unwrap_or_else(|| {
+ trakt_object.ids.slug.unwrap_or_else(|| {
+ let o: String = Input::with_theme(&theme)
+ .with_prompt("Node ID")
+ .validate_with(validate_id)
+ .default(make_id(&last_search))
+ .interact_text()
+ .unwrap();
+ o
+ })
+ });
+
+ let mut sources = Vec::new();
+ sources.push(ImportSource::Trakt {
+ id: trakt_object.ids.trakt,
+ kind: trakt_kind,
+ });
+ if let Some(media) = media {
+ sources.push(ImportSource::Media {
+ location: AssetLocation::Media(media),
+ ignore_metadata: true,
+ ignore_attachments: false,
+ ignore_chapters: false,
+ })
+ }
+
+ let impo = ImportOptions { id, sources };
+
+ let ypath = CONF
+ .library_path
+ .join(library_path)
+ .join(&impo.id)
+ .with_extension("yaml");
+
+ if Confirm::with_theme(&theme)
+ .with_prompt(format!("Write {:?}?", ypath))
+ .interact()
+ .unwrap()
+ {
+ File::create(ypath)
+ .await?
+ .write_all(serde_yaml::to_string(&impo)?.as_bytes())
+ .await?;
+ }
+
+ Ok(())
+ }
+ _ => unreachable!(),
+ }
+}
+
+fn validate_id(s: &String) -> anyhow::Result<()> {
+ if &make_id(&s) == s {
+ Ok(())
+ } else {
+ bail!("invalid id")
+ }
+}
+fn make_id(s: &str) -> String {
+ let mut out = String::new();
+ for s in s.chars() {
+ match s {
+ 'a'..='z' | '0'..='9' => out.push(s),
+ 'A'..='Z' => out.push(s.to_ascii_lowercase()),
+ '-' | ' ' | '_' | ':' => out.push('-'),
+ _ => (),
+ }
+ }
+ out
+}
+
+fn path_to_query(path: &PathBuf) -> String {
+ path.file_stem()
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string()
+ .replace("-", " ")
+ .replace(".", " ")
+}
+
+fn find_folders(base: &Path, path: &Path, out: &mut Vec<PathDisplay>) -> anyhow::Result<()> {
+ out.push(PathDisplay(path.to_owned()));
+ for entry in base.join(path).read_dir()? {
+ let entry = entry?;
+ let child_path = path.join(entry.file_name());
+ if entry.path().is_dir() {
+ find_folders(base, &child_path, out)?;
+ }
+ }
+ Ok(())
+}
+
+pub struct PathDisplay(PathBuf);
+impl Display for PathDisplay {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("/")?;
+ f.write_str(self.0.to_str().unwrap())
+ }
+}
diff --git a/tool/src/main.rs b/tool/src/main.rs
index 58559dc..68513d1 100644
--- a/tool/src/main.rs
+++ b/tool/src/main.rs
@@ -3,14 +3,17 @@
which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
Copyright (C) 2023 metamuffin <metamuffin.org>
*/
+
+pub mod add;
pub mod migrate;
+use add::add;
use anyhow::anyhow;
use clap::{Parser, Subcommand, ValueEnum};
use jellybase::{CONF, SECRETS};
use jellyclient::Instance;
use jellycommon::user::CreateSessionParams;
-use log::{error, info};
+use log::info;
use migrate::migrate;
use std::{fmt::Debug, path::PathBuf};
@@ -25,14 +28,13 @@ struct Args {
#[derive(Subcommand)]
enum Action {
- /// Initialize a new jellything instance
- Init {
- /// Base path of the instance, must either be absolute or relative to the servers pwd
- base_path: PathBuf,
+ Add {
+ #[arg(short, long)]
+ id: Option<String>,
#[arg(short, long)]
- brand: String,
+ media: Option<PathBuf>,
#[arg(short, long)]
- hostname: String,
+ library_path: Option<PathBuf>,
},
Migrate {
database: PathBuf,
@@ -63,50 +65,11 @@ fn main() -> anyhow::Result<()> {
let args = Args::parse();
match args.action {
- Action::Init { .. } => {
- // info!("creating new instance...");
- // std::fs::create_dir_all(path.join("library"))?;
- // std::fs::create_dir_all(path.join("cache"))?;
- // std::fs::create_dir_all(path.join("assets"))?;
- // std::fs::create_dir_all(path.join("media"))?;
- // File::create_new(path.join("assets/front.htm"))?
- // .write_fmt(format_args!("<h1>My very own jellything instance</h1>"))?;
-
- // // TODO: dont fill that
- // serde_yaml::to_writer(
- // File::create_new(path.join("config.yaml"))?,
- // &GlobalConfig {
- // brand: brand.clone(),
- // hostname,
- // slogan: "Creative slogan here".to_string(),
- // asset_path: path.join("assets"),
- // cache_path: path.join("cache"),
- // library_path: path.join("library"),
- // database_path: path.join("database"),
- // temp_path: "/tmp".into(),
-
- // login_expire: 10,
- // ..Default::default()
- // },
- // )?;
- // serde_json::to_writer(
- // File::create_new(path.join("library/directory.json"))?,
- // &Node {
- // public: NodePublic {
- // kind: Some(NodeKind::Collection),
- // title: Some("My Library".to_string()),
- // ..Default::default()
- // },
- // private: NodePrivate {
- // ..Default::default()
- // },
- // },
- // )?;
- // info!("{brand:?} is ready!");
- // warn!("please add an admin password to login.");
- error!("init is currently disabled");
- Ok(())
- }
+ a @ Action::Add { .. } => tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .unwrap()
+ .block_on(add(a)),
a @ Action::Migrate { .. } => migrate(a),
Action::Reimport { hostname, no_tls } => tokio::runtime::Builder::new_multi_thread()
.enable_all()
@@ -135,13 +98,3 @@ fn main() -> anyhow::Result<()> {
}),
}
}
-
-// fn ok_or_warn<T, E: Debug>(r: Result<T, E>) -> Option<T> {
-// match r {
-// Ok(t) => Some(t),
-// Err(e) => {
-// warn!("{e:?}");
-// None
-// }
-// }
-// }