feat(shows): Implement show matcher

Now implemented show matcher, and actual file mover
This commit is contained in:
Andreas Mieke 2023-11-07 18:41:00 +01:00
parent 41ef59ff93
commit 72baadbec7
8 changed files with 482 additions and 98 deletions

39
Cargo.lock generated
View file

@ -17,6 +17,15 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 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]] [[package]]
name = "android-tzdata" name = "android-tzdata"
version = "0.1.1" version = "0.1.1"
@ -830,6 +839,7 @@ dependencies = [
"inline_colorization", "inline_colorization",
"inquire", "inquire",
"log", "log",
"regex",
"reqwest", "reqwest",
"sanitise-file-name", "sanitise-file-name",
"serde", "serde",
@ -866,6 +876,35 @@ dependencies = [
"bitflags 1.3.2", "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]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.11.22" version = "0.11.22"

View file

@ -11,6 +11,7 @@ infer = "0.15.0"
inline_colorization = "0.1.6" inline_colorization = "0.1.6"
inquire = "0.6.2" inquire = "0.6.2"
log = "0.4.20" log = "0.4.20"
regex = "1.10.2"
reqwest = { version = "0.11.22", features = ["json", "blocking"] } reqwest = { version = "0.11.22", features = ["json", "blocking"] }
sanitise-file-name = "1.0.0" sanitise-file-name = "1.0.0"
serde = { version = "1.0.190", features = ["derive"] } serde = { version = "1.0.190", features = ["derive"] }

View file

@ -37,7 +37,7 @@ pub fn load(path: &PathBuf, first: bool) -> Result<Config, Box<dyn Error>> {
} }
pub fn first_run() -> Result<Config, Box<dyn Error>> { pub fn first_run() -> Result<Config, Box<dyn Error>> {
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).") .with_help_message("The API key can be found at https://www.themoviedb.org/settings/api (you must be logged in).")
.prompt(); .prompt();

View file

@ -2,7 +2,7 @@ use std::{path::PathBuf, fs::{self, DirEntry}, error::Error};
use log::trace; 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 { /*fn is_not_hidden(entry: &DirEntry) -> bool {
entry entry
@ -22,8 +22,8 @@ pub fn walk_path(path: PathBuf) -> Vec<PathBuf> {
entries entries
}*/ }*/
pub fn search_path(path: PathBuf, cfg: Config) -> Result<Vec<Move>, Box<dyn Error>> { pub fn search_path(path: PathBuf, cfg: Config, shows: bool) -> Result<Vec<Move>, Box<dyn Error>> {
let entries = fs::read_dir(path)?; let entries = fs::read_dir(path.clone())?;
let mut files: Vec<DirEntry> = Vec::new(); let mut files: Vec<DirEntry> = Vec::new();
let mut folders: Vec<DirEntry> = Vec::new(); let mut folders: Vec<DirEntry> = Vec::new();
@ -45,8 +45,12 @@ pub fn search_path(path: PathBuf, cfg: Config) -> Result<Vec<Move>, Box<dyn Erro
trace!("Sorted Dirs: {:#?}", folders); trace!("Sorted Dirs: {:#?}", folders);
trace!("Sorted Files: {:#?}", files); trace!("Sorted Files: {:#?}", files);
let mut moves: Vec<movie::Move> = Vec::new(); let mut moves: Vec<Move> = 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())); moves.append(&mut handle_movie_files_and_folders(files, folders, cfg.clone()));
}
Ok(moves) Ok(moves)
} }

View file

