/* 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 */ #![feature(file_create_new)] pub mod infojson; pub mod tmdb; use anyhow::Context; use base64::Engine; use clap::{Parser, Subcommand}; use infojson::YVideo; use jellycommon::{ config::GlobalConfig, AssetLocation, LocalTrack, MediaInfo, MediaSource, Node, NodeKind, NodePrivate, NodePublic, Rating, }; use jellymatroska::read::EbmlReader; use jellyremuxer::import::{import_metadata, seek_index}; use log::{info, warn}; use rand::random; use std::{ fs::{remove_file, File}, io::{stdin, Write}, path::PathBuf, process::exit, }; use tmdb::{tmdb_details, tmdb_image, tmdb_search}; #[derive(Parser)] struct Args { #[arg(short = 'N', long)] dry: bool, #[clap(subcommand)] action: Action, } #[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, #[arg(short, long)] brand: String, }, /// Imports a movie, video or series given media and metadata sources New { /// Relative path to the node's parent(!). path: PathBuf, /// Search the node by title on TMDB #[arg(short = 't', long)] tmdb_search: Option, /// Search the node by id on TMDB #[arg(short = 'T', long)] tmdb_id: Option, #[arg(long)] /// Prefix the inferred id with something to avoid collisions ident_prefix: Option, /// Copies media into the library #[arg(long)] copy: bool, /// Moves media into the library (potentially destructive operation) #[arg(long)] r#move: bool, /// Marks node as a video #[arg(long)] video: bool, /// Path to the media of the node, required for non-series #[arg(short, long)] input: Option, /// Marks node as a series #[arg(short, long)] series: bool, }, } fn main() -> anyhow::Result<()> { env_logger::builder() .filter_level(log::LevelFilter::Info) .parse_env("LOG") .init(); let args = Args::parse(); match args.action { Action::Init { base_path: path, brand, } => { 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"))?; File::create_new(path.join("assets/front.htm"))? .write_fmt(format_args!("

My very own jellything instance

"))?; serde_json::to_writer( File::create_new(path.join("config.json"))?, &GlobalConfig { brand: brand.clone(), 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"), cookie_key: Some( base64::engine::general_purpose::STANDARD .encode([(); 32].map(|_| random())), ), session_key: Some( base64::engine::general_purpose::STANDARD .encode([(); 32].map(|_| random())), ), admin_username: "admin".to_string(), admin_password: "hackme".to_string(), login_expire: 10, ..Default::default() }, )?; serde_json::to_writer( File::create_new(path.join("library/directory.json"))?, &Node { public: NodePublic { kind: NodeKind::Collection, title: "My Library".to_string(), ..Default::default() }, private: NodePrivate { ..Default::default() }, }, )?; info!("{brand:?} is ready!"); warn!("please change the admin password."); Ok(()) } Action::New { path, tmdb_id, tmdb_search, input, series, ident_prefix, copy, video, r#move, } => { 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.") } 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 = crate::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(); if td.title.is_some() { info!("is this correct? [y/n]"); if stdin().lines().next().unwrap().unwrap() != "y" { exit(0) } } 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 = File::open(&input_path).unwrap(); let mut input = EbmlReader::new(input); import_metadata(&mut input)? }); 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 = file_meta .as_ref() .map(|m| m.title.clone()) .flatten() .or(tmdb_details .as_ref() .map(|d| d.title.clone().or(d.name.clone())) .flatten()) .unwrap(); 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 !args.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" } } )); 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 = Vec::new(); ratings.extend( infojson .as_ref() .map(|i| Rating::YoutubeViews(i.view_count)), ); ratings.extend( infojson .as_ref() .map(|i| i.like_count.map(Rating::YoutubeLikes)) .flatten(), ); let node = Node { private: NodePrivate { id: Some(ident.clone()), import: None, 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 { parent: None, 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, index: None, kind, children: Vec::new(), media: file_meta.as_ref().map(|m| MediaInfo { duration: m.duration, tracks: m.tracks.clone(), }), }, }; if args.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)?; } seek_index::write_all(&source_path)?; } let f = File::create(path.join(if series { "directory.json" } else { "item.jelly" }))?; serde_json::to_writer_pretty(f, &node)?; } Ok(()) } } } 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 }