#![allow(dead_code)]
use std::{
env, fmt,
fs::{self, File},
io::{Read, Write},
path::{Path, PathBuf},
process::{Command, Output},
thread,
};
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Deserializer};
use uuid::Uuid;
#[derive(Default, Debug)]
pub(crate) struct MetaData {
pub(crate) title: String,
pub(crate) year: u32,
pub(crate) description: Option<String>,
pub(crate) genre: Option<String>,
pub(crate) rating: Option<String>,
pub(crate) runtime: u32,
pub(crate) cast: Vec<CastMember>,
pub(crate) artwork: Option<PathBuf>,
pub(crate) tmdb_id: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct Genre {
name: String,
}
#[derive(Debug, Deserialize)]
struct Credits {
cast: Vec<CastMember>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct CastMember {
pub(crate) name: String,
}
#[derive(Debug, Deserialize)]
struct TMDBMovie {
credits: Credits,
genres: Vec<Genre>,
id: u32,
overview: String,
popularity: f32,
poster_path: Option<String>,
#[serde(rename = "release_date")]
#[serde(deserialize_with = "year_from_release_date")]
year: u32,
releases: Releases,
runtime: u32,
title: String,
downloaded_artwork: Option<PathBuf>,
}
#[derive(Debug, Deserialize)]
struct Releases {
countries: Vec<Country>,
}
#[derive(Debug, Deserialize)]
struct Country {
certification: String,
iso_3166_1: String,
}
#[derive(Debug, Deserialize)]
struct TMDBMovieSearchResponse {
results: Vec<TMDBMovieSearchResult>,
}
#[derive(Debug, Deserialize, PartialEq)]
struct TMDBMovieSearchResult {
id: u32,
overview: String,
popularity: f32,
release_date: String,
title: String,
}
fn year_from_release_date<'de, D>(
deserializer: D,
) -> std::result::Result<u32, D::Error>
where
D: Deserializer<'de>,
{
let input: String = Deserialize::deserialize(deserializer)?;
if let Some(first) = input.split('-').next() {
first.parse::<u32>().map_err(serde::de::Error::custom)
} else {
Err(serde::de::Error::custom(format!(
"unrecognized release_date format: {input}",
)))
}
}
impl PartialOrd for TMDBMovieSearchResult {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Eq for TMDBMovieSearchResult {}
impl Ord for TMDBMovieSearchResult {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other
.popularity
.partial_cmp(&self.popularity)
.unwrap_or_else(|| panic!("Comparison failed for {:?}", self))
}
}
#[derive(Clone)]
struct QueryBuilder {
api_key: String,
base_url: &'static str,
}
impl QueryBuilder {
fn new() -> Result<Self> {
const ENVVAR: &str = "TMDB_API_KEY";
let api_key = std::env::var(ENVVAR)?;
Ok(Self {
api_key,
base_url: "https://api.themoviedb.org/3",
})
}
fn search_tmdb(
&self,
title: impl AsRef<str>,
) -> Result<Vec<TMDBMovieSearchResult>> {
let req = self
.make_request("/search/movie")
.query("query", title.as_ref());
let resp = req.call()?;
let json: TMDBMovieSearchResponse = resp.into_json()?;
let mut results = json.results;
results.sort();
Ok(results)
}
fn fetch_from_tmdb(&self, id: u32) -> Result<TMDBMovie> {
let body_handle = thread::spawn({
let qb = self.clone();
move || {
qb.make_request(format!("/movie/{id}"))
.query("append_to_response", "credits,images,releases")
.call()
}
});
let images_base_handle = thread::spawn({
let qb = self.clone();
move || qb.fetch_tmdb_images_base()
});
let mut tmdbmovie: TMDBMovie = body_handle
.join()
.expect("Unable to join json thread")?
.into_json()?;
let poster_path = &tmdbmovie.poster_path;
if let Some(pp) = poster_path {
let images_base = images_base_handle
.join()
.expect("Unable to join images_base thread")?;
let image_url = images_base + "original" + pp;
let image = ureq::get(&image_url).call()?;
let len = image
.header("Content-Length")
.ok_or_else(|| anyhow!("No content length"))
.and_then(|s| Ok(s.parse::<usize>()?))?;
let mut bytes: Vec<u8> = Vec::with_capacity(len);
image
.into_reader()
.take(10_000_000)
.read_to_end(&mut bytes)?;
if !bytes.is_empty() {
let uuid = Uuid::new_v4().to_string();
let dest = env::temp_dir().join("autorip").join(uuid);
fs::create_dir_all(&dest)?;
let imagefile = dest.join(pp.trim_start_matches('/'));
let mut buf = File::create(&imagefile)?;
buf.write_all(&bytes)?;
tmdbmovie.downloaded_artwork = Some(imagefile);
}
}
Ok(tmdbmovie)
}
fn make_request(&self, path: impl AsRef<str>) -> ureq::Request {
let url = self.base_url.to_owned() + path.as_ref();
ureq::get(url.as_ref()).query("api_key", &self.api_key)
}
fn fetch_tmdb_images_base(&self) -> Result<String> {
Ok(self
.make_request("/configuration")
.call()
.with_context(|| "failed to request tmdb configuration")?
.into_json::<serde_json::Value>()?
.get("images")
.and_then(|v| v.get("secure_base_url"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
anyhow!("Could not get secure_base_url from TMDB API")
})?
.into())
}
}
impl From<TMDBMovie> for MetaData {
fn from(movie: TMDBMovie) -> Self {
Self {
title: movie.title,
year: movie.year,
description: Some(movie.overview),
genre: movie.genres.first().map(|g| g.name.clone()),
runtime: movie.runtime,
rating: movie.releases.countries.iter().find_map(|release| {
if release.iso_3166_1 == "US" {
Some(release.certification.clone())
} else {
None
}
}),
cast: movie.credits.cast,
artwork: movie.downloaded_artwork,
tmdb_id: Some(movie.id),
}
}
}
impl MetaData {
pub(crate) fn guess_from_title(title: impl AsRef<str>) -> Result<Self> {
let qb = QueryBuilder::new()?;
let id = if let Some(result) = qb.search_tmdb(title.as_ref())?.first()
{
result.id
} else {
bail!(
"Could not find a suitable movie ID. Please specify one \
manually."
)
};
Ok(qb.fetch_from_tmdb(id)?.into())
}
pub(crate) fn from_id(id: u32) -> Result<Self> {
Ok(QueryBuilder::new()?.fetch_from_tmdb(id)?.into())
}
pub fn search_by_title(
title: impl AsRef<str>,
count: usize,
) -> Result<impl Iterator<Item = impl fmt::Display>> {
let qb = QueryBuilder::new()?;
let results = qb.search_tmdb(title.as_ref())?;
Ok(results.into_iter().take(count))
}
}
pub(crate) fn set_metadata(
file: impl AsRef<Path>,
md: &MetaData,
) -> Result<Output> {
log::info!("Setting metadata on {:?}: {:?}", file.as_ref(), &md);
let desc = md.description.as_deref().unwrap_or_default();
let ap = || {
let mut cmd = Command::new("atomicparsley");
cmd.arg(file.as_ref());
cmd
};
ap().arg(file.as_ref())
.args("--metaEnema --overWrite".split_whitespace())
.status()?;
ap().arg(file.as_ref())
.args("--artwork REMOVE_ALL --overWrite".split_whitespace())
.status()?;
let mut cmd = ap();
cmd.arg("--description")
.arg(desc)
.arg("--title")
.arg(&md.title)
.arg("--year")
.arg(md.year.to_string())
.arg("--genre")
.arg(md.genre.as_deref().unwrap_or_default())
.arg("--longdesc")
.arg(desc)
.arg("--stik=Movie")
.arg("--Rating")
.arg("--artist")
.arg(
md.cast
.iter()
.map(|c| c.name.clone())
.collect::<Vec<_>>()
.join(", "),
);
if let Some(artwork_path) = &md.artwork {
cmd.arg("--artwork").arg(artwork_path);
};
Ok(cmd.arg("--overWrite").output()?)
}
impl fmt::Display for MetaData {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "tmdb id {}: {}", self.tmdb_id.unwrap(), self.title)?;
writeln!(f, "year: {}", self.year)?;
if let Some(ref g) = self.genre {
writeln!(f, "genre: {g}")?;
};
if let Some(ref r) = self.rating {
writeln!(f, "rating: {r}")?;
};
if let Some(ref d) = self.description {
writeln!(f, "description: {d}")?;
}
if !self.cast.is_empty() {
writeln!(
f,
"cast: {}",
self.cast
.iter()
.map(|c| c.name.clone())
.collect::<Vec<_>>()
.join(", ")
)?;
}
Ok(())
}
}
impl fmt::Display for TMDBMovieSearchResult {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
writeln!(f, "tmdb id {}: {}", self.id, self.title)?;
writeln!(f, "release_date: {}", self.release_date)?;
writeln!(f, "overview: {}", self.overview)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::{env, fs, path::PathBuf};
use uuid::Uuid;
use super::*;
use crate::get_stdout;
fn read_metadata(file: impl AsRef<Path>) -> Result<String> {
get_stdout(
Command::new("atomicparsley")
.arg(file.as_ref())
.arg("--textdata")
.output()?,
)
}
#[test]
fn sets_metadata() -> Result<()> {
let md = MetaData::from_id(218)?;
let testfile: PathBuf = "tests/files/video1.mp4".into();
let uuid = Uuid::new_v4().to_string();
let destdir = env::temp_dir().join("autorip").join(uuid);
fs::create_dir_all(&destdir)?;
let dest = destdir.join("video.mp4");
fs::copy(testfile, &dest)?;
let before = read_metadata(&dest)?;
set_metadata(&dest, &md)?;
let after = read_metadata(&dest)?;
assert_ne!(before, after);
Ok(())
}
}