├── utils ├── __init__.py ├── misc.py ├── sql.py ├── sheets.py ├── themoviedb.py └── config.py ├── README.md ├── plex ├── __init__.py ├── library.py ├── metadata.py └── actions.py ├── requirements.txt ├── .gitignore └── app.py /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # plex_db_tools 2 | -------------------------------------------------------------------------------- /plex/__init__.py: -------------------------------------------------------------------------------- 1 | from . import library, metadata, actions -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrdict==2.0.1 2 | certifi==2019.9.11 3 | chardet==3.0.4 4 | Click==7.0 5 | idna==2.8 6 | loguru==0.3.2 7 | numpy==1.17.2 8 | pandas==0.25.1 9 | python-dateutil==2.8.0 10 | pytz==2019.2 11 | requests==2.22.0 12 | six==1.12.0 13 | tabulate==0.8.3 14 | tmdbsimple==2.2.0 15 | urllib3==1.25.3 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific stuff: 2 | /.idea 3 | 4 | ## File-based project format: 5 | *.iws 6 | 7 | # IntelliJ 8 | /out/ 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | *.pyc 15 | 16 | # logs 17 | *.log* 18 | 19 | # databases 20 | /*.db 21 | 22 | # configs 23 | *.cfg 24 | *.json 25 | 26 | # generators 27 | *.bat 28 | 29 | # Pyenv 30 | **/.python-version 31 | 32 | # Venv 33 | venv/ 34 | -------------------------------------------------------------------------------- /utils/misc.py: -------------------------------------------------------------------------------- 1 | try: 2 | from shlex import quote as cmd_quote 3 | except ImportError: 4 | from pipes import quote as cmd_quote 5 | 6 | from urllib.parse import urljoin 7 | 8 | 9 | class Singleton(type): 10 | _instances = {} 11 | 12 | def __call__(cls, *args, **kwargs): 13 | if cls not in cls._instances: 14 | cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) 15 | 16 | return cls._instances[cls] 17 | 18 | 19 | def quote_string(string_to_quote): 20 | return cmd_quote(string_to_quote) 21 | 22 | 23 | def valid_dict_item(dict_to_check, item_key): 24 | if item_key not in dict_to_check or not dict_to_check[item_key]: 25 | return False 26 | return True 27 | 28 | 29 | def url_join(url_base, path): 30 | return urljoin(url_base, path.lstrip('/')) 31 | 32 | 33 | def dict_contains_keys(dict_to_check, keys): 34 | for key in keys: 35 | if key not in dict_to_check: 36 | return False 37 | return True 38 | -------------------------------------------------------------------------------- /plex/library.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | from utils import sql 4 | 5 | 6 | def find_library_type(database_path, library_name): 7 | query_str = """SELECT 8 | ls.id, ls.name, ls.section_type 9 | FROM library_sections ls 10 | WHERE ls.name = ? 11 | LIMIT 1""" 12 | # lookup info 13 | result = sql.get_query_result(database_path, query_str, [library_name]) 14 | if result is None: 15 | logger.debug(f"Failed to find library with name: {library_name!r}") 16 | return None 17 | 18 | # check we have a valid result 19 | if 'id' in result and 'section_type' in result: 20 | logger.debug(f"Found library with name: {library_name!r} (ID: {result['id']} - Type: {result['section_type']})") 21 | return result['section_type'] 22 | 23 | # we have an unexpected result? 24 | logger.debug(f"Failed to find library with name: {library_name!r}, unexpected query result:\n{result}") 25 | return None 26 | -------------------------------------------------------------------------------- /utils/sql.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | from contextlib import closing 3 | 4 | from loguru import logger 5 | 6 | 7 | def get_query_results(database_path, query_str, query_args): 8 | logger.trace(f"Running query {query_str!r} with args: {query_args}") 9 | try: 10 | with sqlite3.connect(database_path) as conn: 11 | conn.row_factory = sqlite3.Row 12 | with closing(conn.cursor()) as c: 13 | query_results = c.execute(query_str, query_args).fetchall() 14 | if not query_results: 15 | logger.debug(f"No results were found from query") 16 | return [] 17 | 18 | results = [dict(result) for result in query_results] 19 | logger.debug(f"Found {len(results)} results from query") 20 | logger.trace(results) 21 | return results 22 | except Exception: 23 | logger.exception(f"Exception running query {query_str!r}: ") 24 | return None 25 | 26 | 27 | def get_query_result(database_path, query_str, query_args): 28 | logger.trace(f"Running query {query_str!r} with args: {query_args}") 29 | try: 30 | with sqlite3.connect(database_path) as conn: 31 | conn.row_factory = sqlite3.Row 32 | with closing(conn.cursor()) as c: 33 | query_result = c.execute(query_str, query_args).fetchone() 34 | if not query_result: 35 | logger.debug(f"No result was found from query") 36 | return {} 37 | 38 | result = dict(query_result) 39 | logger.trace(f"Found result: {result}") 40 | return result 41 | except Exception: 42 | logger.exception(f"Exception running query {query_str!r}: ") 43 | return None 44 | -------------------------------------------------------------------------------- /utils/sheets.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | from loguru import logger 3 | 4 | from . import themoviedb 5 | 6 | SHEETS_URL = 'https://docs.google.com/spreadsheets/d/e/' \ 7 | '2PACX-1vTXDpwSDxKxWHNEfYSqnlaC_GVxzVavu7iPuAnEa_7LEGzhiQS29fD_1tplegJvljE5Zy1MB63umLzk/pub?output=csv' 8 | 9 | 10 | def get_sheets_collection(id): 11 | logger.debug(f"Retrieving collection from sheets with id: {id!r}") 12 | try: 13 | # open sheet 14 | collections = pd.read_csv(SHEETS_URL, index_col=0) 15 | 16 | # parse item 17 | item = collections.loc[int(id), :] 18 | collection_name = item[0] 19 | collection_poster = item[1] 20 | collection_summary = item[2] 21 | collection_parts = item[3].split(',') 22 | logger.debug(f"Found sheets collection: {collection_name!r} with {len(collection_parts)} parts") 23 | 24 | # build collection details 25 | collection_details = { 26 | 'name': collection_name, 27 | 'poster_url': collection_poster, 28 | 'overview': collection_summary, 29 | 'parts': [] 30 | } 31 | for collection_part in collection_parts: 32 | # validate tmdb id is valid 33 | trimmed_tmdb_id = collection_part.strip() 34 | if not trimmed_tmdb_id.isalnum(): 35 | logger.error(f"Collection {collection_name!r} had an invalid part: {trimmed_tmdb_id!r}") 36 | continue 37 | 38 | # lookup tmdb movie details 39 | movie_details = themoviedb.get_tmdb_id_details(trimmed_tmdb_id) 40 | if movie_details is not None: 41 | collection_details['parts'].append(movie_details) 42 | 43 | return collection_details 44 | 45 | except Exception: 46 | logger.exception(f"Exception retrieving collection from sheets with id {id!r}: ") 47 | return None 48 | -------------------------------------------------------------------------------- /utils/themoviedb.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import tmdbsimple as tmdb 4 | from loguru import logger 5 | 6 | from . import misc 7 | 8 | TMDB_KEY = 'da6bf4ac38be518f95bbb3c309fad7b9' 9 | 10 | 11 | def get_tmdb_id_details(tmdb_id): 12 | logger.debug(f"Retrieving movie details for: {tmdb_id!r}") 13 | 14 | # set api key 15 | tmdb.API_KEY = TMDB_KEY 16 | 17 | # retrieve movie details 18 | try: 19 | movie = tmdb.Movies(tmdb_id).info() 20 | # validate response 21 | if not isinstance(movie, dict) or not misc.dict_contains_keys(movie, ['id', 'imdb_id', 'title']): 22 | logger.error(f"Failed retrieving movie details from Tmdb for: {tmdb_id!r}") 23 | return None 24 | 25 | # build response 26 | return { 27 | 'title': movie['title'], 28 | 'tmdb_id': movie['id'], 29 | 'imdb_id': movie['imdb_id'] 30 | } 31 | 32 | except Exception: 33 | logger.exception(f"Exception retrieving Tmdb movie details for {tmdb_id!r}: ") 34 | return None 35 | 36 | 37 | def get_tmdb_collection_parts(tmdb_id): 38 | logger.debug(f"Retrieving movie collection details for: {tmdb_id!r}") 39 | try: 40 | collection_details = {} 41 | 42 | # set api key 43 | tmdb.API_KEY = TMDB_KEY 44 | 45 | # get collection details 46 | details = tmdb.Collections(tmdb_id).info() 47 | if not isinstance(details, dict) or not misc.dict_contains_keys(details, 48 | ['id', 'name', 'parts', 'poster_path']): 49 | logger.error(f"Failed retrieving movie collection details from Tmdb for: {tmdb_id!r}") 50 | return None 51 | logger.trace(f"Retrieved Tmdb movie collection details for {tmdb_id!r}:") 52 | logger.trace(json.dumps(details, indent=2)) 53 | 54 | # build collection_details 55 | collection_details['name'] = details['name'] 56 | collection_details['poster_url'] = f"https://image.tmdb.org/t/p/original{details['poster_path']}" 57 | collection_details['overview'] = details['overview'] if 'overview' in details else '' 58 | 59 | for collection_part in details['parts']: 60 | if not misc.dict_contains_keys(collection_part, ['id', 'title']): 61 | logger.error(f"Failed processing collection part due to unexpected keys: {collection_part}") 62 | return None 63 | 64 | # retrieve movie details 65 | logger.debug(f"Retrieving movie details for collection part: {collection_part['title']!r} - " 66 | f"TmdbId: {collection_part['id']}") 67 | movie = tmdb.Movies(collection_part['id']).info() 68 | 69 | # validate response 70 | if not isinstance(movie, dict) or not misc.dict_contains_keys(movie, ['id', 'imdb_id', 'title']): 71 | logger.error(f"Failed retrieving movie details from Tmdb for: {collection_part['title']!r} - " 72 | f"TmdbId: {collection_part['id']}") 73 | return None 74 | 75 | # add part to collection details 76 | if 'parts' in collection_details: 77 | collection_details['parts'].append({ 78 | 'title': movie['title'], 79 | 'tmdb_id': movie['id'], 80 | 'imdb_id': movie['imdb_id'] 81 | }) 82 | else: 83 | collection_details['parts'] = [{ 84 | 'title': movie['title'], 85 | 'tmdb_id': movie['id'], 86 | 'imdb_id': movie['imdb_id'] 87 | }] 88 | 89 | logger.trace(f"Retrieved Tmdb movie details for {collection_part['id']!r}:") 90 | logger.trace(json.dumps(movie, indent=2)) 91 | 92 | logger.debug(json.dumps(collection_details, indent=2)) 93 | return collection_details 94 | 95 | except Exception: 96 | logger.exception(f"Exception retrieving Tmdb collection details for {tmdb_id!r}: ") 97 | return None 98 | -------------------------------------------------------------------------------- /plex/metadata.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | from utils import sql 4 | from . import library 5 | 6 | METADATA_MISSING_QUERY_STRINGS = { 7 | '1': """SELECT 8 | ls.name as library_name 9 | , md.* 10 | FROM metadata_items md 11 | JOIN library_sections ls ON ls.id = md.library_section_id 12 | WHERE 13 | ls.name = ? 14 | AND md.metadata_type = 1 15 | AND (md.user_thumb_url like 'media://%' OR md.user_thumb_url = '') 16 | ORDER BY md.added_at ASC""", 17 | '2': """SELECT 18 | ls.name as library_name 19 | , md.* 20 | FROM metadata_items md 21 | JOIN library_sections ls ON ls.id = md.library_section_id 22 | WHERE 23 | ls.name = ? 24 | AND md.metadata_type = 2 25 | AND md.user_thumb_url = '' 26 | ORDER BY md.added_at ASC""" 27 | } 28 | 29 | 30 | def find_items_missing_posters(database_path, library_name): 31 | logger.debug(f"Finding items with missing posters from library: {library_name!r}") 32 | 33 | # determine library type 34 | library_type = library.find_library_type(database_path, library_name) 35 | if library_type is None: 36 | logger.error(f"Failed to find library type for library: {library_name!r}") 37 | return None 38 | 39 | # do we have a query for this library type 40 | if str(library_type) not in METADATA_MISSING_QUERY_STRINGS: 41 | logger.error(f"Unable to find items with missing posters for this library type: {library_type!r}") 42 | return None 43 | 44 | # find items 45 | query_str = METADATA_MISSING_QUERY_STRINGS[str(library_type)] 46 | return sql.get_query_results(database_path, query_str, [library_name]) 47 | 48 | 49 | def find_items_unanalyzed(database_path, library_name): 50 | logger.debug(f"Finding items without analysis from library: {library_name!r}") 51 | 52 | # build query_str 53 | query_str = """select 54 | ls.name as library_name 55 | , mp.file 56 | , mi.* 57 | from media_items mi 58 | join media_parts mp on mp.media_item_id = mi.id 59 | join library_sections ls on ls.id = mi.library_section_id 60 | where mi.bitrate is null and ls.section_type in (1, 2) and ls.name = ?""" 61 | 62 | # retrieve results 63 | return sql.get_query_results(database_path, query_str, [library_name]) 64 | 65 | 66 | def get_metadata_item_id(database_path, metadata_item_id): 67 | logger.debug(f"Finding metadata_item details for id: {metadata_item_id!r}") 68 | 69 | # build query_str 70 | query_str = """SELECT 71 | mi.id 72 | , mi.library_section_id 73 | , mi.metadata_type 74 | , mi.guid 75 | FROM metadata_items mi 76 | WHERE mi.id = ?""" 77 | 78 | # retrieve result 79 | return sql.get_query_result(database_path, query_str, [metadata_item_id]) 80 | 81 | 82 | def get_metadata_item_by_guid(database_path, library_name, guid): 83 | logger.debug(f"Finding metadata_item details from library {library_name!r} with guid: {guid!r}") 84 | 85 | # build query_str 86 | query_str = """SELECT 87 | ls.name 88 | , mi.id 89 | , mi.guid 90 | , mi.title 91 | , mi.year 92 | FROM metadata_items mi 93 | JOIN library_sections ls ON ls.id = mi.library_section_id 94 | WHERE 95 | ls.name = ? 96 | AND 97 | mi.guid = ?""" 98 | 99 | # retrieve result 100 | return sql.get_query_result(database_path, query_str, [library_name, guid]) 101 | 102 | 103 | def get_metadata_item_of_collection(database_path, library_name, collection_name): 104 | logger.debug(f"Finding metadata_item details from library {library_name!r} for collection: {collection_name!r}") 105 | 106 | # build query_str 107 | query_str = """SELECT 108 | mi.id 109 | , mi.guid 110 | , mi.title 111 | FROM metadata_items mi 112 | JOIN library_sections ls ON ls.id = mi.library_section_id 113 | WHERE ls.name = ? and metadata_type = 18 AND mi.guid LIKE 'collection://%' 114 | AND mi.title = ?""" 115 | 116 | # retrieve result 117 | return sql.get_query_result(database_path, query_str, [library_name, collection_name]) 118 | -------------------------------------------------------------------------------- /utils/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import sys 4 | from collections import OrderedDict 5 | 6 | from attrdict import AttrDict 7 | 8 | from utils import misc 9 | 10 | json.encoder.c_make_encoder = None 11 | 12 | 13 | class AttrConfig(AttrDict): 14 | """ 15 | Simple AttrDict subclass to return None when requested attribute does not exist 16 | """ 17 | 18 | def __init__(self, config): 19 | super().__init__(config) 20 | 21 | def __getattr__(self, item): 22 | try: 23 | return super().__getattr__(item) 24 | except AttributeError: 25 | pass 26 | # Default behaviour 27 | return None 28 | 29 | 30 | class Config(object, metaclass=misc.Singleton): 31 | base_config = OrderedDict({ 32 | # plex 33 | 'plex': { 34 | 'database_path': '', 35 | 'url': 'https://plex.domain.com', 36 | 'token': '' 37 | } 38 | }) 39 | 40 | def __init__(self, config_path): 41 | """Initializes config""" 42 | self.conf = OrderedDict({}) 43 | self.config_path = config_path 44 | 45 | @property 46 | def cfg(self): 47 | # Return existing loaded config 48 | if self.conf: 49 | return self.conf 50 | 51 | # Built initial config if it doesn't exist 52 | if self.build_config(): 53 | print("Please edit the default configuration before running again!") 54 | sys.exit(0) 55 | # Load config, upgrade if necessary 56 | else: 57 | tmp = self.load_config() 58 | self.conf, upgraded = self.upgrade_settings(tmp) 59 | 60 | # Save config if upgraded 61 | if upgraded: 62 | self.dump_config() 63 | print("New config options were added, adjust and restart!") 64 | sys.exit(0) 65 | 66 | return self.conf 67 | 68 | @property 69 | def default_config(self): 70 | config = self.base_config 71 | return config 72 | 73 | def build_config(self): 74 | if not os.path.exists(self.config_path): 75 | print("Dumping default config to: %s" % self.config_path) 76 | with open(self.config_path, 'w') as fp: 77 | json.dump(self.default_config, fp, sort_keys=False, indent=2, default=str) 78 | return True 79 | else: 80 | return False 81 | 82 | def dump_config(self): 83 | if os.path.exists(self.config_path): 84 | with open(self.config_path, 'w') as fp: 85 | json.dump(self.conf, fp, sort_keys=False, indent=2, default=str) 86 | return True 87 | else: 88 | return False 89 | 90 | def load_config(self): 91 | with open(self.config_path, 'r') as fp: 92 | return AttrConfig(json.load(fp, object_hook=OrderedDict)) 93 | 94 | def __inner_upgrade(self, settings1, settings2, key=None, overwrite=False): 95 | sub_upgraded = False 96 | merged = settings2.copy() 97 | 98 | if isinstance(settings1, dict): 99 | for k, v in settings1.items(): 100 | # missing k 101 | if k not in settings2: 102 | merged[k] = v 103 | sub_upgraded = True 104 | if not key: 105 | print("Added %r config option: %s" % (str(k), str(v))) 106 | else: 107 | print("Added %r to config option %r: %s" % (str(k), str(key), str(v))) 108 | continue 109 | 110 | # iterate children 111 | if isinstance(v, dict) or isinstance(v, list): 112 | merged[k], did_upgrade = self.__inner_upgrade(settings1[k], settings2[k], key=k, 113 | overwrite=overwrite) 114 | sub_upgraded = did_upgrade if did_upgrade else sub_upgraded 115 | elif settings1[k] != settings2[k] and overwrite: 116 | merged = settings1 117 | sub_upgraded = True 118 | elif isinstance(settings1, list) and key: 119 | for v in settings1: 120 | if v not in settings2: 121 | merged.append(v) 122 | sub_upgraded = True 123 | print("Added to config option %r: %s" % (str(key), str(v))) 124 | continue 125 | 126 | return merged, sub_upgraded 127 | 128 | def upgrade_settings(self, currents): 129 | upgraded_settings, upgraded = self.__inner_upgrade(self.base_config, currents) 130 | return AttrConfig(upgraded_settings), upgraded 131 | 132 | def merge_settings(self, settings_to_merge): 133 | upgraded_settings, upgraded = self.__inner_upgrade(settings_to_merge, self.conf, overwrite=True) 134 | 135 | self.conf = upgraded_settings 136 | 137 | if upgraded: 138 | self.dump_config() 139 | 140 | return AttrConfig(upgraded_settings), upgraded 141 | -------------------------------------------------------------------------------- /plex/actions.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from loguru import logger 3 | 4 | from utils import misc 5 | from . import metadata 6 | 7 | 8 | def refresh_item_metadata(cfg, metadata_item_id): 9 | try: 10 | plex_refresh_url = misc.urljoin(cfg.plex.url, f'/library/metadata/{metadata_item_id}/refresh') 11 | params = { 12 | 'X-Plex-Token': cfg.plex.token 13 | } 14 | 15 | # send refresh request 16 | logger.debug(f"Sending refresh metadata request to: {plex_refresh_url}") 17 | resp = requests.put(plex_refresh_url, params=params, verify=False, timeout=30) 18 | 19 | logger.trace(f"Request URL: {resp.url}") 20 | logger.trace(f"Response: {resp.status_code} {resp.reason}") 21 | 22 | if resp.status_code != 200: 23 | logger.error(f"Failed refreshing metadata for item {metadata_item_id!r}: {resp.status_code} {resp.reason}") 24 | return False 25 | 26 | return True 27 | 28 | except Exception: 29 | logger.exception(f"Exception refreshing Plex metadata for item {metadata_item_id}: ") 30 | return False 31 | 32 | 33 | def analyze_metadata_item(cfg, metadata_item_id): 34 | try: 35 | plex_analyze_url = misc.urljoin(cfg.plex.url, f'/library/metadata/{metadata_item_id}/analyze') 36 | params = { 37 | 'X-Plex-Token': cfg.plex.token 38 | } 39 | 40 | # send refresh request 41 | logger.debug(f"Sending analyze metadata_item_id request to: {plex_analyze_url}") 42 | resp = requests.put(plex_analyze_url, params=params, verify=False, timeout=600) 43 | 44 | logger.trace(f"Request URL: {resp.url}") 45 | logger.trace(f"Response: {resp.status_code} {resp.reason}") 46 | 47 | if resp.status_code != 200: 48 | logger.error(f"Failed analyzing metadata_item {metadata_item_id!r}: {resp.status_code} {resp.reason}") 49 | return False 50 | 51 | return True 52 | except Exception: 53 | logger.exception(f"Exception analyzing Plex metadata_item_id {metadata_item_id}: ") 54 | return False 55 | 56 | 57 | def set_metadata_item_collection(cfg, metadata_item_id, collection_name): 58 | try: 59 | # retrieve metadata_item_id details 60 | result = metadata.get_metadata_item_id(cfg.plex.database_path, metadata_item_id) 61 | if not result or not misc.dict_contains_keys(result, ['id', 'library_section_id', 'metadata_type']): 62 | logger.error(f"Unable to find metadata_item with id: {metadata_item_id!r}") 63 | return False 64 | 65 | # we have the details we need to build a metadata_item update 66 | plex_update_url = misc.urljoin(cfg.plex.url, f"/library/sections/{result['library_section_id']}/all") 67 | params = { 68 | 'X-Plex-Token': cfg.plex.token, 69 | 'id': metadata_item_id, 70 | 'type': result['metadata_type'], 71 | 'collection[0].tag.tag': collection_name, 72 | 'includeExternalMedia': 1 73 | } 74 | 75 | # send update request 76 | logger.debug(f"Sending update metadata_item_id request to: {plex_update_url}") 77 | resp = requests.put(plex_update_url, params=params, verify=False, timeout=600) 78 | 79 | logger.trace(f"Request URL: {resp.url}") 80 | logger.trace(f"Response: {resp.status_code} {resp.reason}") 81 | 82 | if resp.status_code != 200: 83 | logger.error(f"Failed updating metadata_item {metadata_item_id!r}: {resp.status_code} {resp.reason}") 84 | return False 85 | 86 | return True 87 | 88 | except Exception: 89 | logger.exception(f"Exception updating metadata_item with id {metadata_item_id!r} to be in the " 90 | f"{collection_name!r} collection: ") 91 | return False 92 | 93 | 94 | def set_metadata_item_summary(cfg, metadata_item_id, summary): 95 | try: 96 | # retrieve metadata_item_id details 97 | result = metadata.get_metadata_item_id(cfg.plex.database_path, metadata_item_id) 98 | if not result or not misc.dict_contains_keys(result, ['id', 'library_section_id', 'metadata_type']): 99 | logger.error(f"Unable to find metadata_item with id: {metadata_item_id!r}") 100 | return False 101 | 102 | # we have the details we need to build a metadata_item update 103 | plex_update_url = misc.urljoin(cfg.plex.url, f"/library/sections/{result['library_section_id']}/all") 104 | params = { 105 | 'X-Plex-Token': cfg.plex.token, 106 | 'id': metadata_item_id, 107 | 'type': result['metadata_type'], 108 | 'summary.value': summary, 109 | 'includeExternalMedia': 1 110 | } 111 | 112 | # send update request 113 | logger.debug(f"Sending update metadata_item_id request to: {plex_update_url}") 114 | resp = requests.put(plex_update_url, params=params, verify=False, timeout=600) 115 | 116 | logger.trace(f"Request URL: {resp.url}") 117 | logger.trace(f"Response: {resp.status_code} {resp.reason}") 118 | 119 | if resp.status_code != 200: 120 | logger.error(f"Failed updating metadata_item {metadata_item_id!r}: {resp.status_code} {resp.reason}") 121 | return False 122 | 123 | return True 124 | 125 | except Exception: 126 | logger.exception(f"Exception updating the summary of metadata_item with id {metadata_item_id!r}") 127 | return False 128 | 129 | 130 | def set_metadata_item_poster(cfg, metadata_item_id, poster_url): 131 | try: 132 | plex_update_url = misc.urljoin(cfg.plex.url, f"/library/metadata/{metadata_item_id}/posters") 133 | params = { 134 | 'X-Plex-Token': cfg.plex.token, 135 | 'includeExternalMedia': 1, 136 | 'url': poster_url 137 | } 138 | 139 | # send update request 140 | logger.debug(f"Sending update metadata_item_id request to {plex_update_url}") 141 | resp = requests.post(plex_update_url, params=params, verify=False, timeout=600) 142 | 143 | logger.trace(f"Request URL: {resp.url}") 144 | logger.trace(f"Response: {resp.status_code} {resp.reason}") 145 | 146 | if resp.status_code != 200: 147 | logger.error(f"Failed updating metadata_item {metadata_item_id!r}: {resp.status_code} {resp.reason}") 148 | return False 149 | 150 | return True 151 | 152 | except Exception: 153 | logger.exception(f"Exception updating poster for metadata_item with id {metadata_item_id!r} to {poster_url!r}:") 154 | return False 155 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import json 3 | import logging 4 | import os 5 | import sys 6 | import time 7 | 8 | import click 9 | import requests 10 | from loguru import logger 11 | from requests.packages.urllib3.exceptions import InsecureRequestWarning 12 | from tabulate import tabulate 13 | 14 | import plex 15 | from utils import misc, themoviedb, sheets 16 | 17 | ############################################################ 18 | # INIT 19 | ############################################################ 20 | 21 | 22 | # Globals 23 | cfg = None 24 | manager = None 25 | 26 | # Logging 27 | logging.getLogger("requests").setLevel(logging.WARNING) 28 | logging.getLogger("urllib3").setLevel(logging.WARNING) 29 | requests.packages.urllib3.disable_warnings(InsecureRequestWarning) 30 | 31 | 32 | # Click 33 | @click.group(help='plex_db_tools') 34 | @click.version_option('1.0.0', prog_name='plex_db_tools') 35 | @click.option( 36 | '-v', '--verbose', 37 | envvar="LOG_LEVEL", 38 | count=True, 39 | default=0, 40 | help='Adjust the logging level') 41 | @click.option( 42 | '--config-path', 43 | envvar='CONFIG_PATH', 44 | type=click.Path(file_okay=True, dir_okay=False), 45 | help='Configuration filepath', 46 | show_default=True, 47 | default=os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "config.json") 48 | ) 49 | @click.option( 50 | '--log-path', 51 | envvar='LOG_PATH', 52 | type=click.Path(file_okay=True, dir_okay=False), 53 | help='Log filepath', 54 | show_default=True, 55 | default=os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), "activity.log") 56 | ) 57 | def app(verbose, config_path, log_path): 58 | global cfg 59 | 60 | # Ensure paths are full paths 61 | if not config_path.startswith(os.path.sep): 62 | config_path = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), config_path) 63 | if not log_path.startswith(os.path.sep): 64 | log_path = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), log_path) 65 | 66 | # Load config 67 | from utils.config import Config 68 | cfg = Config(config_path=config_path).cfg 69 | 70 | # Load logger 71 | log_levels = {0: 'INFO', 1: 'DEBUG', 2: 'TRACE'} 72 | log_level = log_levels[verbose] if verbose in log_levels else 'TRACE' 73 | config_logger = { 74 | 'handlers': [ 75 | {'sink': sys.stdout, 'backtrace': True if verbose >= 2 else False, 'level': log_level}, 76 | {'sink': log_path, 77 | 'rotation': '30 days', 78 | 'retention': '7 days', 79 | 'enqueue': True, 80 | 'backtrace': True if verbose >= 2 else False, 81 | 'level': log_level} 82 | ] 83 | } 84 | logger.configure(**config_logger) 85 | 86 | # Display params 87 | logger.info("%s = %r" % ("CONFIG_PATH".ljust(12), config_path)) 88 | logger.info("%s = %r" % ("LOG_PATH".ljust(12), log_path)) 89 | logger.info("%s = %r" % ("LOG_LEVEL".ljust(12), log_level)) 90 | return 91 | 92 | 93 | ############################################################ 94 | # COMMANDS 95 | ############################################################ 96 | 97 | @app.command(help='Find unalayzed media items') 98 | @click.option( 99 | '-l', '--library', 100 | help='Library to search for unanalyzed items', required=True 101 | ) 102 | @click.option('--auto-mode', '-a', required=False, default='0', help='Automatically perform specific action') 103 | def unanalyzed_media(library, auto_mode): 104 | global cfg 105 | 106 | # retrieve items with unanalyzed media 107 | results = plex.metadata.find_items_unanalyzed(cfg.plex.database_path, library) 108 | if results is None: 109 | logger.error(f"Failed to find unanalyzed media items for library: {library!r}") 110 | sys.exit(1) 111 | 112 | if not results: 113 | logger.info(f"There were no media items without analysis in library: {library!r}") 114 | sys.exit(0) 115 | 116 | logger.info(f"Found {len(results)} media items without analysis in library: {library!r}") 117 | 118 | # process found items 119 | for item in results: 120 | if 'file' not in item or 'metadata_item_id' not in item: 121 | logger.debug(f"Skipping item as there was no title or metadata_item_id found: {item}") 122 | continue 123 | 124 | logger.info(f"Media analysis was required for: {item['file']}") 125 | 126 | if auto_mode == '0': 127 | # ask user what to-do 128 | logger.info("What would you like to-do with this item? (0 = skip, 1 = analyze)") 129 | user_input = input() 130 | if user_input is None or user_input == '0': 131 | continue 132 | else: 133 | # user the determined auto mode 134 | user_input = auto_mode 135 | 136 | # act on user input 137 | if user_input == '1': 138 | # do analyze 139 | logger.debug("Analyzing metadata...") 140 | if plex.actions.analyze_metadata_item(cfg, item['metadata_item_id']): 141 | logger.info("Media analysis successful!") 142 | else: 143 | continue 144 | 145 | logger.info("Finished") 146 | sys.exit(0) 147 | 148 | 149 | @app.command(help='Find missing posters') 150 | @click.option( 151 | '-l', '--library', 152 | help='Library to search for missing posters', required=True) 153 | @click.option('--auto-mode', '-a', required=False, default='0', help='Automatically perform specific action') 154 | def missing_posters(library, auto_mode): 155 | global cfg 156 | 157 | # retrieve items with missing posters 158 | results = plex.metadata.find_items_missing_posters(cfg.plex.database_path, library) 159 | if results is None: 160 | logger.error(f"Failed to find missing posters for library: {library!r}") 161 | sys.exit(1) 162 | 163 | if not results: 164 | logger.info(f"There were no items with missing posters in library: {library!r}") 165 | sys.exit(0) 166 | 167 | logger.info(f"Found {len(results)} items with missing posters in the library: {library!r}") 168 | 169 | # process found items 170 | for item in results: 171 | if 'title' not in item or 'id' not in item: 172 | logger.debug(f"Skipping item as there was no title or metadata_item_id found: {item}") 173 | continue 174 | 175 | # build table data for this item 176 | table_data = [ 177 | # Library 178 | ['Library', item['library_name'] if misc.valid_dict_item(item, 'library_name') else ''] 179 | # Metadata Item ID 180 | , ['ID', item['id']] 181 | # GUID 182 | , ['GUID', item['guid'] if misc.valid_dict_item(item, 'guid') else ''] 183 | # Poster 184 | , ['Poster', item['user_thumb_url'] if misc.valid_dict_item(item, 'user_thumb_url') else ''] 185 | # Added date 186 | , ['Added', item['added_at'] if misc.valid_dict_item(item, 'added_at') else ''] 187 | ] 188 | 189 | # show user information 190 | logger.info(f"Item with missing poster, {item['title']} " 191 | f"({item['year'] if misc.valid_dict_item(item, 'year') else '????'}):" 192 | f"\n{tabulate(table_data)}") 193 | 194 | if auto_mode == '0': 195 | # ask user what to-do 196 | logger.info("What would you like to-do with this item? (0 = skip, 1 = refresh)") 197 | user_input = input() 198 | if user_input is None or user_input == '0': 199 | continue 200 | else: 201 | # user the determined auto mode 202 | user_input = auto_mode 203 | 204 | # act on user input 205 | if user_input == '1': 206 | # do refresh 207 | logger.debug("Refreshing metadata...") 208 | if plex.actions.refresh_item_metadata(cfg, item['id']): 209 | logger.info("Refreshed metadata!") 210 | else: 211 | continue 212 | 213 | logger.info("Finished") 214 | sys.exit(0) 215 | 216 | 217 | @app.command(help='Create or update movie collection') 218 | @click.option( 219 | '-l', '--library', 220 | help='Library to search for missing posters', required=True) 221 | @click.option( 222 | '-i', '--tmdb-id', 223 | help='The Movie Database Collection ID', required=False 224 | ) 225 | @click.option( 226 | '-s', '--sheets-id', 227 | help='Cloudbox Sheets Collection ID', required=False 228 | ) 229 | def create_update_collection(library, tmdb_id, sheets_id): 230 | if not tmdb_id and not sheets_id: 231 | logger.error("You must specify either a Tmdb ID or a Sheets ID!") 232 | sys.exit(1) 233 | 234 | if tmdb_id: 235 | logger.info(f"Retrieving details for Tmdb collection: {tmdb_id!r}") 236 | 237 | # retrieve collection details 238 | collection_details = themoviedb.get_tmdb_collection_parts(tmdb_id) 239 | if not collection_details or not misc.dict_contains_keys(collection_details, ['name', 'poster_url', 'parts']): 240 | logger.error(f"Failed retrieving details of Tmdb collection: {tmdb_id!r}") 241 | sys.exit(1) 242 | 243 | logger.info( 244 | f"Retrieved collection details: {collection_details['name']!r}, {len(collection_details['parts'])} parts") 245 | else: 246 | logger.info(f"Retrieving details for Sheets collection: {sheets_id!r}") 247 | collection_details = sheets.get_sheets_collection(sheets_id) 248 | 249 | # iterate collection items assigning them to the collection 250 | for item in collection_details['parts']: 251 | # try to find item by imdb guid 252 | plex_item_details = plex.metadata.get_metadata_item_by_guid(cfg.plex.database_path, library, 253 | f"com.plexapp.agents.imdb://{item['imdb_id']}" 254 | f"?lang=en") 255 | if not plex_item_details: 256 | # fallback to tmdb guid 257 | plex_item_details = plex.metadata.get_metadata_item_by_guid(cfg.plex.database_path, library, 258 | f"com.plexapp.agents.themoviedb://" 259 | f"{item['tmdb_id']}?lang=en") 260 | 261 | if not plex_item_details or not misc.dict_contains_keys(plex_item_details, ['id', 'guid', 'title', 'year']): 262 | logger.warning( 263 | f"Failed to find collection item in library: {library!r} - {item['title']}: {json.dumps(item)}") 264 | continue 265 | 266 | # we have a plex item, lets assign it to the category 267 | logger.debug( 268 | f"Adding {plex_item_details['title']} ({plex_item_details['year']}) to collection: " 269 | f"{collection_details['name']!r}") 270 | 271 | if plex.actions.set_metadata_item_collection(cfg, plex_item_details['id'], collection_details['name']): 272 | logger.info( 273 | f"Added {plex_item_details['title']} ({plex_item_details['year']}) to collection: " 274 | f"{collection_details['name']!r}") 275 | time.sleep(2) 276 | else: 277 | logger.error( 278 | f"Failed adding {plex_item_details['title']} ({plex_item_details['year']}) to collection: " 279 | f"{collection_details['name']!r}") 280 | sys.exit(1) 281 | 282 | # locate collection in database 283 | logger.info("Sleeping 10 seconds before attempting to locate the collection in database") 284 | time.sleep(10) 285 | 286 | # lookup collection metadata_item_id 287 | collection_metadata = plex.metadata.get_metadata_item_of_collection(cfg.plex.database_path, library, 288 | collection_details['name']) 289 | if not collection_metadata or not misc.dict_contains_keys(collection_metadata, ['id', 'guid']): 290 | logger.error( 291 | f"Failed to find collection in the Plex library {library!r} with name: {collection_details['name']!r}") 292 | sys.exit(1) 293 | 294 | # set poster 295 | if collection_details['poster_url']: 296 | if not plex.actions.set_metadata_item_poster(cfg, collection_metadata['id'], collection_details['poster_url']): 297 | logger.error(f"Failed setting collection poster to: {collection_details['poster_url']!r}") 298 | sys.exit(1) 299 | 300 | logger.info(f"Updated collection poster to: {collection_details['poster_url']!r}") 301 | 302 | # set overview 303 | if collection_details['overview']: 304 | logger.info("Sleeping 5 seconds before setting collection summary") 305 | time.sleep(5) 306 | if not plex.actions.set_metadata_item_summary(cfg, collection_metadata['id'], collection_details['overview']): 307 | logger.error(f"Failed setting collection summary to: {collection_details['overview']!r}") 308 | sys.exit(1) 309 | 310 | logger.info(f"Updated collection summary to: {collection_details['overview']!r}") 311 | 312 | logger.info("Finished!") 313 | sys.exit(0) 314 | 315 | 316 | ############################################################ 317 | # MAIN 318 | ############################################################ 319 | 320 | if __name__ == "__main__": 321 | app() 322 | --------------------------------------------------------------------------------