@ -1,10 +1,13 @@
mod config; mod config;
mod directory; mod directory;
mod movie; mod movie;
mod show;
mod media;
use log::*; use log::*;
use clap::Parser; use clap::Parser;
use std::{path::PathBuf, env}; use std::{path::PathBuf, env, fs};
use inline_colorization::*;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
@ -22,9 +25,13 @@ struct Args {
first_run: bool, first_run: bool,
/// Move files rather than copying them /// Move files rather than copying them
#[arg(short, long, name="move")] #[arg(short, long="move")]
moov: bool, moov: bool,
/// Output moves/copies instead of actually doing them
#[arg(short, long)]
dry_run: bool,
/// Look for shows instead of movies /// Look for shows instead of movies
#[arg(short, long)] #[arg(short, long)]
shows: bool, shows: bool,
@ -71,15 +78,48 @@ fn main() {
args.path.unwrap() args.path.unwrap()
}; };
//let files = directory::walk_path(search_path); let moves = directory::search_path(search_path, cfg, args.shows).unwrap();
let moves = directory::search_path(search_path, cfg).unwrap();
for move_file in moves { for move_file in moves {
info!("Moving: {:#?}: {:#?}", args.moov, move_file); if args.moov {
_ = move_file.from; // Move files instead of copying
_ = move_file.to; 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() { /*for file in files.clone() {
info!("Found: {}", file.to_str().unwrap()); info!("Found: {}", file.to_str().unwrap());
}*/ }*/

71
src/media.rs Normal file
View file

@ -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<Vec<u8>, Box<dyn Error>> {
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<String> {
let tokens: Vec<String> = file_name.split(&['-', ' ', ':', '@', '.'][..]).filter(|t| token_valid(t)).map(String::from).collect();
trace!("Tokens are: {:#?}", tokens);
tokens
}

View file

@ -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 infer;
use inquire::{Select, Text, Confirm}; use inquire::{Select, Text, Confirm};
use log::{info, warn, error, trace, debug}; use log::{info, warn, error, trace, debug};
@ -9,7 +9,7 @@ use inline_colorization::*;
use sanitise_file_name::sanitise; use sanitise_file_name::sanitise;
use walkdir::WalkDir; 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)] #[derive(Deserialize, Debug)]
struct TMDBResponse { struct TMDBResponse {
@ -31,74 +31,7 @@ impl fmt::Display for TMDBEntry {
} }
} }
#[derive(Debug)] fn lookup_movie(file_name: PathBuf, mut name_tokens: Vec<String>, cfg: Config) -> Option<TMDBEntry> {
pub struct Move {
pub from: PathBuf,
pub to: PathBuf
}
fn get_file_header(path: PathBuf) -> Result<Vec<u8>, Box<dyn Error>> {
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<String> {
let mut tokens: Vec<String> = 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<String>, cfg: Config) -> Option<TMDBEntry> {
let mut h = HeaderMap::new(); let mut h = HeaderMap::new();
h.insert("Accept", HeaderValue::from_static("application/json")); h.insert("Accept", HeaderValue::from_static("application/json"));
h.insert("Authorization", HeaderValue::from_str(format!("Bearer {}", cfg.tmdb_key).as_str()).unwrap()); 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<String>, cfg: Config) -
let http_response = client 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())) .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::<TMDBResponse>().unwrap(); if http_response.is_err() {
warn!("Request error: {:#?}", http_response.unwrap_err());
return None;
}
response = http_response.unwrap().json::<TMDBResponse>().unwrap();
trace!("TMDB Reponse: {:#?}", response); trace!("TMDB Reponse: {:#?}", response);
if response.total_results == 0 { if response.total_results == 0 {
@ -133,7 +72,7 @@ fn lookup_media(file_name: PathBuf, mut name_tokens: Vec<String>, cfg: Config) -
let options = response.results; 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 { match ans {
Ok(choice) => { Ok(choice) => {
debug!("Selected: {:#?}", choice); debug!("Selected: {:#?}", choice);
@ -146,21 +85,27 @@ fn lookup_media(file_name: PathBuf, mut name_tokens: Vec<String>, cfg: Config) -
} }
} }
fn video_file_handler(entry: PathBuf, cfg: Config) -> Option<TMDBEntry> { fn movie_video_file_handler(entry: PathBuf, cfg: Config) -> Option<TMDBEntry> {
info!("Found video file: {:#?}", entry); info!("Found movie video file: {:#?}", entry);
let file_name = entry.file_name().unwrap_or_default(); let file_name = entry.file_name().unwrap_or_default();
trace!("File name is: {:#?}", file_name); 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<DirEntry>, folders: Vec<DirEntry>, cfg: Config) -> Vec<Move> { pub fn handle_movie_files_and_folders(files: Vec<DirEntry>, folders: Vec<DirEntry>, cfg: Config) -> Vec<Move> {
let mut moves: Vec<Move> = Vec::new(); let mut moves: Vec<Move> = Vec::new();
let mut primary_media: Option<TMDBEntry> = 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 let mut primary_media: Option<TMDBEntry> = 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 { 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); check_movie_file(file.path(), &mut primary_media, &cfg, &mut moves);
} }
match primary_media { match primary_media {
@ -171,6 +116,9 @@ pub fn handle_movie_files_and_folders(files: Vec<DirEntry>, folders: Vec<DirEntr
match entry { match entry {
Ok(entry) => { Ok(entry) => {
if entry.file_type().is_file() { 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); 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<DirEntry>, folders: Vec<DirEntr
None => { None => {
// There is no primary media yet, try every folder as main folder // There is no primary media yet, try every folder as main folder
for folder in folders { 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<TMDBEntry>, cfg: &
match primary_media.as_ref() { match primary_media.as_ref() {
None => { None => {
// No primary media found yet, look up media on TMDB // 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) => { Some(meta) => {
*primary_media = Some(meta.clone()); *primary_media = Some(meta.clone());
let original_path = file; let original_path = file;
let ext = original_path.extension().unwrap_or_default(); 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 }); moves.push(Move { from: original_path, to: new_path });
}, },
None => { None => {
@ -233,7 +186,12 @@ fn check_movie_file(file: PathBuf, primary_media: &mut Option<TMDBEntry>, cfg: &
Ok(edition_name) => { Ok(edition_name) => {
let original_path = file; let original_path = file;
let ext = original_path.extension().unwrap_or_default(); 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 }); moves.push(Move { from: original_path, to: new_path });
return; return;
}, },
@ -249,7 +207,12 @@ fn check_movie_file(file: PathBuf, primary_media: &mut Option<TMDBEntry>, cfg: &
Ok(description) => { Ok(description) => {
let original_path = file; let original_path = file;
let ext = original_path.extension().unwrap_or_default(); 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 }); moves.push(Move { from: original_path, to: new_path });
return; return;
}, },
@ -293,7 +256,12 @@ fn check_movie_file(file: PathBuf, primary_media: &mut Option<TMDBEntry>, cfg: &
// Forced // Forced
let original_path = file; let original_path = file;
let ext = original_path.extension().unwrap_or_default(); 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 }); moves.push(Move { from: original_path, to: new_path });
return; return;
}, },
@ -301,7 +269,12 @@ fn check_movie_file(file: PathBuf, primary_media: &mut Option<TMDBEntry>, cfg: &
// Non-forced // Non-forced
let original_path = file; let original_path = file;
let ext = original_path.extension().unwrap_or_default(); 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 }); moves.push(Move { from: original_path, to: new_path });
return; return;
}, },

256
src/show.rs Normal file
View file

@ -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<TMDBEntry>,
total_results: i32
}
#[derive(Deserialize, Debug, Clone)]
struct TMDBEntry {
id: i32,
name: String,
original_language: Option<String>,
first_air_date: Option<String>,
}
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<TMDBEntry> {
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<String>, cfg: Config) -> Option<TMDBEntry> {
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::<TMDBResponse>().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<DirEntry>, folders: Vec<DirEntry>, cfg: Config) -> Vec<Move> {
let mut moves: Vec<Move> = Vec::new();
let mut primary_media: Option<TMDBEntry>;
// 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<TMDBEntry>, cfg: &Config, moves: &mut Vec<Move>) {
trace!("Checking {:#?}", file);
match get_file_header(file.clone()) {
Ok(header) => {
// Try to parse Season/Episode from filename
let re = RegexBuilder::new(r"(?:S(?<season0>[0-9]+)\.?E(?<episode0>[0-9]+)|(?<season1>[0-9]+)x(?<episode1>[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),
}
}