From a8fe841aaefe904121d936e608572a1422191167 Mon Sep 17 00:00:00 2001 From: metamuffin Date: Sun, 21 Jan 2024 19:29:17 +0100 Subject: trakt import --- Cargo.lock | 31 +++++ base/src/cache.rs | 66 ++++++++- base/src/lib.rs | 10 +- common/src/config.rs | 2 + common/src/lib.rs | 52 +++++++ import/src/lib.rs | 69 +++++++--- import/src/mod.rs | 319 ------------------------------------------- import/src/trakt.rs | 191 +++++++++++++++++--------- server/src/routes/ui/node.rs | 1 + tool/Cargo.toml | 3 + tool/src/add.rs | 181 ++++++++++++++++++++++++ tool/src/main.rs | 75 ++-------- 12 files changed, 537 insertions(+), 463 deletions(-) delete mode 100644 import/src/mod.rs create mode 100644 tool/src/add.rs diff --git a/Cargo.lock b/Cargo.lock index 7f9c23f..d9e9955 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -704,6 +704,20 @@ dependencies = [ "syn 2.0.48", ] +[[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" @@ -962,6 +976,15 @@ dependencies = [ "slab", ] +[[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" @@ -1462,11 +1485,13 @@ dependencies = [ "base64", "bincode", "clap", + "dialoguer", "env_logger", "indicatif", "jellybase", "jellyclient", "jellycommon", + "jellyimport", "log", "rand 0.8.5", "reqwest", @@ -2737,6 +2762,12 @@ dependencies = [ "lazy_static", ] +[[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" 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( + seed: &[&str], + mut generate: Fun, +) -> Result, anyhow::Error> +where + Fun: FnMut() -> Fut, + Fut: Future>, + 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::() + .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::(&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 = 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, + pub tvdb: Option, pub imdb: Option, pub omdb: Option, pub fanart_tv: Option, 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, }, @@ -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 = LazyLock::new(|| Semaphore::new(1)); +struct Apis { + trakt: Option, +} + 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, 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 -*/ -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::() - ); - } - let res_index = if results.results.len() > 1 { - stdin() - .lines() - .next() - .unwrap() - .unwrap() - .parse::() - .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::(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> { - let res = self - .client - .get(format!( - "https://api.trakt.tv/search/{}?query={}{}", - types - .iter() - .map(|t| serde_json::to_string(t).unwrap()) - .collect::>() - .join(","), - urlencoding::encode(query), - optext(extended) - )) - .send() - .await?; - Ok(res.json().await?) + ) -> anyhow::Result>> { + 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::>() + .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> { + 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, - ids: TraktMediaObjectIds, + pub title: String, + pub year: Option, + pub ids: TraktMediaObjectIds, + + pub tagline: Option, + pub overview: Option, + pub released: Option, + pub runtime: Option, + pub country: Option, + pub trailer: Option, + pub homepage: Option, + pub status: Option, + pub rating: Option, + pub votes: Option, + pub comment_count: Option, + pub language: Option, + pub available_translations: Option>, + pub genres: Option>, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Encode, Decode, Clone)] pub struct TraktMediaObjectIds { - trakt: u64, - slug: String, + pub trakt: u64, + pub slug: Option, + pub imdb: Option, + pub tmdb: Option, + pub omdb: Option, + pub tvdb: Option, +} - imdb: Option, - tmdb: Option, +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 = 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::>(); + + 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) -> 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 */ + +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, #[arg(short, long)] - brand: String, + media: Option, #[arg(short, long)] - hostname: String, + library_path: Option, }, 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!("

My very own jellything instance

"))?; - - // // 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(r: Result) -> Option { -// match r { -// Ok(t) => Some(t), -// Err(e) => { -// warn!("{e:?}"); -// None -// } -// } -// } -- cgit v1.2.3-70-g09d2