From 72baadbec79c24370c70f41e6f880a2efad97891 Mon Sep 17 00:00:00 2001 From: Andreas Mieke Date: Tue, 7 Nov 2023 18:41:00 +0100 Subject: [PATCH] feat(shows): Implement show matcher Now implemented show matcher, and actual file mover --- Cargo.lock | 39 ++++++++ Cargo.toml | 1 + src/config.rs | 2 +- src/directory.rs | 14 ++- src/main.rs | 56 +++++++++-- src/media.rs | 71 +++++++++++++ src/movie.rs | 141 +++++++++++--------------- src/show.rs | 256 +++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 482 insertions(+), 98 deletions(-) create mode 100644 src/media.rs create mode 100644 src/show.rs diff --git a/Cargo.lock b/Cargo.lock index 1c2befc..c9f3794 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -830,6 +839,7 @@ dependencies = [ "inline_colorization", "inquire", "log", + "regex", "reqwest", "sanitise-file-name", "serde", @@ -866,6 +876,35 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + [[package]] name = "reqwest" version = "0.11.22" diff --git a/Cargo.toml b/Cargo.toml index 414d857..06da5d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ infer = "0.15.0" inline_colorization = "0.1.6" inquire = "0.6.2" log = "0.4.20" +regex = "1.10.2" reqwest = { version = "0.11.22", features = ["json", "blocking"] } sanitise-file-name = "1.0.0" serde = { version = "1.0.190", features = ["derive"] } diff --git a/src/config.rs b/src/config.rs index 5348a19..c4e2c70 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,7 +37,7 @@ pub fn load(path: &PathBuf, first: bool) -> Result> { } pub fn first_run() -> Result> { - let tmdb_key = Text::new("Enter your TMDB API Key:") + let tmdb_key = Text::new("Enter your TMDB API Read Access Token:") .with_help_message("The API key can be found at https://www.themoviedb.org/settings/api (you must be logged in).") .prompt(); diff --git a/src/directory.rs b/src/directory.rs index 16470c6..5abf2ef 100644 --- a/src/directory.rs +++ b/src/directory.rs @@ -2,7 +2,7 @@ use std::{path::PathBuf, fs::{self, DirEntry}, error::Error}; use log::trace; -use crate::{movie::{handle_movie_files_and_folders, self, Move}, config::Config}; +use crate::{movie::handle_movie_files_and_folders, config::Config, media::Move, show::handle_show_files_and_folders}; /*fn is_not_hidden(entry: &DirEntry) -> bool { entry @@ -22,8 +22,8 @@ pub fn walk_path(path: PathBuf) -> Vec { entries }*/ -pub fn search_path(path: PathBuf, cfg: Config) -> Result, Box> { - let entries = fs::read_dir(path)?; +pub fn search_path(path: PathBuf, cfg: Config, shows: bool) -> Result, Box> { + let entries = fs::read_dir(path.clone())?; let mut files: Vec = Vec::new(); let mut folders: Vec = Vec::new(); @@ -45,8 +45,12 @@ pub fn search_path(path: PathBuf, cfg: Config) -> Result, Box = Vec::new(); - moves.append(&mut handle_movie_files_and_folders(files, folders, cfg.clone())); + let mut moves: Vec = Vec::new(); + if shows { + moves.append(&mut handle_show_files_and_folders(path, files, folders, cfg.clone())); + } else { + moves.append(&mut handle_movie_files_and_folders(files, folders, cfg.clone())); + } Ok(moves) } diff --git a/src/main.rs b/src/main.rs index c14447e..e77ba56 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,13 @@ mod config; mod directory; mod movie; +mod show; +mod media; use log::*; use clap::Parser; -use std::{path::PathBuf, env}; +use std::{path::PathBuf, env, fs}; +use inline_colorization::*; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -22,9 +25,13 @@ struct Args { first_run: bool, /// Move files rather than copying them - #[arg(short, long, name="move")] + #[arg(short, long="move")] moov: bool, + /// Output moves/copies instead of actually doing them + #[arg(short, long)] + dry_run: bool, + /// Look for shows instead of movies #[arg(short, long)] shows: bool, @@ -71,15 +78,48 @@ fn main() { args.path.unwrap() }; - //let files = directory::walk_path(search_path); - let moves = directory::search_path(search_path, cfg).unwrap(); - + let moves = directory::search_path(search_path, cfg, args.shows).unwrap(); for move_file in moves { - info!("Moving: {:#?}: {:#?}", args.moov, move_file); - _ = move_file.from; - _ = move_file.to; + if args.moov { + // Move files instead of copying + println!("Moving {style_bold}{color_red}{}{color_reset}{style_reset} -> {style_bold}{color_green}{}{color_reset}{style_reset}", move_file.from.display(), move_file.to.display()); + if args.dry_run { + continue; + } + fs::create_dir_all(move_file.to.parent().unwrap()).unwrap(); + match fs::rename(&move_file.from, &move_file.to) { + Ok(_) => continue, + Err(e) => { + warn!("Can not rename, error {:#?}, copying and deleting instead", e); + match fs::copy(&move_file.from, &move_file.to) { + Ok(_) => _ = fs::remove_file(&move_file.from), + Err(e) => { + error!("Copy also failed with error {:#?}", e); + continue; + } + } + } + } + } else { + // Copy files + println!("Copying {style_bold}{color_red}{}{color_reset}{style_reset} -> {style_bold}{color_green}{}{color_reset}{style_reset}", move_file.from.display(), move_file.to.display()); + if args.dry_run { + continue; + } + fs::create_dir_all(move_file.to.parent().unwrap()).unwrap(); + match fs::copy(&move_file.from, &move_file.to) { + Ok(_) => _ = (), + Err(e) => { + error!("Copy failed with error {:#?}", e); + continue; + } + } + } } + + //let files = directory::walk_path(search_path); + /*for file in files.clone() { info!("Found: {}", file.to_str().unwrap()); }*/ diff --git a/src/media.rs b/src/media.rs new file mode 100644 index 0000000..5cc7111 --- /dev/null +++ b/src/media.rs @@ -0,0 +1,71 @@ +use std::{path::PathBuf, error::Error, fs::File, cmp, io::Read}; + +use log::trace; + +#[derive(Debug, Clone)] +pub struct Move { + pub from: PathBuf, + pub to: PathBuf +} + +pub fn get_file_header(path: PathBuf) -> Result, Box> { + let f = File::open(path)?; + + let limit = f + .metadata() + .map(|m| cmp::min(m.len(), 8192) as usize + 1) + .unwrap_or(0); + let mut bytes = Vec::with_capacity(limit); + f.take(8192).read_to_end(&mut bytes)?; + Ok(bytes) +} + +fn token_valid(t: &&str) -> bool { + if + t.eq_ignore_ascii_case("dvd") || + t.eq_ignore_ascii_case("bluray") || + t.eq_ignore_ascii_case("webrip") || + t.eq_ignore_ascii_case("youtube") || + t.eq_ignore_ascii_case("download") || + t.eq_ignore_ascii_case("web") || + t.eq_ignore_ascii_case("uhd") || + t.eq_ignore_ascii_case("hd") || + t.eq_ignore_ascii_case("tv") || + t.eq_ignore_ascii_case("tvrip") || + t.eq_ignore_ascii_case("1080p") || + t.eq_ignore_ascii_case("1080i") || + t.eq_ignore_ascii_case("2160p") || + t.eq_ignore_ascii_case("x264") || + t.eq_ignore_ascii_case("x265") || + t.eq_ignore_ascii_case("h265") || + t.eq_ignore_ascii_case("dts") || + t.eq_ignore_ascii_case("hevc") || + t.eq_ignore_ascii_case("10bit") || + t.eq_ignore_ascii_case("12bit") || + t.eq_ignore_ascii_case("hdr") || + t.eq_ignore_ascii_case("xvid") || + t.eq_ignore_ascii_case("AAC5") || + t.eq_ignore_ascii_case("AAC") || + t.eq_ignore_ascii_case("AC3") || + t.eq_ignore_ascii_case("remux") || + t.eq_ignore_ascii_case("atmos") || + t.eq_ignore_ascii_case("pdtv") || + t.eq_ignore_ascii_case("td") || + t.eq_ignore_ascii_case("internal") || + t.eq_ignore_ascii_case("ma") || + t.eq_ignore_ascii_case("sample") || // This just removes the word sample, maybe we want to ban files with the word sample all together + (t.starts_with('[') || t.ends_with(']')) || + (t.starts_with('(') || t.ends_with(')')) || + (t.starts_with('{') || t.ends_with('}')) || + (t.starts_with(['s','S']) && t.len() == 3 && t.chars().next().map(char::is_numeric).unwrap_or(false)) // Season specifier + { + return false; + } + true +} + +pub fn tokenize_media_name(file_name: String) -> Vec { + let tokens: Vec = file_name.split(&['-', ' ', ':', '@', '.'][..]).filter(|t| token_valid(t)).map(String::from).collect(); + trace!("Tokens are: {:#?}", tokens); + tokens +} \ No newline at end of file diff --git a/src/movie.rs b/src/movie.rs index 10a5485..9a088ea 100644 --- a/src/movie.rs +++ b/src/movie.rs @@ -1,4 +1,4 @@ -use std::{path::PathBuf, error::Error, io::Read, fs::{File, DirEntry}, cmp, fmt}; +use std::{path::PathBuf, fs::DirEntry, fmt, time::Duration}; use infer; use inquire::{Select, Text, Confirm}; use log::{info, warn, error, trace, debug}; @@ -9,7 +9,7 @@ use inline_colorization::*; use sanitise_file_name::sanitise; use walkdir::WalkDir; -use crate::{config::Config, directory::search_path}; +use crate::{config::Config, directory::search_path, media::{self, Move, get_file_header}}; #[derive(Deserialize, Debug)] struct TMDBResponse { @@ -31,74 +31,7 @@ impl fmt::Display for TMDBEntry { } } -#[derive(Debug)] -pub struct Move { - pub from: PathBuf, - pub to: PathBuf -} - -fn get_file_header(path: PathBuf) -> Result, Box> { - let f = File::open(path)?; - - let limit = f - .metadata() - .map(|m| cmp::min(m.len(), 8192) as usize + 1) - .unwrap_or(0); - let mut bytes = Vec::with_capacity(limit); - f.take(8192).read_to_end(&mut bytes)?; - Ok(bytes) -} - -fn token_valid(t: &&str) -> bool { - if - t.eq_ignore_ascii_case("dvd") || - t.eq_ignore_ascii_case("bluray") || - t.eq_ignore_ascii_case("webrip") || - t.eq_ignore_ascii_case("youtube") || - t.eq_ignore_ascii_case("download") || - t.eq_ignore_ascii_case("web") || - t.eq_ignore_ascii_case("uhd") || - t.eq_ignore_ascii_case("hd") || - t.eq_ignore_ascii_case("tv") || - t.eq_ignore_ascii_case("tvrip") || - t.eq_ignore_ascii_case("1080p") || - t.eq_ignore_ascii_case("1080i") || - t.eq_ignore_ascii_case("2160p") || - t.eq_ignore_ascii_case("x264") || - t.eq_ignore_ascii_case("x265") || - t.eq_ignore_ascii_case("h265") || - t.eq_ignore_ascii_case("dts") || - t.eq_ignore_ascii_case("hevc") || - t.eq_ignore_ascii_case("10bit") || - t.eq_ignore_ascii_case("12bit") || - t.eq_ignore_ascii_case("hdr") || - t.eq_ignore_ascii_case("xvid") || - t.eq_ignore_ascii_case("AAC5") || - t.eq_ignore_ascii_case("AAC") || - t.eq_ignore_ascii_case("AC3") || - t.eq_ignore_ascii_case("remux") || - t.eq_ignore_ascii_case("atmos") || - t.eq_ignore_ascii_case("ma") || - t.eq_ignore_ascii_case("sample") || // This just removes the word sample, maybe we want to ban files with the word sample all together - (t.starts_with('[') || t.ends_with(']')) || - (t.starts_with('(') || t.ends_with(')')) || - (t.starts_with('{') || t.ends_with('}')) - { - return false; - } - true -} - -fn tokenize_media_name(file_name: String) -> Vec { - let mut tokens: Vec = file_name.split(&['-', ' ', ':', '@', '.'][..]).filter(|t| token_valid(t)).map(String::from).collect(); - trace!("Tokens are: {:#?}", tokens); - - // Remove last token (file ext) - _ = tokens.pop(); - tokens -} - -fn lookup_media(file_name: PathBuf, mut name_tokens: Vec, cfg: Config) -> Option { +fn lookup_movie(file_name: PathBuf, mut name_tokens: Vec, cfg: Config) -> Option { let mut h = HeaderMap::new(); h.insert("Accept", HeaderValue::from_static("application/json")); h.insert("Authorization", HeaderValue::from_str(format!("Bearer {}", cfg.tmdb_key).as_str()).unwrap()); @@ -119,9 +52,15 @@ fn lookup_media(file_name: PathBuf, mut name_tokens: Vec, cfg: Config) - let http_response = client .get(format!("https://api.themoviedb.org/3/search/movie?query={}&include_adult=false&language=en-US&page=1", encode(name.as_str()).into_owned())) - .send().unwrap(); + .timeout(Duration::from_secs(120)) + .send(); - response = http_response.json::().unwrap(); + if http_response.is_err() { + warn!("Request error: {:#?}", http_response.unwrap_err()); + return None; + } + + response = http_response.unwrap().json::().unwrap(); trace!("TMDB Reponse: {:#?}", response); if response.total_results == 0 { @@ -133,7 +72,7 @@ fn lookup_media(file_name: PathBuf, mut name_tokens: Vec, cfg: Config) - let options = response.results; - let ans = Select::new(format!("Select movie or show that matches the file {style_bold}{}{style_reset}:", file_name.display()).as_str(), options).prompt(); + let ans = Select::new(format!("Select movie that matches the file {style_bold}{}{style_reset}:", file_name.display()).as_str(), options).prompt(); match ans { Ok(choice) => { debug!("Selected: {:#?}", choice); @@ -146,21 +85,27 @@ fn lookup_media(file_name: PathBuf, mut name_tokens: Vec, cfg: Config) - } } -fn video_file_handler(entry: PathBuf, cfg: Config) -> Option { - info!("Found video file: {:#?}", entry); +fn movie_video_file_handler(entry: PathBuf, cfg: Config) -> Option { + info!("Found movie video file: {:#?}", entry); let file_name = entry.file_name().unwrap_or_default(); trace!("File name is: {:#?}", file_name); - let name_tokens = tokenize_media_name(file_name.to_str().unwrap_or_default().to_string()); + let mut name_tokens = media::tokenize_media_name(file_name.to_str().unwrap_or_default().to_string()); - lookup_media(entry, name_tokens, cfg) + // Remove last token (file ext) + _ = name_tokens.pop(); + + lookup_movie(entry, name_tokens, cfg) } pub fn handle_movie_files_and_folders(files: Vec, folders: Vec, cfg: Config) -> Vec { let mut moves: Vec = Vec::new(); let mut primary_media: Option = None; // Assuming first file (biggest file) is primary media, store the information of this, for the rest, do lazy matching for extra content/subs and so on for file in files { + if file.path().to_str().unwrap_or_default().to_string().to_ascii_lowercase().contains("sample") { + continue; + } check_movie_file(file.path(), &mut primary_media, &cfg, &mut moves); } match primary_media { @@ -171,6 +116,9 @@ pub fn handle_movie_files_and_folders(files: Vec, folders: Vec { if entry.file_type().is_file() { + if entry.path().to_str().unwrap_or_default().to_string().to_ascii_lowercase().contains("sample") { + continue; + } check_movie_file(entry.into_path(), &mut primary_media, &cfg, &mut moves); } }, @@ -185,7 +133,7 @@ pub fn handle_movie_files_and_folders(files: Vec, folders: Vec { // There is no primary media yet, try every folder as main folder for folder in folders { - moves.append(&mut search_path(folder.path(), cfg.clone()).unwrap()); + moves.append(&mut search_path(folder.path(), cfg.clone(), false).unwrap()); } } } @@ -201,12 +149,17 @@ fn check_movie_file(file: PathBuf, primary_media: &mut Option, cfg: & match primary_media.as_ref() { None => { // No primary media found yet, look up media on TMDB - match video_file_handler(file.clone(), cfg.clone()) { + match movie_video_file_handler(file.clone(), cfg.clone()) { Some(meta) => { *primary_media = Some(meta.clone()); let original_path = file; let ext = original_path.extension().unwrap_or_default(); - let new_path = cfg.plex_library.join(format!("Movies/{0} {{tmdb-{1}}}/{0} {{tmdb-{1}}}.{2}", sanitise(meta.title.as_str()), meta.id, ext.to_str().unwrap_or_default())); + let year: String; + match meta.release_date.unwrap_or_default().split('-').nth(0) { + Some(y) => year = format!("({}) ", y), + None => year = "".to_string() + } + let new_path = cfg.plex_library.join(format!("Movies/{0} {3}{{tmdb-{1}}}/{0} {3}{{tmdb-{1}}}.{2}", sanitise(meta.title.as_str()), meta.id, ext.to_str().unwrap_or_default(), year)); moves.push(Move { from: original_path, to: new_path }); }, None => { @@ -233,7 +186,12 @@ fn check_movie_file(file: PathBuf, primary_media: &mut Option, cfg: & Ok(edition_name) => { let original_path = file; let ext = original_path.extension().unwrap_or_default(); - let new_path = cfg.plex_library.join(format!("Movies/{0} {{tmdb-{1}}}/{0} {{tmdb-{1}}} {{edition-{3}}}.{2}", sanitise(primary_media.title.as_str()), primary_media.id, ext.to_str().unwrap_or_default(), edition_name)); + let year: String; + match primary_media.clone().release_date.unwrap_or_default().split('-').nth(0) { + Some(y) => year = format!("({}) ", y), + None => year = "".to_string() + } + let new_path = cfg.plex_library.join(format!("Movies/{0} {4}{{tmdb-{1}}}/{0} {4}{{tmdb-{1}}} {{edition-{3}}}.{2}", sanitise(primary_media.title.as_str()), primary_media.id, ext.to_str().unwrap_or_default(), edition_name, year)); moves.push(Move { from: original_path, to: new_path }); return; }, @@ -249,7 +207,12 @@ fn check_movie_file(file: PathBuf, primary_media: &mut Option, cfg: & Ok(description) => { let original_path = file; let ext = original_path.extension().unwrap_or_default(); - let new_path = cfg.plex_library.join(format!("Movies/{0} {{tmdb-{1}}}/{3}/{4}.{2}", sanitise(primary_media.title.as_str()), primary_media.id, ext.to_str().unwrap_or_default(), choice, description)); + let year: String; + match primary_media.clone().release_date.unwrap_or_default().split('-').nth(0) { + Some(y) => year = format!("({}) ", y), + None => year = "".to_string() + } + let new_path = cfg.plex_library.join(format!("Movies/{0} {5}{{tmdb-{1}}}/{3}/{4}.{2}", sanitise(primary_media.title.as_str()), primary_media.id, ext.to_str().unwrap_or_default(), choice, description, year)); moves.push(Move { from: original_path, to: new_path }); return; }, @@ -293,7 +256,12 @@ fn check_movie_file(file: PathBuf, primary_media: &mut Option, cfg: & // Forced let original_path = file; let ext = original_path.extension().unwrap_or_default(); - let new_path = cfg.plex_library.join(format!("Movies/{0} {{tmdb-{1}}}/{0} {{tmdb-{1}}}.{3}.forced.{2}", sanitise(primary_media.as_ref().unwrap().title.as_str()), primary_media.as_ref().unwrap().id, ext.to_str().unwrap_or_default(), lang_code.to_ascii_lowercase())); + let year: String; + match primary_media.clone().unwrap().release_date.unwrap_or_default().split('-').nth(0) { + Some(y) => year = format!("({}) ", y), + None => year = "".to_string() + } + let new_path = cfg.plex_library.join(format!("Movies/{0} {4}{{tmdb-{1}}}/{0} {{tmdb-{1}}}.{3}.forced.{2}", sanitise(primary_media.as_ref().unwrap().title.as_str()), primary_media.as_ref().unwrap().id, ext.to_str().unwrap_or_default(), lang_code.to_ascii_lowercase(), year)); moves.push(Move { from: original_path, to: new_path }); return; }, @@ -301,7 +269,12 @@ fn check_movie_file(file: PathBuf, primary_media: &mut Option, cfg: & // Non-forced let original_path = file; let ext = original_path.extension().unwrap_or_default(); - let new_path = cfg.plex_library.join(format!("Movies/{0} {{tmdb-{1}}}/{0} {{tmdb-{1}}}.{3}.{2}", sanitise(primary_media.as_ref().unwrap().title.as_str()), primary_media.as_ref().unwrap().id, ext.to_str().unwrap_or_default(), lang_code.to_ascii_lowercase())); + let year: String; + match primary_media.clone().unwrap().release_date.unwrap_or_default().split('-').nth(0) { + Some(y) => year = format!("({}) ", y), + None => year = "".to_string() + } + let new_path = cfg.plex_library.join(format!("Movies/{0} {4}{{tmdb-{1}}}/{0} {{tmdb-{1}}}.{3}.{2}", sanitise(primary_media.as_ref().unwrap().title.as_str()), primary_media.as_ref().unwrap().id, ext.to_str().unwrap_or_default(), lang_code.to_ascii_lowercase(), year)); moves.push(Move { from: original_path, to: new_path }); return; }, diff --git a/src/show.rs b/src/show.rs new file mode 100644 index 0000000..f08de8c --- /dev/null +++ b/src/show.rs @@ -0,0 +1,256 @@ +use std::{fmt, fs::DirEntry, path::PathBuf, time::Duration}; + +use inquire::{Select, Text, Confirm}; +use log::{error, info, trace, debug, warn}; +use reqwest::{header::{HeaderMap, HeaderValue}, blocking::Client}; +use sanitise_file_name::sanitise; +use serde::Deserialize; +use urlencoding::encode; +use walkdir::WalkDir; +use inline_colorization::*; +use regex::RegexBuilder; + +use crate::{config::Config, media::{Move, self, get_file_header}, directory::search_path}; + +#[derive(Deserialize, Debug)] +struct TMDBResponse { + results: Vec, + total_results: i32 +} + +#[derive(Deserialize, Debug, Clone)] +struct TMDBEntry { + id: i32, + name: String, + original_language: Option, + first_air_date: Option, +} + +impl fmt::Display for TMDBEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({}, {}) (ID: {})", self.name, self.first_air_date.clone().unwrap_or("unknown".to_string()), self.original_language.as_ref().unwrap(), self.id) + } +} + +fn check_show_name(entry: PathBuf, cfg: Config) -> Option { + info!("Found folder: {:#?}", entry); + + let folder_name = entry.file_name().unwrap_or_default(); + trace!("Folder name is: {:#?}", folder_name); + + let name_tokens = media::tokenize_media_name(folder_name.to_str().unwrap_or_default().to_string()); + lookup_show(entry, name_tokens, cfg) +} + +fn lookup_show(folder_name: PathBuf, mut name_tokens: Vec, cfg: Config) -> Option { + if name_tokens.first().unwrap_or(&"".to_string()).eq_ignore_ascii_case("season") { + // Is a season folder most likely, skip useless TMDB requests + return None; + } + let mut h = HeaderMap::new(); + h.insert("Accept", HeaderValue::from_static("application/json")); + h.insert("Authorization", HeaderValue::from_str(format!("Bearer {}", cfg.tmdb_key).as_str()).unwrap()); + + let client = Client::builder() + .default_headers(h) + .build().unwrap(); + + let mut response: TMDBResponse; + loop { + if name_tokens.len() == 0 { + error!("Could not find title on TMDB!"); + return None; + } + + let name = name_tokens.join(" "); + trace!("Searching on TMDB for {:#?}", name); + + let http_response = client + .get(format!("https://api.themoviedb.org/3/search/tv?query={}&include_adult=false&language=en-US&page=1", encode(name.as_str()).into_owned())) + .timeout(Duration::from_secs(120)) + .send(); + + if http_response.is_err() { + warn!("Request error: {:#?}", http_response.unwrap_err()); + return None; + } + + response = http_response.unwrap().json::().unwrap(); + trace!("TMDB Reponse: {:#?}", response); + + if response.total_results == 0 { + name_tokens.pop(); + } else { + break; + } + } + + let options = response.results; + + let ans = Select::new(format!("Select show that resides in folder {style_bold}{}{style_reset} (Ctrl-C to skip):", folder_name.display()).as_str(), options).prompt(); + match ans { + Ok(choice) => { + debug!("Selected: {:#?}", choice); + return Some(choice); + }, + Err(e) => { + error!("Error while selecting content: {:#?}", e); + return None; + }, + } +} + +pub fn handle_show_files_and_folders(directory: PathBuf, files: Vec, folders: Vec, cfg: Config) -> Vec { + let mut moves: Vec = Vec::new(); + let mut primary_media: Option; + + // Check current directory for possible name + primary_media = check_show_name(directory, cfg.clone()); + + //check_show_file(file.path(), &mut primary_media, &cfg, &mut moves); + match primary_media { + Some(_) => { + // There is already primary media, check files and directories for more media for same show + for file in files { + if file.file_type().unwrap().is_file() { + if file.path().to_str().unwrap_or_default().to_string().to_ascii_lowercase().contains("sample") { + continue; + } + check_show_file(file.path(), &mut primary_media, &cfg, &mut moves); + } + } + for folder in folders { + for entry in WalkDir::new(folder.path()) { + match entry { + Ok(entry) => { + if entry.file_type().is_file() { + if entry.path().to_str().unwrap_or_default().to_string().to_ascii_lowercase().contains("sample") { + continue; + } + check_show_file(entry.into_path(), &mut primary_media, &cfg, &mut moves); + } + }, + Err(e) => { + error!("Error walking the directory: {:#?}", e); + continue; + } + } + } + } + }, + None => { + // There is no primary media yet, try every folder as main folder + for folder in folders { + moves.append(&mut search_path(folder.path(), cfg.clone(), true).unwrap()); + } + } + } + moves +} + +fn check_show_file(file: PathBuf, primary_media: &mut Option, cfg: &Config, moves: &mut Vec) { + trace!("Checking {:#?}", file); + match get_file_header(file.clone()) { + Ok(header) => { + // Try to parse Season/Episode from filename + let re = RegexBuilder::new(r"(?:S(?[0-9]+)\.?E(?[0-9]+)|(?[0-9]+)x(?[0-9]+))") + .case_insensitive(true).build().unwrap(); + let Some(caps) = re.captures(file.to_str().unwrap_or_default()) else { warn!("Regex doesn't match {:#?}, skipping", file); return; }; + let season: i32 = caps.name("season0").map_or_else(||caps.name("season1").map_or("", |m| m.as_str()), |m| m.as_str()).parse().unwrap(); + let episode: i32 = caps.name("episode0").map_or_else(||caps.name("episode1").map_or("", |m| m.as_str()), |m| m.as_str()).parse().unwrap(); + trace!("Found Season {0:02}, Episode {1:02}", season, episode); + + // Handle video files + if infer::is_video(&header) { + match primary_media.as_ref() { + None => { + error!("Can not parse files without matched show!"); + return; + }, + Some(primary_media) => { + let original_path = file; + let ext = original_path.extension().unwrap_or_default(); + let year: String; + match primary_media.clone().first_air_date.unwrap_or_default().split('-').nth(0) { + Some(y) => year = format!("({}) ", y), + None => year = "".to_string() + } + let new_path = cfg.plex_library.join(format!("TV Shows/{0} {3}{{tmdb-{1}}}/Season {4:02}/{0} - S{4:02}E{5:02}.{2}", sanitise(primary_media.name.as_str()), primary_media.id, ext.to_str().unwrap_or_default(), year, season, episode)); + moves.push(Move { from: original_path, to: new_path }); + } + } + } else { + match file.extension() { + Some(ext) => { + if ext.eq_ignore_ascii_case("srt") || + ext.eq_ignore_ascii_case("ass") || + ext.eq_ignore_ascii_case("ssa") || + ext.eq_ignore_ascii_case("smi") || + ext.eq_ignore_ascii_case("pgs") || + ext.eq_ignore_ascii_case("vob") { + // Subtitle file + if primary_media.is_none() { + warn!("Can not categorize subtitle file without primary media, skipping."); + return; + } + + let lang_code = Text::new(format!("Specify ISO-639-1 (2-letter) language code (e.g. 'en', 'de') or leave empty to discard for {style_bold}{}{style_reset}:", file.display()).as_str()).prompt(); + match lang_code { + Ok(lang_code) => { + if lang_code == "" { + return; + } + let forced = Confirm::new("Is this a forced sub?").with_default(false).prompt(); + match forced { + Ok(true) => { + // Forced + let original_path = file; + let ext = original_path.extension().unwrap_or_default(); + let year: String; + match primary_media.clone().unwrap().first_air_date.unwrap_or_default().split('-').nth(0) { + Some(y) => year = format!("({}) ", y), + None => year = "".to_string() + } + let new_path = cfg.plex_library.join(format!("TV Shows/{0} {4}{{tmdb-{1}}}/Season {5:02}/{0} - S{5:02}E{6:02}.{3}.forced.{2}", sanitise(primary_media.as_ref().unwrap().name.as_str()), primary_media.as_ref().unwrap().id, ext.to_str().unwrap_or_default(), lang_code.to_ascii_lowercase(), year, season, episode)); + moves.push(Move { from: original_path, to: new_path }); + return; + }, + Ok(false) => { + // Non-forced + let original_path = file; + let ext = original_path.extension().unwrap_or_default(); + let year: String; + match primary_media.clone().unwrap().first_air_date.unwrap_or_default().split('-').nth(0) { + Some(y) => year = format!("({}) ", y), + None => year = "".to_string() + } + let new_path = cfg.plex_library.join(format!("TV Shows/{0} {4}{{tmdb-{1}}}/Season {5:02}/{0} - S{5:02}E{6:02}.{3}.{2}", sanitise(primary_media.as_ref().unwrap().name.as_str()), primary_media.as_ref().unwrap().id, ext.to_str().unwrap_or_default(), lang_code.to_ascii_lowercase(), year, season, episode)); + moves.push(Move { from: original_path, to: new_path }); + return; + }, + Err(e) => { + error!("There was an error: {:#?}", e); + return; + }, + } + }, + Err(e) => { + error!("There was an error: {:#?}", e); + return; + }, + } + } else { + info!("Not a video file nor subtitle, skipping"); + return; + } + }, + None => { + error!("File {:#?} has no file extension", file); + return; + } + } + } + }, + Err(error) => error!("Can not get file header for {:#?}, Error: {:#?}", file, error), + } +} \ No newline at end of file