Plex-Meta-Manager/modules/util.py

362 lines
14 KiB
Python
Raw Normal View History

2021-08-10 15:33:32 +00:00
import glob, logging, os, re, signal, sys, time, traceback
2021-08-06 23:02:33 +00:00
from datetime import datetime, timedelta
2021-08-10 15:33:32 +00:00
from logging.handlers import RotatingFileHandler
2021-05-19 21:30:20 +00:00
from pathvalidate import is_valid_filename, sanitize_filename
2021-05-11 01:22:18 +00:00
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
2021-01-20 21:37:59 +00:00
try:
import msvcrt
windows = True
except ModuleNotFoundError:
windows = False
logger = logging.getLogger("Plex Meta Manager")
class TimeoutExpired(Exception):
pass
class Failed(Exception):
pass
class NotScheduled(Exception):
pass
2021-06-30 15:02:55 +00:00
class ImageData:
2021-06-12 15:29:17 +00:00
def __init__(self, attribute, location, prefix="", is_poster=True, is_url=True):
self.attribute = attribute
self.location = location
self.prefix = prefix
self.is_poster = is_poster
self.is_url = is_url
self.compare = location if is_url else os.stat(location).st_size
self.message = f"{prefix}{'poster' if is_poster else 'background'} to [{'URL' if is_url else 'File'}] {location}"
2021-10-04 17:51:32 +00:00
def __str__(self):
return str(self.__dict__)
2021-01-20 21:37:59 +00:00
def retry_if_not_failed(exception):
return not isinstance(exception, Failed)
2021-05-11 01:22:18 +00:00
def retry_if_not_plex(exception):
return not isinstance(exception, (BadRequest, NotFound, Unauthorized))
2021-02-24 06:42:58 +00:00
separating_character = "="
2021-01-20 21:37:59 +00:00
screen_width = 100
2021-05-26 13:25:32 +00:00
spacing = 0
2021-01-20 21:37:59 +00:00
days_alias = {
"monday": 0, "mon": 0, "m": 0,
"tuesday": 1, "tues": 1, "tue": 1, "tu": 1, "t": 1,
"wednesday": 2, "wed": 2, "w": 2,
"thursday": 3, "thurs": 3, "thur": 3, "thu": 3, "th": 3, "r": 3,
"friday": 4, "fri": 4, "f": 4,
"saturday": 5, "sat": 5, "s": 5,
"sunday": 6, "sun": 6, "su": 6, "u": 6
}
2021-08-14 22:59:35 +00:00
mod_displays = {
"": "is", ".not": "is not", ".is": "is", ".isnot": "is not", ".begins": "begins with", ".ends": "ends with", ".before": "is before", ".after": "is after",
2021-08-14 22:59:35 +00:00
".gt": "is greater than", ".gte": "is greater than or equal", ".lt": "is less than", ".lte": "is less than or equal"
}
2021-07-23 19:44:21 +00:00
pretty_days = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday", 4: "Friday", 5: "Saturday", 6: "Sunday"}
2021-01-20 21:37:59 +00:00
pretty_months = {
2021-07-23 19:44:21 +00:00
1: "January", 2: "February", 3: "March", 4: "April", 5: "May", 6: "June",
7: "July", 8: "August", 9: "September", 10: "October", 11: "November", 12: "December"
2021-01-20 21:37:59 +00:00
}
2021-08-14 22:59:35 +00:00
seasons = ["winter", "spring", "summer", "fall"]
2021-07-23 19:44:21 +00:00
pretty_ids = {"anidbid": "AniDB", "imdbid": "IMDb", "mal_id": "MyAnimeList", "themoviedb_id": "TMDb", "thetvdb_id": "TVDb", "tvdbid": "TVDb"}
2021-01-20 21:37:59 +00:00
2021-02-24 06:44:06 +00:00
def tab_new_lines(data):
return str(data).replace("\n", "\n|\t ") if "\n" in str(data) else str(data)
2021-01-20 21:37:59 +00:00
def make_ordinal(n):
2021-07-26 20:29:28 +00:00
return f"{n}{'th' if 11 <= (n % 100) <= 13 else ['th', 'st', 'nd', 'rd', 'th'][min(n % 10, 4)]}"
2021-01-20 21:37:59 +00:00
2021-08-22 15:54:33 +00:00
def add_zero(number):
return str(number) if len(str(number)) > 1 else f"0{number}"
2021-08-04 14:20:52 +00:00
def add_dict_list(keys, value, dict_map):
for key in keys:
if key in dict_map:
dict_map[key].append(value)
else:
dict_map[key] = [value]
2021-05-08 23:49:55 +00:00
def get_list(data, lower=False, split=True, int_list=False):
if data is None: return None
elif isinstance(data, list): return data
2021-01-20 21:37:59 +00:00
elif isinstance(data, dict): return [data]
2021-02-17 06:10:50 +00:00
elif split is False: return [str(data)]
2021-02-16 04:54:47 +00:00
elif lower is True: return [d.strip().lower() for d in str(data).split(",")]
2021-05-08 23:49:55 +00:00
elif int_list is True: return [int(d.strip()) for d in str(data).split(",")]
2021-02-16 04:54:47 +00:00
else: return [d.strip() for d in str(data).split(",")]
2021-01-20 21:37:59 +00:00
def get_int_list(data, id_type):
int_values = []
2021-07-30 19:19:43 +00:00
for value in get_list(data):
2021-01-20 21:37:59 +00:00
try: int_values.append(regex_first_int(value, id_type))
except Failed as e: logger.error(e)
return int_values
2021-07-21 19:25:29 +00:00
def validate_date(date_text, method, return_as=None):
try: date_obg = datetime.strptime(str(date_text), "%Y-%m-%d" if "-" in str(date_text) else "%m/%d/%Y")
except ValueError: raise Failed(f"Collection Error: {method}: {date_text} must match pattern YYYY-MM-DD (e.g. 2020-12-25) or MM/DD/YYYY (e.g. 12/25/2020)")
return datetime.strftime(date_obg, return_as) if return_as else date_obg
2021-01-20 21:37:59 +00:00
def logger_input(prompt, timeout=60):
if windows: return windows_input(prompt, timeout)
elif hasattr(signal, "SIGALRM"): return unix_input(prompt, timeout)
else: raise SystemError("Input Timeout not supported on this system")
2021-07-14 14:47:20 +00:00
def header(language="en-US,en;q=0.5"):
2021-10-26 15:01:08 +00:00
return {"Accept-Language": "eng" if language == "default" else language, "User-Agent": "Mozilla/5.0 x64"}
2021-07-14 14:47:20 +00:00
2021-01-20 21:37:59 +00:00
def alarm_handler(signum, frame):
raise TimeoutExpired
def unix_input(prompt, timeout=60):
2021-02-24 06:44:06 +00:00
prompt = f"| {prompt}: "
2021-01-20 21:37:59 +00:00
signal.signal(signal.SIGALRM, alarm_handler)
signal.alarm(timeout)
2021-07-04 04:13:06 +00:00
try: return input(prompt)
except EOFError: raise Failed("Input Failed")
finally: signal.alarm(0)
2021-01-20 21:37:59 +00:00
def windows_input(prompt, timeout=5):
2021-02-24 06:44:06 +00:00
sys.stdout.write(f"| {prompt}: ")
2021-01-20 21:37:59 +00:00
sys.stdout.flush()
result = []
start_time = time.time()
while True:
if msvcrt.kbhit():
2021-02-24 06:42:58 +00:00
char = msvcrt.getwche()
if ord(char) == 13: # enter_key
2021-01-20 21:37:59 +00:00
out = "".join(result)
print("")
2021-02-24 06:44:06 +00:00
logger.debug(f"{prompt}: {out}")
2021-01-20 21:37:59 +00:00
return out
2021-02-24 06:42:58 +00:00
elif ord(char) >= 32: #space_char
result.append(char)
2021-01-20 21:37:59 +00:00
if (time.time() - start_time) > timeout:
print("")
raise TimeoutExpired
def print_multiline(lines, info=False, warning=False, error=False, critical=False):
2021-02-21 17:01:10 +00:00
for i, line in enumerate(str(lines).split("\n")):
2021-01-20 21:37:59 +00:00
if critical: logger.critical(line)
elif error: logger.error(line)
elif warning: logger.warning(line)
elif info: logger.info(line)
else: logger.debug(line)
if i == 0:
logger.handlers[1].setFormatter(logging.Formatter(" " * 65 + "| %(message)s"))
logger.handlers[1].setFormatter(logging.Formatter("[%(asctime)s] %(filename)-27s %(levelname)-10s | %(message)s"))
def print_stacktrace():
print_multiline(traceback.format_exc())
def my_except_hook(exctype, value, tb):
for line in traceback.format_exception(etype=exctype, value=value, tb=tb):
print_multiline(line, critical=True)
def get_id_from_imdb_url(imdb_url):
match = re.search("(tt\\d+)", str(imdb_url))
if match: return match.group(1)
2021-02-24 06:44:06 +00:00
else: raise Failed(f"Regex Error: Failed to parse IMDb ID from IMDb URL: {imdb_url}")
2021-01-20 21:37:59 +00:00
def regex_first_int(data, id_type, default=None):
match = re.search("(\\d+)", str(data))
if match:
return int(match.group(1))
elif default:
2021-02-24 06:44:06 +00:00
logger.warning(f"Regex Warning: Failed to parse {id_type} from {data} using {default} as default")
2021-01-20 21:37:59 +00:00
return int(default)
else:
2021-02-24 06:44:06 +00:00
raise Failed(f"Regex Error: Failed to parse {id_type} from {data}")
2021-01-20 21:37:59 +00:00
2021-05-24 03:38:46 +00:00
def centered(text, sep=" "):
2021-01-20 21:37:59 +00:00
if len(text) > screen_width - 2:
2021-06-11 14:26:11 +00:00
return text
2021-01-20 21:37:59 +00:00
space = screen_width - len(text) - 2
2021-05-24 03:38:46 +00:00
text = f" {text} "
2021-01-20 21:37:59 +00:00
if space % 2 == 1:
2021-05-24 03:38:46 +00:00
text += sep
2021-01-20 21:37:59 +00:00
space -= 1
2021-05-24 03:38:46 +00:00
side = int(space / 2) - 1
final_text = f"{sep * side}{text}{sep * side}"
2021-03-21 23:00:37 +00:00
return final_text
2021-01-20 21:37:59 +00:00
2021-05-24 03:38:46 +00:00
def separator(text=None, space=True, border=True, debug=False):
sep = " " if space else separating_character
2021-05-19 17:12:34 +00:00
for handler in logger.handlers:
apply_formatter(handler, border=False)
2021-05-24 03:38:46 +00:00
border_text = f"|{separating_character * screen_width}|"
if border and debug:
logger.debug(border_text)
elif border:
logger.info(border_text)
2021-01-20 21:37:59 +00:00
if text:
2021-02-28 03:56:49 +00:00
text_list = text.split("\n")
for t in text_list:
2021-05-24 03:38:46 +00:00
logger.info(f"|{sep}{centered(t, sep=sep)}{sep}|")
if border and debug:
logger.debug(border_text)
elif border:
logger.info(border_text)
2021-05-19 17:12:34 +00:00
for handler in logger.handlers:
apply_formatter(handler)
def apply_formatter(handler, border=True):
text = f"| %(message)-{screen_width - 2}s |" if border else f"%(message)-{screen_width - 2}s"
2021-08-10 15:33:32 +00:00
if isinstance(handler, RotatingFileHandler):
2021-05-19 17:12:34 +00:00
text = f"[%(asctime)s] %(filename)-27s %(levelname)-10s {text}"
handler.setFormatter(logging.Formatter(text))
2021-01-20 21:37:59 +00:00
2021-05-26 13:25:32 +00:00
def adjust_space(display_title):
display_title = str(display_title)
space_length = spacing - len(display_title)
if space_length > 0:
display_title += " " * space_length
return display_title
def print_return(text):
print(adjust_space(f"| {text}"), end="\r")
global spacing
spacing = len(text) + 2
2021-01-20 21:37:59 +00:00
2021-05-26 13:25:32 +00:00
def print_end():
print(adjust_space(" "), end="\r")
global spacing
spacing = 0
2021-05-19 21:30:20 +00:00
def validate_filename(filename):
if is_valid_filename(filename):
2021-05-20 20:38:48 +00:00
return filename, None
2021-05-19 21:30:20 +00:00
else:
mapping_name = sanitize_filename(filename)
2021-05-20 20:38:48 +00:00
return mapping_name, f"Log Folder Name: {filename} is invalid using {mapping_name}"
2021-07-06 15:46:29 +00:00
def is_locked(filepath):
locked = None
file_object = None
if os.path.exists(filepath):
try:
file_object = open(filepath, 'a', 8)
if file_object:
locked = False
2021-07-30 19:19:43 +00:00
except IOError:
2021-07-06 15:46:29 +00:00
locked = True
finally:
if file_object:
file_object.close()
return locked
2021-07-22 21:00:45 +00:00
2021-08-10 15:33:32 +00:00
def glob_filter(filter_in):
filter_in = filter_in.translate({ord("["): "[[]", ord("]"): "[]]"}) if "[" in filter_in else filter_in
return glob.glob(filter_in)
2021-08-07 06:01:21 +00:00
def is_date_filter(value, modifier, data, final, current_time):
if value is None:
return True
2021-08-06 23:02:33 +00:00
if modifier in ["", ".not"]:
threshold_date = current_time - timedelta(days=data)
2021-08-07 06:01:21 +00:00
if (modifier == "" and (value is None or value < threshold_date)) \
or (modifier == ".not" and value and value >= threshold_date):
return True
2021-08-06 23:02:33 +00:00
elif modifier in [".before", ".after"]:
filter_date = validate_date(data, final)
2021-08-07 06:01:21 +00:00
if (modifier == ".before" and value >= filter_date) or (modifier == ".after" and value <= filter_date):
return True
2021-08-06 23:02:33 +00:00
elif modifier == ".regex":
2021-08-07 06:01:21 +00:00
jailbreak = True
2021-08-06 23:02:33 +00:00
for check_data in data:
2021-08-07 06:01:21 +00:00
if re.compile(check_data).match(value.strftime("%m/%d/%Y")):
2021-08-06 23:02:33 +00:00
jailbreak = True
break
if not jailbreak:
2021-08-07 06:01:21 +00:00
return True
return False
def is_number_filter(value, modifier, data):
return value is None or (modifier == ".gt" and value <= data) \
or (modifier == ".gte" and value < data) \
or (modifier == ".lt" and value >= data) \
or (modifier == ".lte" and value > data)
def is_string_filter(values, modifier, data):
jailbreak = False
for value in values:
for check_value in data:
if (modifier in ["", ".not"] and check_value.lower() in value.lower()) \
or (modifier in [".is", ".isnot"] and value.lower() == check_value.lower()) \
2021-08-07 06:01:21 +00:00
or (modifier == ".begins" and value.lower().startswith(check_value.lower())) \
or (modifier == ".ends" and value.lower().endswith(check_value.lower())) \
or (modifier == ".regex" and re.compile(check_value).match(value)):
jailbreak = True
break
if jailbreak: break
return (jailbreak and modifier in [".not", ".isnot"]) or (not jailbreak and modifier in ["", ".is", ".begins", ".ends", ".regex"])
2021-08-06 23:02:33 +00:00
2021-08-14 22:59:35 +00:00
def parse(attribute, data, datatype=None, methods=None, parent=None, default=None, options=None, translation=None, minimum=1, maximum=None, regex=None):
2021-07-30 19:19:43 +00:00
display = f"{parent + ' ' if parent else ''}{attribute} attribute"
2021-07-23 18:45:49 +00:00
if options is None and translation is not None:
options = [o for o in translation]
2021-07-30 19:19:43 +00:00
value = data[methods[attribute]] if methods and attribute in methods else data
if datatype == "list":
if methods and attribute in methods and data[methods[attribute]]:
return [v for v in value if v] if isinstance(value, list) else [str(value)]
return []
elif datatype == "dictlist":
final_list = []
for dict_data in get_list(value):
if isinstance(dict_data, dict):
final_list.append((dict_data, {dm.lower(): dm for dm in dict_data}))
else:
raise Failed(f"Collection Error: {display} {dict_data} is not a dictionary")
return final_list
elif methods and attribute not in methods:
message = f"{display} not found"
elif value is None:
message = f"{display} is blank"
2021-08-14 22:59:35 +00:00
elif regex is not None:
regex_str, example = regex
if re.compile(regex_str).match(str(value)):
return str(value)
else:
message = f"{display}: {value} must match pattern {regex_str} e.g. {example}"
2021-07-30 19:19:43 +00:00
elif datatype == "bool":
if isinstance(value, bool):
return value
elif isinstance(value, int):
return value > 0
elif str(value).lower() in ["t", "true"]:
return True
elif str(value).lower() in ["f", "false"]:
return False
else:
message = f"{display} must be either true or false"
elif datatype in ["int", "float"]:
try:
value = int(str(value)) if datatype == "int" else float(str(value))
if (maximum is None and minimum <= value) or (maximum is not None and minimum <= value <= maximum):
return value
except ValueError:
pass
pre = f"{display} {value} must {'an integer' if datatype == 'int' else 'a number'}"
if maximum is None:
message = f"{pre} {minimum} or greater"
else:
message = f"{pre} between {minimum} and {maximum}"
elif (translation is not None and str(value).lower() not in translation) or \
(options is not None and translation is None and str(value).lower() not in options):
2021-08-14 22:59:35 +00:00
message = f"{display} {value} must be in {', '.join([str(o) for o in options])}"
2021-07-22 21:00:45 +00:00
else:
2021-07-30 19:19:43 +00:00
return translation[value] if translation is not None else value
2021-07-22 21:00:45 +00:00
if default is None:
2021-07-23 18:45:49 +00:00
raise Failed(f"Collection Error: {message}")
2021-07-22 21:00:45 +00:00
else:
2021-07-23 18:45:49 +00:00
logger.warning(f"Collection Warning: {message} using {default} as default")
return translation[default] if translation is not None else default