/* 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 anyhow::Context; use clap::{Parser, Subcommand}; use infojson::YVideo; use jellycommon::{ 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 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 { New { path: PathBuf, #[arg(short = 't', long)] tmdb_search: Option, #[arg(short = 'T', long)] tmdb_id: Option, #[arg(long)] copy: bool, #[arg(long)] r#move: bool, #[arg(long)] video: bool, #[arg(short, long)] input: Option, #[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::New { path, tmdb_id, tmdb_search, input, series, copy, video, r#move, } => { 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 = make_ident(&title); 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| Rating::YoutubeLikes(i.like_count)), ); let node = Node { private: NodePrivate { 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".to_string() } else { format!("{ident}.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 }