├── .env.example ├── .envrc ├── .flake8 ├── .github └── workflows │ └── ruff.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Kometa ├── .env.example ├── README.md ├── clean-overlay-backup.py ├── collection.tmpl ├── extract-collections.py ├── helpers.py ├── kometa-helpers.py ├── kometa-mal-auth.py ├── kometa-trakt-auth.py ├── logs.py ├── metadata-extractor.py ├── originals-to-assets.py ├── overlay-default-posters.py ├── template.tmpl └── top-n-actor-coll.py ├── LICENSE ├── Plex Image Picker ├── .envrc ├── Dockerfile ├── README.md ├── app.py ├── requirements.txt ├── static │ └── theme.js └── templates │ ├── base.html │ ├── connect.html │ ├── item.html │ ├── items.html │ └── libraries.html ├── Plex ├── .env.example ├── .vscode │ └── launch.json ├── ID-notes.txt ├── README.md ├── actor-count.py ├── adjust-added-dates.py ├── apply-all-status.py ├── build-assets-tmdb.py ├── changes.txt ├── crew-count.py ├── database.py ├── delete-collections.py ├── grab-all-IDs.py ├── grab-all-info.py ├── grab-all-posters.py ├── grab-all-status.py ├── grab-imdb-posters.py ├── helpers.py ├── ids.csv ├── ids.sqlite ├── import-IDs.py ├── list-collections.py ├── list-item-ids.py ├── list-libraries.py ├── list-low-poster-counts.py ├── loading.gif ├── logs.py ├── mediascripts.sqlite.HIDDEN ├── pumpanddump.sh ├── refresh-metadata.py ├── rematch-items.py ├── reset-posters-plex.py ├── reset-posters-tmdb.py ├── reverse-genres.py ├── set-user-rating.py ├── show-all-playlists.py ├── templates │ ├── category.html │ ├── direct_shows.html │ ├── home.html │ ├── index.html │ ├── library.html │ ├── loading.gif │ └── show.html └── user-emails.py ├── README.md ├── TMDB ├── .env.example ├── README.md ├── get_TMDB_Original_Language.py ├── people_list.txt ├── requirements.txt └── tmdb-people.py ├── app.py ├── requirements.txt └── ruff.toml /.env.example: -------------------------------------------------------------------------------- 1 | # PLEX API ENV VARS 2 | PLEXAPI_HEADER_IDENTIFIER="media-scripts" 3 | PLEXAPI_PLEXAPI_TIMEOUT='360' 4 | PLEXAPI_AUTH_SERVER_BASEURL=https://plex.domain.tld 5 | # Just the base URL, no /web or anything at the end. 6 | # i.e. http://192.168.1.11:32400 or the like 7 | PLEXAPI_AUTH_SERVER_TOKEN=PLEX-TOKEN 8 | PLEXAPI_LOG_BACKUP_COUNT='3' 9 | PLEXAPI_LOG_FORMAT='%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s' # PLEX API ENV VARS 10 | PLEXAPI_LOG_LEVEL='INFO' 11 | PLEXAPI_LOG_PATH='plexapi.log' 12 | PLEXAPI_LOG_ROTATE_BYTES='512000' 13 | PLEXAPI_LOG_SHOW_SECRETS=0 14 | PLEXAPI_SKIP_VERIFYSSL=0 # ignore self signed certificate errors 15 | 16 | # GENERAL ENV VARS 17 | TMDB_KEY=TMDB_API_KEY # https://developers.themoviedb.org/3/getting-started/introduction 18 | TVDB_KEY=TVDB_V4_API_KEY # currently not used; https://thetvdb.com/api-information 19 | DELAY=1 # optional delay between items 20 | LIBRARY_NAMES=Movies,TV Shows,Movies 4K # comma-separated list of libraries to act on 21 | 22 | # IMAGE DOWNLOAD ENV VARS 23 | ## what-to-grab 24 | GRAB_SEASONS=1 # should get-all-posters retrieve season posters? 25 | GRAB_EPISODES=1 # should get-all-posters retrieve episode posters? [requires GRAB_SEASONS] 26 | GRAB_BACKGROUNDS=1 # should get-all-posters retrieve backgrounds? 27 | ONLY_CURRENT=0 # should get-all-posters retrieve ONLY current artwork? 28 | ARTWORK=1 # current background is downloaded with current poster 29 | INCLUDE_COLLECTION_ARTWORK=1 # should get-all-posters retrieve collection posters? 30 | ONLY_COLLECTION_ARTWORK=0 # should get-all-posters retrieve ONLY collection posters? 31 | ONLY_THESE_COLLECTIONS=Bing|Bang|Boing # only grab artwork for these collections and items in them 32 | POSTER_DEPTH=20 # grab this many posters [0 grabs all] 33 | KEEP_JUNK=0 # keep files that script would normally delete [incorrect filetypes, mainly] 34 | FIND_OVERLAID_IMAGES=0 # check all downloaded images for overlays 35 | # RETAIN_OVERLAID_IMAGES=0 # keep images that have an overlay EXIF tag [this will override the following two] 36 | RETAIN_KOMETA_OVERLAID_IMAGES=0 # keep images that have the Kometa overlay EXIF tag 37 | RETAIN_TCM_OVERLAID_IMAGES=0 # keep images that have the TCM overlay EXIF tag 38 | 39 | ## where-to-put-it 40 | USE_ASSET_NAMING=1 # should grab-all-posters name images to match Kometa's Asset Directory requirements? 41 | USE_ASSET_FOLDERS=1 # should those Kometa-Asset-Directory names use asset folders? 42 | USE_ASSET_SUBFOLDERS=0 # create asset folders in subfolders ["Collections", "Other", or [0-9, A-Z]] ] 43 | ASSETS_BY_LIBRARIES=1 # should those Kometa-Asset-Directory images be sorted into library folders? 44 | ASSET_DIR=assets # top-level directory for those Kometa-Asset-Directory images 45 | # if asset-directory naming is on, the next three are ignored 46 | POSTER_DIR=extracted_posters # put downloaded posters here 47 | CURRENT_POSTER_DIR=current_posters # put downloaded current posters and artwork here 48 | POSTER_CONSOLIDATE=0 # if false, posters are separated into folders by library 49 | 50 | ## tracking 51 | TRACK_URLS=1 # If set to 1, URLS are tracked and won't be downloaded twice 52 | TRACK_COMPLETION=1 # If set to 1, movies/shows are tracked as complete by rating id 53 | TRACK_IMAGE_SOURCES=1 # keep a file containing file names and source URLs 54 | 55 | ## general 56 | POSTER_DOWNLOAD=1 # if false, generate a script rather than downloading 57 | FOLDERS_ONLY=0 # Just build out the folder hierarchy; no image downloading 58 | DEFAULT_YEARS_BACK=2 # in absence of a "last run date", grab things added this many years back. 59 | # 0 sets the fallback date to the beginning of time 60 | THREADED_DOWNLOADS=0 # should downloads be done in the background in threads? 61 | RESET_LIBRARIES=Bing,Bang,Boing # reset "last time" count to the fallback date for these libraries 62 | RESET_COLLECTIONS=Bing,Bang,Boing # CURRENTLY UNUSED 63 | ADD_SOURCE_EXIF_COMMENT=1 # CURRENTLY UNUSED 64 | 65 | # STATUS ENV VARS 66 | PLEX_OWNER=yournamehere # account name of the server owner 67 | TARGET_PLEX_URL=https://plex.domain2.tld # As above, the target of apply_all_status 68 | TARGET_PLEX_TOKEN=PLEX-TOKEN-TWO # As above, the target of apply_all_status 69 | TARGET_PLEX_OWNER=yournamehere # As above, the target of apply_all_status 70 | LIBRARY_MAP={"LIBRARY_ON_PLEX":"LIBRARY_ON_TARGET_PLEX", ...} 71 | # In apply_all_status, map libraries according to this JSON. 72 | 73 | # RESET-POSTERS ENV VARS 74 | TRACK_RESET_STATUS=1 # should reset-posters-* keep track of status and pick up where it left off? 75 | LOCAL_RESET_ARCHIVE=1 # should reset-posters-tmdb keep a local archive of posters? 76 | TARGET_LABELS=this label, that label # comma-separated list of labels to reset posters on 77 | REMOVE_LABELS=0 # attempt to remove the TARGET_LABELs from items after resetting the poster 78 | RESET_SEASONS=1 # reset-posters-* resets season artwork as well in TV libraries 79 | RESET_EPISODES=1 # reset-posters-* resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] 80 | RETAIN_RESET_STATUS_FILE=0 # Don't delete the reset progress file at the end 81 | FLUSH_STATUS_AT_START=0 # Delete the reset progress file at the start instead of reading it 82 | RESET_SEASONS_WITH_SERIES=0 # If there isn't a season poster, use the series poster 83 | DRY_RUN=0 # [currently only works with reset-posters-*]; don't actually do anything, just log 84 | 85 | # LIST ITEM IDS ENV VARS 86 | INCLUDE_COLLECTION_MEMBERS=0 87 | ONLY_COLLECTION_MEMBERS=0 88 | 89 | # DELETE_COLLECTION ENV VARS 90 | KEEP_COLLECTIONS=bing,bang # List of collections to keep 91 | 92 | # REMATCH-ITEMS ENV VARS 93 | UNMATCHED_ONLY=1 # If 1, only rematch things that are currently unmatched 94 | 95 | # RESET_ADDED_AT 96 | ADJUST_DATE_FUTURES_ONLY=0 # Only look at items that show up as added in the future 97 | ADJUST_DATE_EPOCH_ONLY=1 # Only adjust items that have "originally available" dates of `1970-01-01` 98 | 99 | # REFRESH_METADATA 100 | REFRESH_1970_ONLY=1 # If 1, only refresh things that have an originally-available date of 1970-01-01 101 | 102 | # ACTOR ENV VARS 103 | CAST_DEPTH=20 # how deep to go into the cast for actor collections 104 | TOP_COUNT=10 # how many actors to export 105 | JOB_TYPE=Actor # Actor or Director 106 | KNOWN_FOR_ONLY=0 # ignore cast members who are not primarily known as JOBTYPE 107 | BUILD_COLLECTIONS=0 # build yaml for Kometa config.yml 108 | NUM_COLLECTIONS=20 # this many actors in Kometa yaml 109 | TRACK_GENDER=1 # Pay attention to actor gender [as recorded on TMDB] 110 | MIN_GENDER_NONE=5 # include minimum this many "none" gendered actors in the YAML, if possible 111 | MIN_GENDER_FEMALE=5 # include minimum this many "female" gendered actors in the YAML, if possible 112 | MIN_GENDER_MALE=5 # include minimum this many "male" gendered actors in the YAML, if possible 113 | MIN_GENDER_NB=5 # include minimum this many "non-binary" gendered actors in the YAML, if possible 114 | 115 | # LOW POSTER COUNT 116 | POSTER_THRESHOLD=10 117 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | layout python3 2 | python -m pip install --upgrade pip 3 | python -m pip install -r requirements.txt 4 | 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = C901, E722, W503, E501, F823, F841 3 | max-line-length = 140 4 | max-complexity = 10 5 | -------------------------------------------------------------------------------- /.github/workflows/ruff.yml: -------------------------------------------------------------------------------- 1 | name: Ruff 2 | on: [ push, pull_request ] 3 | jobs: 4 | ruff: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: astral-sh/ruff-action@v3 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/.env 2 | **/.direnv 3 | **/*.cache 4 | **/*.csv 5 | **/*.db 6 | **/*.png 7 | **/*.pyc 8 | **/*.yml 9 | **/*.log 10 | **/*.pickle 11 | **/*.sqlite 12 | 13 | Kometa/metadata-items 14 | 15 | Plex/movies 16 | Plex/shows 17 | Plex/active_assets 18 | Plex/active_assets* 19 | Plex/assets* 20 | **/Kometa-Images 21 | **/Kometa-Images-Overlaid 22 | **/assets 23 | **/config 24 | **/posters 25 | **/current_posters 26 | **/current_artwork 27 | **/extracted-posters 28 | **/extracted_posters 29 | **/people_posters 30 | **/venv 31 | **/.venv 32 | **/*-output.txt 33 | **/status.txt 34 | 35 | Other 36 | *-*-*-*-*.txt 37 | *-*-*-*-*-*.txt 38 | *-*-*-*-*-*-*.txt -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - id: check-case-conflict 9 | - id: check-json 10 | - id: check-merge-conflict 11 | - id: no-commit-to-branch 12 | - id: requirements-txt-fixer 13 | - repo: https://github.com/astral-sh/ruff-pre-commit 14 | # Ruff version. 15 | rev: v0.11.8 16 | hooks: 17 | # Run the linter. 18 | - id: ruff 19 | args: [ --fix ] 20 | # Run the formatter. 21 | - id: ruff-format 22 | -------------------------------------------------------------------------------- /Kometa/.env.example: -------------------------------------------------------------------------------- 1 | TMDB_KEY=TMDB_API_KEY 2 | TVDB_KEY=TVDB_V4_API_KEY 3 | PLEX_URL=https://plex.domain.tld 4 | PLEX_TOKEN=PLEX-TOKEN 5 | PLEX_OWNER=yournamehere 6 | LIBRARY_NAMES=Movies,TV Shows,Movies 4K 7 | CAST_DEPTH=20 8 | TOP_COUNT=10 9 | TARGET_LABELS=bing, bang, boing 10 | REMOVE_LABELS=1 11 | DELAY=1 12 | CURRENT_POSTER_DIR=current_posters 13 | POSTER_DIR=extracted_posters 14 | POSTER_DEPTH=20 15 | POSTER_DOWNLOAD=0 16 | POSTER_CONSOLIDATE=0 17 | KOMETA_CONFIG_DIR=/opt/kometa/config # Path to Kometa config directory 18 | 19 | # ORIGINAL TO ASSETS 20 | USE_ASSET_FOLDERS=1 # should those Kometa-Asset-Directory names use asset folders? 21 | ASSET_DIR=assets # top-level directory for those Kometa-Asset-Directory images 22 | KOMETA_CONFIG_DIR=/kometa/is/here 23 | 24 | -------------------------------------------------------------------------------- /Kometa/clean-overlay-backup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | from datetime import datetime 5 | from os import listdir 6 | from os.path import isfile, join 7 | from pathlib import Path 8 | 9 | from alive_progress import alive_bar 10 | from helpers import get_all_from_library, get_plex, load_and_upgrade_env 11 | from logs import blogger, logger, plogger, setup_logger 12 | 13 | SCRIPT_NAME = Path(__file__).stem 14 | 15 | VERSION = "0.1.0" 16 | 17 | env_file_path = Path(".env") 18 | 19 | # current dateTime 20 | now = datetime.now() 21 | 22 | # convert to string 23 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 24 | 25 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 26 | 27 | setup_logger("activity_log", ACTIVITY_LOG) 28 | 29 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 30 | 31 | if load_and_upgrade_env(env_file_path) < 0: 32 | exit() 33 | 34 | PLEX_URL = ( 35 | os.getenv("PLEX_URL") 36 | if os.getenv("PLEX_URL") 37 | else os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") 38 | ) 39 | PLEX_TOKEN = ( 40 | os.getenv("PLEX_TOKEN") 41 | if os.getenv("PLEX_TOKEN") 42 | else os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") 43 | ) 44 | 45 | if PLEX_URL.endswith("/"): 46 | PLEX_URL = PLEX_URL[:-1] 47 | 48 | if PLEX_URL is None or PLEX_URL == "https://plex.domain.tld": 49 | plogger("You must specify PLEX URL in the .env file.", "info", "a") 50 | exit() 51 | 52 | if PLEX_TOKEN is None or PLEX_TOKEN == "PLEX-TOKEN": 53 | plogger("You must specify PLEX TOKEN in the .env file.", "info", "a") 54 | exit() 55 | 56 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 57 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 58 | 59 | KOMETA_CONFIG_DIR = os.getenv("KOMETA_CONFIG_DIR") 60 | 61 | if KOMETA_CONFIG_DIR is None: 62 | plogger("You must specify KOMETA_CONFIG_DIR in the .env file.", "info", "a") 63 | exit() 64 | 65 | DELAY = int(os.getenv("DELAY")) 66 | 67 | if not DELAY: 68 | DELAY = 0 69 | 70 | if LIBRARY_NAMES: 71 | LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] 72 | else: 73 | LIB_ARRAY = [LIBRARY_NAME] 74 | 75 | redaction_list = [] 76 | redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_BASEURL")) 77 | redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_TOKEN")) 78 | 79 | plex = get_plex() 80 | 81 | logger("Plex connection succeeded", "info", "a") 82 | 83 | ALL_LIBS = plex.library.sections() 84 | ALL_LIB_NAMES = [] 85 | 86 | logger(f"{len(ALL_LIBS)} libraries found:", "info", "a") 87 | for lib in ALL_LIBS: 88 | logger(f"{lib.title.strip()}: {lib.type}", "info", "a") 89 | ALL_LIB_NAMES.append(f"{lib.title.strip()}") 90 | 91 | if LIBRARY_NAMES == "ALL_LIBRARIES": 92 | LIB_ARRAY = [] 93 | for lib in ALL_LIBS: 94 | LIB_ARRAY.append(lib.title.strip()) 95 | 96 | 97 | def get_SE_str(item): 98 | if item.TYPE == "season": 99 | ret_val = f"S{str(item.seasonNumber).zfill(2)}" 100 | elif item.TYPE == "episode": 101 | ret_val = ( 102 | f"S{str(item.seasonNumber).zfill(2)}E{str(item.episodeNumber).zfill(2)}" 103 | ) 104 | else: 105 | ret_val = "" 106 | 107 | return ret_val 108 | 109 | 110 | def get_progress_string(item): 111 | if item.TYPE == "season": 112 | ret_val = f"{item.parentTitle} - {get_SE_str(item)} - {item.title}" 113 | elif item.TYPE == "episode": 114 | ret_val = f"{item.grandparentTitle} - {item.parentTitle} - {get_SE_str(item)} - {item.title}" 115 | else: 116 | ret_val = f"{item.title}" 117 | 118 | return ret_val 119 | 120 | 121 | for lib in LIB_ARRAY: 122 | if lib in ALL_LIB_NAMES: 123 | try: 124 | highwater = 0 125 | 126 | LIBRARY_BACKUP = f"{KOMETA_CONFIG_DIR}overlays/{lib} Original Posters" 127 | 128 | all_backup_files = [ 129 | f for f in listdir(LIBRARY_BACKUP) if isfile(join(LIBRARY_BACKUP, f)) 130 | ] 131 | backup_dict = {} 132 | missing_dict = {} 133 | 134 | for f in all_backup_files: 135 | rk = f.split(".")[0] 136 | ext = f.split(".")[1] 137 | backup_dict[rk] = f"{LIBRARY_BACKUP}/{rk}.{ext}" 138 | 139 | plogger( 140 | f"{len(backup_dict)} images in the {lib} overlay backup directory ...", 141 | "info", 142 | "a", 143 | ) 144 | 145 | plogger(f"Loading {lib} ...", "info", "a") 146 | the_lib = plex.library.section(lib) 147 | the_uuid = the_lib.uuid 148 | 149 | ID_ARRAY = [] 150 | the_title = the_lib.title 151 | 152 | plogger(f"Loading {the_lib.TYPE}s from {lib} ...", "info", "a") 153 | item_count, items = get_all_from_library(the_lib, None, None) 154 | 155 | plogger( 156 | f"Completed loading {len(items)} of {item_count} {the_lib.TYPE}(s) from {the_lib.title}", 157 | "info", 158 | "a", 159 | ) 160 | 161 | if the_lib.TYPE == "show": 162 | plogger(f"Loading seasons from {lib} ...", "info", "a") 163 | season_count, seasons = get_all_from_library(the_lib, "season", None) 164 | 165 | plogger( 166 | f"Completed loading {len(seasons)} of {season_count} season(s) from {the_lib.title}", 167 | "info", 168 | "a", 169 | ) 170 | items.extend(seasons) 171 | 172 | plogger(f"Loading episodes from {lib} ...", "info", "a") 173 | episode_count, episodes = get_all_from_library(the_lib, "episode", None) 174 | 175 | plogger( 176 | f"Completed loading {len(episodes)} of {episode_count} episode(s) from {the_lib.title}", 177 | "info", 178 | "a", 179 | ) 180 | items.extend(episodes) 181 | 182 | item_total = len(items) 183 | if item_total > 0: 184 | logger(f"looping over {item_total} items...", "info", "a") 185 | item_count = 0 186 | 187 | with alive_bar( 188 | item_total, 189 | dual_line=True, 190 | title=f"Clean Overlay Backup {the_lib.title}", 191 | ) as bar: 192 | for item in items: 193 | try: 194 | rk = f"{item.ratingKey}" 195 | blogger( 196 | f"Processing {item.title}; rating key {rk}", 197 | "info", 198 | "a", 199 | bar, 200 | ) 201 | if rk in backup_dict.keys(): 202 | blogger(f"Rating key {rk} found", "info", "a", bar) 203 | backup_dict.pop(rk) 204 | else: 205 | missing_dict[rk] = f"{item.title}" 206 | blogger( 207 | f"{item.title}; rating key {rk} has no backup art", 208 | "info", 209 | "a", 210 | bar, 211 | ) 212 | 213 | item_count += 1 214 | except Exception as ex: 215 | plogger( 216 | f"Problem processing {item.title}; {ex}", "info", "a" 217 | ) 218 | 219 | bar() 220 | 221 | plogger(f"Processed {item_count} of {item_total}", "info", "a") 222 | 223 | plogger(f"{len(backup_dict)} items to delete", "info", "a") 224 | 225 | if len(backup_dict) > 0: 226 | delete_list = [] 227 | with alive_bar( 228 | item_total, 229 | dual_line=True, 230 | title=f"Clean Overlay Backup {the_lib.title}", 231 | ) as bar: 232 | for rk in backup_dict: 233 | target_file = backup_dict[rk] 234 | p = Path(target_file) 235 | blogger(f"Deleting {target_file}", "info", "a", bar) 236 | try: 237 | p.unlink() 238 | delete_list.append(rk) 239 | except Exception as ex: 240 | plogger( 241 | f"Problem deleting {target_file}; {ex}", "info", "a" 242 | ) 243 | 244 | for rk in delete_list: 245 | backup_dict.pop(rk) 246 | 247 | if len(backup_dict) > 0: 248 | plogger( 249 | f"{len(backup_dict)} items could not be deleted", "info", "a" 250 | ) 251 | 252 | plogger( 253 | f"{len(missing_dict)} items in Plex with no backup art", "info", "a" 254 | ) 255 | plogger( 256 | "These might be items added to Plex since the last overlay run", 257 | "info", 258 | "a", 259 | ) 260 | plogger( 261 | "They might be items that are not intended to have overlays", 262 | "info", 263 | "a", 264 | ) 265 | 266 | progress_str = "COMPLETE" 267 | logger(progress_str, "info", "a") 268 | 269 | except Exception as ex: 270 | progress_str = f"Problem processing {lib}; {ex}" 271 | plogger(progress_str, "info", "a") 272 | else: 273 | logger( 274 | f"Library {lib} not found: available libraries on this server are: {ALL_LIB_NAMES}", 275 | "info", 276 | "a", 277 | ) 278 | 279 | 280 | plogger("Complete!", "info", "a") 281 | -------------------------------------------------------------------------------- /Kometa/collection.tmpl: -------------------------------------------------------------------------------- 1 | %%NAME%%: 2 | template: {name: Person, person: %%ID%%} 3 | -------------------------------------------------------------------------------- /Kometa/extract-collections.py: -------------------------------------------------------------------------------- 1 | import os 2 | import platform 3 | import re 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | from alive_progress import alive_bar 8 | from helpers import get_plex, load_and_upgrade_env 9 | from logs import plogger, setup_logger 10 | from plexapi.utils import download 11 | from ruamel import yaml 12 | 13 | SCRIPT_NAME = Path(__file__).stem 14 | 15 | # 0.0.3 : handle some errors better 16 | # 0.0.4 : deal with invalid filenames 17 | # 0.0.5 : file_poster not url_poster 18 | 19 | VERSION = "0.0.5" 20 | 21 | env_file_path = Path(".env") 22 | 23 | # current dateTime 24 | now = datetime.now() 25 | 26 | IS_WINDOWS = platform.system() == "Windows" 27 | 28 | # convert to string 29 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 30 | 31 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 32 | 33 | setup_logger("activity_log", ACTIVITY_LOG) 34 | 35 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 36 | 37 | if load_and_upgrade_env(env_file_path) < 0: 38 | exit() 39 | 40 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 41 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 42 | # TMDB_KEY = os.getenv("TMDB_KEY") 43 | # TVDB_KEY = os.getenv("TVDB_KEY") 44 | # CAST_DEPTH = int(os.getenv("CAST_DEPTH")) 45 | # TOP_COUNT = int(os.getenv("TOP_COUNT")) 46 | DELAY = int(os.getenv("DELAY")) 47 | 48 | if not DELAY: 49 | DELAY = 0 50 | 51 | PLEX_URL = ( 52 | os.getenv("PLEX_URL") 53 | if os.getenv("PLEX_URL") 54 | else os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") 55 | ) 56 | PLEX_TOKEN = ( 57 | os.getenv("PLEX_TOKEN") 58 | if os.getenv("PLEX_TOKEN") 59 | else os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") 60 | ) 61 | 62 | if PLEX_URL.endswith("/"): 63 | PLEX_URL = PLEX_URL[:-1] 64 | 65 | 66 | if LIBRARY_NAMES: 67 | lib_array = LIBRARY_NAMES.split(",") 68 | else: 69 | lib_array = [LIBRARY_NAME] 70 | 71 | artwork_dir = "artwork" 72 | background_dir = "background" 73 | config_dir = "config" 74 | 75 | plex = get_plex() 76 | 77 | coll_obj = {} 78 | coll_obj["collections"] = {} 79 | 80 | 81 | def get_sort_text(argument): 82 | switcher = {0: "release", 1: "alpha", 2: "custom"} 83 | return switcher.get(argument, "invalid-sort") 84 | 85 | 86 | for lib in lib_array: 87 | lib = lib.lstrip() 88 | safe_lib = re.sub(r"[/\\?%*:|\"<>\x7F\x00-\x1F]", "-", lib) 89 | print(f"Processing: [{lib}] | safe: [{safe_lib}]") 90 | try: 91 | the_lib = plex.library.section(lib) 92 | 93 | collections = the_lib.collections() 94 | item_total = len(collections) 95 | with alive_bar( 96 | item_total, dual_line=True, title=f"Extract collections: {the_lib.title}" 97 | ) as bar: 98 | for collection in collections: 99 | if collection.smart: 100 | filters = collection.filters() 101 | 102 | title = collection.title 103 | 104 | safe_title = re.sub(r"[/\\?%*:|\"<>\x7F\x00-\x1F]", "-", title) 105 | 106 | print(f"title - {title} | safe - {safe_title}") 107 | 108 | artwork_path = Path(".", config_dir, f"{safe_lib}-{artwork_dir}") 109 | artwork_path.mkdir(mode=511, parents=True, exist_ok=True) 110 | 111 | background_path = Path(".", config_dir, f"{safe_lib}-{background_dir}") 112 | background_path.mkdir(mode=511, parents=True, exist_ok=True) 113 | 114 | thumbPath = None 115 | artPath = None 116 | 117 | try: 118 | thumbPath = download( 119 | f"{PLEX_URL}{collection.thumb}", 120 | PLEX_TOKEN, 121 | filename=f"{safe_title}.png", 122 | savepath=artwork_path, 123 | ) 124 | except Exception as ex: 125 | print(f"Continuing without image - {ex}") 126 | 127 | if collection.art is not None: 128 | artPath = download( 129 | f"{PLEX_URL}{collection.art}", 130 | PLEX_TOKEN, 131 | filename=f"{safe_title}.png", 132 | savepath=background_path, 133 | ) 134 | 135 | this_coll = {} 136 | this_coll["sort_title"] = collection.titleSort 137 | if thumbPath is not None: 138 | this_coll["file_poster"] = f"./{thumbPath}" 139 | if artPath is not None: 140 | this_coll["file_background"] = f"./{artPath}" 141 | 142 | if len(collection.summary) > 0: 143 | this_coll["summary"] = collection.summary 144 | 145 | this_coll["collection_order"] = get_sort_text(collection.collectionSort) 146 | 147 | this_coll["plex_search"] = {} 148 | this_coll["plex_search"]["any"] = {} 149 | titlearray = [] 150 | items = collection.items() 151 | for item in items: 152 | titlearray.append(item.title) 153 | this_coll["plex_search"]["any"]["title.is"] = titlearray 154 | 155 | if len(this_coll) > 0: 156 | coll_obj["collections"][collection.title] = this_coll 157 | 158 | bar() 159 | 160 | metadatafile_path = Path(".", config_dir, f"{safe_lib}-existing.yml") 161 | 162 | if yaml.version_info < (0, 15): 163 | # data = yaml.load(istream, Loader=yaml.CSafeLoader) 164 | # yaml.round_trip_dump(data, ostream, width=1000, explicit_start=True) 165 | yaml.round_trip_dump( 166 | coll_obj, 167 | open(metadatafile_path, "w", encoding="utf-8"), 168 | indent=None, 169 | block_seq_indent=2, 170 | ) 171 | else: 172 | # yml = ruamel.yaml.YAML(typ='safe') 173 | # data = yml.load(istream) 174 | if len(coll_obj["collections"]) > 0: 175 | ymlo = yaml.YAML() # or yaml.YAML(typ='rt') 176 | ymlo.width = 1000 177 | ymlo.explicit_start = True 178 | ymlo.dump(coll_obj, open(metadatafile_path, "w", encoding="utf-8")) 179 | else: 180 | print(f"{lib} has no collections to export") 181 | except Exception: 182 | print(f"error loading library: {lib}") 183 | print(f"This server has: {plex.library.sections()}") 184 | -------------------------------------------------------------------------------- /Kometa/kometa-mal-auth.py: -------------------------------------------------------------------------------- 1 | # This little script needs only Python 3.9 and a couple requirements, and will generate the MAL section for your Kometa config file. 2 | # Most of this code is pulled from Kometa's own MAL authentication; it's just been simplified to do 3 | # the one thing and not rely on any Kometa code. 4 | # 5 | # You can run this on a completely separate machine to where Kometa is running. 6 | # 7 | # Download it somewhere, 8 | # python3 -m pip install pyopenssl 9 | # python3 -m pip install requests secrets 10 | # python3 kometa-mal-auth.py 11 | # then run it with "python3 kometa-mal-auth.py". 12 | # 13 | # If you're running Kometa locally, just copy it into the Kometa directory and run in the Kometa environment. All teh requirements are already there. 14 | # 15 | # You'll be asked for your MyAnimeList Client ID and Client Secret 16 | # Then taken to a MyAnimeList web page 17 | # Login and click "Allow" 18 | # You'll be taken to a local URL that won't load. 19 | # Copy that localhost URL and paste it at the prompt. 20 | # 21 | # Some yaml will be printed, ready to copy-paste into your Kometa config.yml. 22 | 23 | import os 24 | import re 25 | import secrets 26 | import webbrowser 27 | 28 | import requests 29 | 30 | urls = { 31 | "oauth_token": "https://myanimelist.net/v1/oauth2/token", 32 | "oauth_authorize": "https://myanimelist.net/v1/oauth2/authorize", 33 | } 34 | 35 | print("Let's authenticate against MyAnimeList!{os.linesep}{os.linesep}") 36 | session = requests.Session() 37 | 38 | client_id = input("MyAnimeList Client ID: ").strip() 39 | client_secret = input("MyAnimeList Client Secret: ").strip() 40 | 41 | code_verifier = secrets.token_urlsafe(100)[:128] 42 | url = f"{urls['oauth_authorize']}?response_type=code&client_id={client_id}&code_challenge={code_verifier}" 43 | 44 | print(f"We're going to open {url}{os.linesep}{os.linesep}") 45 | print(f"Log in and click the Allow option.{os.linesep}") 46 | print( 47 | f"You will be redirected to a localhost url that probably won't load.{os.linesep}" 48 | ) 49 | print(f"That's fine. Copy that localhost URL and paste it below.{os.linesep}") 50 | tmpVar = input("Hit enter when ready: ").strip() 51 | 52 | webbrowser.open(url, new=2) 53 | 54 | url = input("URL: ").strip() 55 | 56 | match = re.search("code=([^&]+)", str(url)) 57 | if not match: 58 | print(f"Couldn't find the required code in that URL.{os.linesep}") 59 | exit() 60 | 61 | code = match.group(1) 62 | 63 | data = { 64 | "client_id": client_id, 65 | "client_secret": client_secret, 66 | "code": code, 67 | "code_verifier": code_verifier, 68 | "grant_type": "authorization_code", 69 | } 70 | 71 | new_authorization = session.post(urls["oauth_token"], data=data).json() 72 | 73 | if "error" in new_authorization: 74 | print(f"ERROR: invalid code.{os.linesep}") 75 | exit() 76 | 77 | print(f"{os.linesep}{os.linesep}Copy the following into your Kometa config.yml:") 78 | print("############################################") 79 | print("mal:") 80 | print(f" client_id: {client_id}") 81 | print(f" client_secret: {client_secret}") 82 | print(" authorization:") 83 | print(f" access_token: {new_authorization['access_token']}") 84 | print(f" token_type: {new_authorization['token_type']}") 85 | print(f" expires_in: {new_authorization['expires_in']}") 86 | print(f" refresh_token: {new_authorization['refresh_token']}") 87 | print("############################################") 88 | -------------------------------------------------------------------------------- /Kometa/kometa-trakt-auth.py: -------------------------------------------------------------------------------- 1 | # This little script needs only Python 3.9 and requests, and will generate the trakt section for your Kometa config file. 2 | # Most of this code is pulled from Kometa's own trakt authentication; it's just been simplified to do 3 | # the one thing and not rely on any Kometa code. 4 | # 5 | # You can run this on a completely separate machine to where Kometa is running. 6 | # 7 | # Download it somewhere, "python3 -m pip install requests" and run it with "python3 kometa-trakt-auth.py". 8 | # 9 | # You'll be asked for your trakt Client ID and Client Secret 10 | # Then taken to a trakt web page 11 | # copy the PIN and paste it at the prompt. 12 | # 13 | # Some yaml will be printed, ready to copy-paste into your Kometa config.yml. 14 | 15 | import webbrowser 16 | 17 | import requests 18 | 19 | redirect_uri = "urn:ietf:wg:oauth:2.0:oob" 20 | redirect_uri_encoded = redirect_uri.replace(":", "%3A") 21 | base_url = "https://api.trakt.tv" 22 | 23 | print("Let's authenticate against Trakt!\n\n") 24 | 25 | client_id = input("Trakt Client ID: ").strip() 26 | client_secret = input("Trakt Client Secret: ").strip() 27 | 28 | url = f"https://trakt.tv/oauth/authorize?response_type=code&client_id={client_id}&redirect_uri={redirect_uri_encoded}" 29 | print( 30 | f"Taking you to: {url}\n\nIf you get an OAuth error your Client ID or Client Secret is invalid\n\nIf a browser window doesn't open go to that URL manually.\n\n" 31 | ) 32 | webbrowser.open(url, new=2) 33 | pin = input("Enter the Trakt pin from that web page: ").strip() 34 | json = { 35 | "code": pin, 36 | "client_id": client_id, 37 | "client_secret": client_secret, 38 | "redirect_uri": redirect_uri, 39 | "grant_type": "authorization_code", 40 | } 41 | response = requests.post( 42 | f"{base_url}/oauth/token", json=json, headers={"Content-Type": "application/json"} 43 | ) 44 | 45 | if response.status_code != 200: 46 | print( 47 | "Trakt Error: Invalid trakt pin. If you're sure you typed it in correctly your client_id or client_secret may be invalid" 48 | ) 49 | else: 50 | print("Authentication successful; validating credentials...") 51 | 52 | headers = { 53 | "Content-Type": "application/json", 54 | "Authorization": f"Bearer {response.json()['access_token']}", 55 | "trakt-api-version": "2", 56 | "trakt-api-key": client_id, 57 | } 58 | 59 | validation_response = requests.get(f"{base_url}/users/settings", headers=headers) 60 | if validation_response.status_code == 423: 61 | print("Trakt Error: Account is locked; please contact Trakt Support") 62 | else: 63 | print("Copy the following into your Kometa config.yml:") 64 | print("############################################") 65 | print("trakt:") 66 | print(f" client_id: {client_id}") 67 | print(f" client_secret: {client_secret}") 68 | print(" authorization:") 69 | print(f" access_token: {response.json()['access_token']}") 70 | print(f" token_type: {response.json()['token_type']}") 71 | print(f" expires_in: {response.json()['expires_in']}") 72 | print(f" refresh_token: {response.json()['refresh_token']}") 73 | print(f" scope: {response.json()['scope']}") 74 | print(f" created_at: {response.json()['created_at']}") 75 | print(" pin:") 76 | print("############################################") 77 | -------------------------------------------------------------------------------- /Kometa/logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def setup_logger(logger_name, log_file, level=logging.INFO): 5 | log_setup = logging.getLogger(logger_name) 6 | formatter = logging.Formatter( 7 | "%(levelname)s: %(asctime)s %(message)s", datefmt="%m/%d/%Y %I:%M:%S %p" 8 | ) 9 | fileHandler = logging.FileHandler(log_file, mode="a", encoding="utf-8") 10 | fileHandler.setFormatter(formatter) 11 | log_setup.setLevel(level) 12 | log_setup.addHandler(fileHandler) 13 | 14 | 15 | def setup_dual_logger(logger_name, log_file, level=logging.INFO): 16 | log_setup = logging.getLogger(logger_name) 17 | formatter = logging.Formatter( 18 | "%(levelname)s: %(asctime)s %(message)s", datefmt="%m/%d/%Y %I:%M:%S %p" 19 | ) 20 | fileHandler = logging.FileHandler(log_file, mode="a", encoding="utf-8") 21 | fileHandler.setFormatter(formatter) 22 | streamHandler = logging.StreamHandler() 23 | streamHandler.setFormatter(formatter) 24 | log_setup.setLevel(level) 25 | log_setup.addHandler(fileHandler) 26 | log_setup.addHandler(streamHandler) 27 | 28 | 29 | def logger(msg, level, logfile): 30 | if logfile == "a": 31 | log = logging.getLogger("activity_log") 32 | if logfile == "d": 33 | log = logging.getLogger("download_log") 34 | if level == "info": 35 | log.info(msg) 36 | if level == "warning": 37 | log.warning(msg) 38 | if level == "error": 39 | log.error(msg) 40 | 41 | 42 | def plogger(msg, level, logfile): 43 | logger(msg, level, logfile) 44 | print(msg) 45 | 46 | 47 | def blogger(msg, level, logfile, bar): 48 | logger(msg, level, logfile) 49 | bar.text(msg) 50 | -------------------------------------------------------------------------------- /Kometa/overlay-default-posters.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from pathlib import Path 3 | 4 | from alive_progress import alive_bar 5 | from git.repo.base import Repo 6 | from PIL import Image 7 | 8 | IMAGE_REPO = "https://github.com/Kometa-Team/Default-Images" 9 | LOCAL_FOLDER = "Kometa-Images" 10 | OVERLAID_FOLDER = "Kometa-Images-Overlaid" 11 | OVERLAY_SOURCE_FOLDER = "default_collection_overlays" 12 | OVERLAY_BASE_IMAGE = "overlay-template.png" 13 | 14 | theRepo = None 15 | theRepoPath = Path(LOCAL_FOLDER) 16 | theTargetPath = Path(OVERLAID_FOLDER) 17 | 18 | if not theRepoPath.exists(): 19 | print(f"Cloning {IMAGE_REPO}") 20 | print("This may take some time with no display") 21 | theRepo = Repo.clone_from(IMAGE_REPO, LOCAL_FOLDER) 22 | else: 23 | print(f"Fetch/Pull on {LOCAL_FOLDER}") 24 | theRepo = Repo(LOCAL_FOLDER) 25 | theRepo.remotes.origin.fetch() 26 | theRepo.remotes.origin.pull() 27 | 28 | global_overlay = Path(f"{OVERLAY_SOURCE_FOLDER}/{OVERLAY_BASE_IMAGE}") 29 | global_overlay_im = None 30 | 31 | if global_overlay.exists(): 32 | print(f"Using {global_overlay} as global overlay") 33 | global_overlay_im = Image.open(f"{global_overlay}") 34 | global_overlay_im = global_overlay_im.resize((2000, 3000), Image.Resampling.LANCZOS) 35 | 36 | 37 | def skip_this(path): 38 | ret_val = False 39 | ret_val = ret_val or ".git" in path.parts 40 | ret_val = ret_val or ".github" in path.parts 41 | ret_val = ret_val or ".gitignore" in path.parts 42 | 43 | ret_val = ret_val or "overlays" in path.parts 44 | ret_val = ret_val or "logos" in path.parts 45 | 46 | ret_val = ret_val or ".ttf" in path.suffixes 47 | ret_val = ret_val or ".psd" in path.suffixes 48 | ret_val = ret_val or ".xcf" in path.suffixes 49 | ret_val = ret_val or ".md" in path.suffixes 50 | ret_val = ret_val or ".txt" in path.suffixes 51 | 52 | ret_val = ret_val or path.is_dir() 53 | 54 | ret_val = ret_val or "!_" in path.stem 55 | ret_val = ret_val or path.stem == "overlay" 56 | 57 | return ret_val 58 | 59 | 60 | target_paths = [] 61 | 62 | print("building list of targets") 63 | for path in pathlib.Path(LOCAL_FOLDER).glob("**/*"): 64 | if not skip_this(path): 65 | target_paths.append(path) 66 | 67 | item_total = len(target_paths) 68 | 69 | with alive_bar(item_total, dual_line=True, title="Applying overlays") as bar: 70 | for path in target_paths: 71 | bar.text(path) 72 | 73 | source_path = Path(path) 74 | target_path = Path(f"{path}".replace(LOCAL_FOLDER, OVERLAID_FOLDER)) 75 | target_path.parent.mkdir(parents=True, exist_ok=True) 76 | 77 | target_group = path.parts[1] 78 | 79 | try: 80 | local_overlay = f"{OVERLAY_SOURCE_FOLDER}/{target_group}.png" 81 | local_overlay_im = Image.open(local_overlay) 82 | local_overlay_im = local_overlay_im.resize( 83 | (2000, 3000), Image.Resampling.LANCZOS 84 | ) 85 | except: 86 | local_overlay_im = global_overlay_im 87 | 88 | source_image = Image.open(source_path) 89 | 90 | source_image.paste(local_overlay_im, (0, 0), local_overlay_im) 91 | source_image.save(target_path) 92 | 93 | bar() 94 | -------------------------------------------------------------------------------- /Kometa/template.tmpl: -------------------------------------------------------------------------------- 1 | ###################################################### 2 | # People Collections # 3 | ###################################################### 4 | templates: 5 | Person: 6 | smart_filter: 7 | any: 8 | actor: tmdb 9 | director: tmdb 10 | writer: tmdb 11 | producer: tmdb 12 | sort_by: year.asc 13 | validate: false 14 | tmdb_person: <> 15 | sort_title: +9_<> 16 | schedule: weekly(monday) 17 | collection_order: release 18 | collection_mode: hide 19 | 20 | collections: 21 | -------------------------------------------------------------------------------- /Kometa/top-n-actor-coll.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import textwrap 4 | from collections import Counter 5 | 6 | from dotenv import load_dotenv 7 | from plexapi.server import PlexServer 8 | from tmdbapis import TMDbAPIs 9 | 10 | load_dotenv() 11 | 12 | PLEX_URL = os.getenv("PLEX_URL") 13 | PLEX_TOKEN = os.getenv("PLEX_TOKEN") 14 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 15 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 16 | TMDB_KEY = os.getenv("TMDB_KEY") 17 | TVDB_KEY = os.getenv("TVDB_KEY") 18 | CAST_DEPTH = int(os.getenv("CAST_DEPTH")) 19 | TOP_COUNT = int(os.getenv("TOP_COUNT")) 20 | DELAY = int(os.getenv("DELAY")) 21 | 22 | if not DELAY: 23 | DELAY = 0 24 | 25 | if LIBRARY_NAMES: 26 | lib_array = LIBRARY_NAMES.split(",") 27 | else: 28 | lib_array = [LIBRARY_NAME] 29 | 30 | tmdb = TMDbAPIs(TMDB_KEY, language="en") 31 | 32 | tmdb_str = "tmdb://" 33 | tvdb_str = "tvdb://" 34 | 35 | actors = Counter() 36 | 37 | YAML_STR = "" 38 | COLL_TMPL = "" 39 | 40 | with open("template.tmpl") as tmpl: 41 | YAML_STR = tmpl.read() 42 | 43 | with open("collection.tmpl") as tmpl: 44 | COLL_TMPL = tmpl.read() 45 | 46 | 47 | def getTID(theList): 48 | tmid = None 49 | tvid = None 50 | for guid in theList: 51 | if tmdb_str in guid.id: 52 | tmid = guid.id.replace(tmdb_str, "") 53 | if tvdb_str in guid.id: 54 | tvid = guid.id.replace(tvdb_str, "") 55 | return tmid, tvid 56 | 57 | 58 | def progress(count, total, status=""): 59 | bar_len = 40 60 | filled_len = int(round(bar_len * count / float(total))) 61 | 62 | percents = round(100.0 * count / float(total), 1) 63 | bar = "=" * filled_len + "-" * (bar_len - filled_len) 64 | stat_str = textwrap.shorten(status, width=30) 65 | 66 | sys.stdout.write("[%s] %s%s ... %s\r" % (bar, percents, "%", stat_str.ljust(30))) 67 | sys.stdout.flush() 68 | 69 | 70 | print(f"connecting to {PLEX_URL}...") 71 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 72 | for lib in lib_array: 73 | METADATA_TITLE = f"{lib} Top {TOP_COUNT} Actors.yml" 74 | 75 | print(f"getting items from [{lib}]...") 76 | items = plex.library.section(lib).all() 77 | item_total = len(items) 78 | print(f"looping over {item_total} items...") 79 | item_count = 1 80 | for item in items: 81 | tmpDict = {} 82 | tmdb_id, tvdb_id = getTID(item.guids) 83 | item_count = item_count + 1 84 | try: 85 | progress(item_count, item_total, item.title) 86 | cast = "" 87 | if item.TYPE == "show": 88 | cast = tmdb.tv_show(tmdb_id).cast 89 | else: 90 | cast = tmdb.movie(tmdb_id).casts["cast"] 91 | count = 0 92 | for actor in cast: 93 | if count < CAST_DEPTH: 94 | count = count + 1 95 | if actor.known_for_department == "Acting": 96 | tmpDict[f"{actor.id}-{actor.name}"] = 1 97 | actors.update(tmpDict) 98 | except Exception: 99 | progress(item_count, item_total, "EX: " + item.title) 100 | 101 | print("\r\r") 102 | 103 | count = 0 104 | for actor in sorted(actors.items(), key=lambda x: x[1], reverse=True): 105 | if count < TOP_COUNT: 106 | print("{}\t{}".format(actor[1], actor[0])) 107 | name_arr = actor[0].split("-") 108 | this_coll = COLL_TMPL.replace("%%NAME%%", name_arr[1]) 109 | this_coll = this_coll.replace("%%ID%%", name_arr[0]) 110 | YAML_STR = YAML_STR + this_coll 111 | count = count + 1 112 | 113 | with open(METADATA_TITLE, "w") as out: 114 | out.write(YAML_STR) 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Chaz Larson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Plex Image Picker/.envrc: -------------------------------------------------------------------------------- 1 | layout pyenv 3.12.6 2 | python -m pip install --upgrade pip 3 | python -m pip install -r requirements.txt 4 | 5 | -------------------------------------------------------------------------------- /Plex Image Picker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | ENV TINI_VERSION=v0.19.0 3 | 4 | WORKDIR / 5 | COPY requirements.txt requirements.txt 6 | 7 | RUN echo "**** install system packages ****" \ 8 | && apt-get update \ 9 | && apt-get upgrade -y --no-install-recommends \ 10 | && apt-get install -y tzdata --no-install-recommends \ 11 | && apt-get install -y wget curl nano \ 12 | && wget -O /tini https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-"$(dpkg --print-architecture | awk -F- '{ print $NF }')" \ 13 | && chmod +x /tini \ 14 | && pip3 install --no-cache-dir --upgrade --requirement /requirements.txt \ 15 | && apt-get clean \ 16 | && apt-get update \ 17 | && apt-get check \ 18 | && apt-get -f install \ 19 | && apt-get autoclean \ 20 | && rm -rf /requirements.txt /tmp/* /var/tmp/* /var/lib/apt/lists/* 21 | COPY . / 22 | 23 | VOLUME /assets 24 | 25 | EXPOSE 5000 26 | ENV FLASK_APP=app.py 27 | ENTRYPOINT ["/tini", "--"] 28 | 29 | CMD ["flask", "run", "--host=0.0.0.0"] 30 | 31 | -------------------------------------------------------------------------------- /Plex Image Picker/README.md: -------------------------------------------------------------------------------- 1 | # Plex Image Picker 2 | 3 | You want a simple way to choose which Plex-supplied image you want in your asset directory. 4 | 5 | This presents a web UI that lets you scroll through the images that plex provides for each item [movie, show, season, episode], selecting the one you want by clicking a button. 6 | 7 | When you click on an image, it is downloaded to a file system rooted at `assets` with the correct pathing and naming for the Kometa asset directory. 8 | 9 | You can then copy that `assets` directory to the Kometa config dir ready for use. 10 | 11 | This script does not use anything from the `.env`, but it does make some assumptions: 12 | 13 | ## Requirements 14 | 15 | 1. A system that can run Python 3.7 [or newer] 16 | 1. Python 3.7 [or newer] installed on that system 17 | 18 | One of the requirements of these scripts is alive-progress 2.4.1, which requires Python 3.7. 19 | 1. A basic knowledge of how to run Python scripts. 20 | 1. You can run a web server on the machine that listens on port 5000 21 | 22 | ## Setup 23 | 24 | ### if you use [`direnv`](https://github.com/direnv/direnv) and [`pyenv`](https://github.com/pyenv/pyenv): 25 | 1. clone the repo 26 | 1. cd into the repo dir 27 | 1. cd into the app directory [`cd "Plex Image Picker"`] 28 | 1. run `direnv allow` as the prompt will tell you to 29 | 1. direnv will build the virtual env and keep requirements up to date 30 | 31 | ### if you don't use [`direnv`](https://github.com/direnv/direnv) and [`pyenv`](https://github.com/pyenv/pyenv): 32 | 1. install direnv and pyenv 33 | 2. go to the previous section 34 | 35 | Ha ha only serious. 36 | 37 | To set it up without those things: 38 | 39 | #### Python 40 | 41 | 1. clone repo 42 | ``` 43 | git clone https://github.com/chazlarson/Media-Scripts.git 44 | ``` 45 | 1. cd to repo directory 46 | ``` 47 | cd Media-Scripts 48 | ``` 49 | 1. cd to application directory 50 | ``` 51 | cd Plex Image Picker 52 | ``` 53 | 1. Install requirements with `python3 -m pip install -r requirements.txt` [I'd suggest doing this in a virtual environment] 54 | 55 | Creating a virtual environment is described [here](https://docs.python.org/3/library/venv.html); there's also a step-by-step in the local walkthrough in the Kometa wiki. 56 | 1. Run with `flask run` 57 | 1. Go to one of the URLs presented. 58 | 59 | #### Docker 60 | 61 | 1. build the docker image: 62 | ``` 63 | docker build -t plex-image-picker . 64 | ``` 65 | 2. run the container: 66 | ``` 67 | docker run -p 5000:5000 -v /opt/Media-Scripts/plex_art_app/assets:/assets:rw plex-image-picker 68 | ``` 69 | Change the port and path as needed, of course. 70 | 3. Go to one of the URLs presented, as appropriate. 71 | 72 | #### Interface 73 | 74 | You'll be asked to enter connection details. You can change the directory where the images will go on this initial setup screen: 75 | 76 | ![](images/connect.png) 77 | 78 | Then you will choose a library: 79 | 80 | ![](images/libraries.png) 81 | 82 | You'll see a grid displaying all posters available for the first item in the library: 83 | 84 | ![](images/movies-posters.png) 85 | 86 | You can choose to view backgrounds instead of posters: 87 | 88 | ![](images/movies-backgrounds.png) 89 | 90 | When you click the Download button, the file will be downloaded to the asset directory; a flash message will report the path: 91 | 92 | ![](images/movies-download.png) 93 | 94 | Click through the movies/shows with the top row of buttons; click through pages of images with the lower set of buttons. 95 | 96 | Navigate to a specific page of posters with the dropdown on the right. 97 | 98 | Clicking the title in the upper left will take you back to the library list. 99 | 100 | In a Show library, you will be able to choose to view posters/backgrounds for shows, seasons, or episodes: 101 | 102 | ![](images/shows-posters.png) 103 | ![](images/seasons-posters.png) 104 | ![](images/episodes-posters.png) 105 | -------------------------------------------------------------------------------- /Plex Image Picker/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import math 3 | import string 4 | import requests 5 | from flask import Flask, flash, redirect, render_template, request, session, url_for 6 | from plexapi.server import PlexServer 7 | 8 | app = Flask(__name__) 9 | app.secret_key = os.urandom(24) 10 | 11 | # module‑level alphabet list so we only build it once 12 | ALPHABET = list(string.ascii_uppercase) 13 | ALPHABET.insert(0, "0-9") 14 | 15 | 16 | def get_plex(): 17 | base_url = session.get("base_url") 18 | token = session.get("token") 19 | if not base_url or not token: 20 | return None 21 | return PlexServer(base_url, token) 22 | 23 | 24 | @app.route("/", methods=["GET", "POST"]) 25 | def connect(): 26 | if request.method == "POST": 27 | session["base_url"] = request.form["base_url"] 28 | session["token"] = request.form["token"] 29 | session["asset_dir"] = ( 30 | "assets" if not request.form["asset_dir"] else request.form["asset_dir"] 31 | ) 32 | return redirect(url_for("libraries")) 33 | return render_template("connect.html") 34 | 35 | 36 | @app.route("/libraries") 37 | def libraries(): 38 | plex = get_plex() 39 | if not plex: 40 | return redirect(url_for("connect")) 41 | sections = plex.library.sections() 42 | return render_template("libraries.html", sections=sections) 43 | 44 | 45 | # new paginated item picker 46 | @app.route("/browse//items") 47 | def list_items(section_key): 48 | plex = get_plex() 49 | if not plex: 50 | return redirect(url_for("connect")) 51 | 52 | # find the right library section 53 | section = next( 54 | (s for s in plex.library.sections() if str(s.key) == section_key), 55 | None, 56 | ) 57 | if not section: 58 | flash("Library not found.") 59 | return redirect(url_for("libraries")) 60 | 61 | # full list of items 62 | all_items_full = list(section.all()) 63 | 64 | # give everything a global index 65 | global_index = 1 66 | for item in all_items_full: 67 | item.global_index = global_index 68 | global_index = global_index + 1 69 | 70 | # optional letter filter (defaults to 'All') 71 | letter = request.args.get("letter", "All") 72 | if letter != "All": 73 | if letter[:1].isdigit(): 74 | items_filtered = [ 75 | item 76 | for item in all_items_full 77 | if item.title and item.title[:1].isdigit() 78 | ] 79 | else: 80 | items_filtered = [ 81 | item 82 | for item in all_items_full 83 | if item.title and item.title.upper().startswith(letter.upper()) 84 | ] 85 | else: 86 | items_filtered = all_items_full 87 | 88 | # pagination params (based on filtered set) 89 | total = len(items_filtered) 90 | per_page = 20 91 | pages = math.ceil(total / per_page) 92 | 93 | # clamp page number 94 | page = int(request.args.get("page", 1)) 95 | page = max(1, min(page, pages)) 96 | 97 | # slice out this page from filtered items 98 | start = (page - 1) * per_page 99 | page_items = items_filtered[start : start + per_page] 100 | 101 | return render_template( 102 | "items.html", 103 | section=section, 104 | items=page_items, 105 | page=page, 106 | pages=pages, 107 | per_page=per_page, 108 | alphabet=ALPHABET, 109 | letter=letter, 110 | ) 111 | 112 | 113 | @app.route("/browse/") 114 | def browse(section_key): 115 | plex = get_plex() 116 | if not plex: 117 | return redirect(url_for("connect")) 118 | section = next( 119 | (s for s in plex.library.sections() if str(s.key) == section_key), None 120 | ) 121 | if not section: 122 | flash("Library not found.") 123 | return redirect(url_for("libraries")) 124 | items = list(section.all()) 125 | pages = len(items) 126 | item_page = int(request.args.get("page", 1)) 127 | item_letter = request.args.get("letter", "All") 128 | item_page = max(1, min(item_page, pages)) 129 | item = items[item_page - 1] 130 | 131 | art_type = request.args.get("art_type", "poster") 132 | season = request.args.get("season") 133 | try: 134 | season_rating_key = item.season(int(season)).ratingKey 135 | except: 136 | season_rating_key = None 137 | 138 | episode = request.args.get("episode") 139 | art_page = int(request.args.get("art_page", 1)) 140 | 141 | return render_template( 142 | "item.html", 143 | section=section, 144 | item=item, 145 | season_rating_key=season_rating_key, 146 | items=items, 147 | item_page=item_page, 148 | item_letter=item_letter, 149 | pages=pages, 150 | art_type=art_type, 151 | season=season, 152 | episode=episode, 153 | art_page=art_page, 154 | ) 155 | 156 | 157 | @app.route("/download", methods=["POST"]) 158 | def download(): 159 | plex = get_plex() 160 | if not plex: 161 | return redirect(url_for("connect")) 162 | 163 | rating_key = int(request.form["rating_key"]) 164 | try: 165 | season_rating_key = int(request.form["season_rating_key"]) 166 | except: 167 | season_rating_key = None 168 | 169 | # try: 170 | # episode_rating_key = int(request.form['episode_rating_key']) 171 | # except: 172 | # episode_rating_key = None 173 | section_key = request.form["section_key"] 174 | art_type = request.form["art_type"] 175 | img_key = request.form["img_key"] 176 | season = ( 177 | None if request.form.get("season") == "None" else request.form.get("season") 178 | ) 179 | episode = ( 180 | None if request.form.get("episode") == "None" else request.form.get("episode") 181 | ) 182 | item_page = request.form.get("item_page", 1) 183 | art_page = request.form.get("art_page", 1) 184 | 185 | item = plex.fetchItem(rating_key) 186 | season_item = None if not season_rating_key else plex.fetchItem(season_rating_key) 187 | # episode_item = None if not episode_rating_key else plex.fetchItem(episode_rating_key) 188 | 189 | try: 190 | asset_name = None 191 | if item.type == "movie": 192 | media_file = item.media[0].parts[0].file 193 | elif item.type == "show" or season_item: 194 | media_file = item.locations[0] 195 | asset_name = os.path.basename(media_file) 196 | elif item.type == "episode": 197 | media_file = item.media[0].parts[0].file 198 | else: 199 | media_file = None 200 | if not asset_name: 201 | asset_name = os.path.basename(os.path.dirname(media_file)) 202 | except: 203 | asset_name = item.title 204 | 205 | base_dir = os.path.join( 206 | os.getcwd(), 207 | session["asset_dir"], 208 | "movies" if item.type == "movie" else "series", 209 | asset_name, 210 | ) 211 | os.makedirs(base_dir, exist_ok=True) 212 | 213 | ext = os.path.splitext(img_key)[1] or ".jpg" 214 | if item.type == "show": 215 | if episode: 216 | s, e = int(season), int(episode) 217 | name = f"S{s:02d}E{e:02d}" 218 | elif season: 219 | s = int(season) 220 | name = f"Season {s:02d}" 221 | else: 222 | name = art_type 223 | suffix = ( 224 | f"_{art_type}" if art_type == "background" and (season or episode) else "" 225 | ) 226 | filename = f"{name}{suffix}{ext}" 227 | else: 228 | filename = f"{art_type}{ext}" 229 | 230 | if img_key.startswith("http"): 231 | img_url = img_key 232 | else: 233 | img_url = f"{session['base_url']}{img_key}&X-Plex-Token={session['token']}" 234 | 235 | resp = requests.get(img_url) 236 | with open(os.path.join(base_dir, filename), "wb") as f: 237 | f.write(resp.content) 238 | 239 | flash(f"Saved to {os.path.join(base_dir, filename)}") 240 | return redirect( 241 | url_for( 242 | "browse", 243 | section_key=section_key, 244 | page=item_page, 245 | art_type=art_type, 246 | season=season, 247 | episode=episode, 248 | art_page=art_page, 249 | ) 250 | ) 251 | 252 | 253 | if __name__ == "__main__": 254 | app.run(host="0.0.0.0", port=5000) 255 | -------------------------------------------------------------------------------- /Plex Image Picker/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask 2 | plexapi 3 | requests 4 | pylint[spelling] 5 | -------------------------------------------------------------------------------- /Plex Image Picker/static/theme.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | const selector = document.getElementById('theme-selector'); 3 | function applyTheme(theme) { 4 | document.documentElement.setAttribute('data-theme', theme); 5 | } 6 | selector.addEventListener('change', () => applyTheme(selector.value)); 7 | if (selector.value === 'auto') { 8 | const mq = window.matchMedia('(prefers-color-scheme: dark)'); 9 | mq.addEventListener('change', e => applyTheme(mq.matches ? 'dark' : 'light')); 10 | applyTheme(mq.matches ? 'dark' : 'light'); 11 | } else { 12 | applyTheme(selector.value); 13 | } 14 | })(); -------------------------------------------------------------------------------- /Plex Image Picker/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Plex Art Downloader 7 | 8 | 9 | 10 | 11 | 16 |
17 | {% with msgs = get_flashed_messages() %} 18 | {% if msgs %} 19 |
20 | {% for m in msgs %}

{{ m }}

{% endfor %} 21 |
22 | {% endif %} 23 | {% endwith %} 24 | {% block content %}{% endblock %} 25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /Plex Image Picker/templates/connect.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block content %} 3 |

Connect to Plex

4 |
5 |
6 | 7 | 8 |
9 |
10 | 11 | 12 |
13 |
14 | 15 | 16 |
17 | 18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /Plex Image Picker/templates/item.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends 'base.html' %} 3 | {% block content %} 4 | 5 | 6 | 7 | 8 | 9 |

{{ section.title }}: {{ item.title }}

10 |
11 |
12 | First Item 13 | Previous Item 14 | Next Item 15 | Last Item 16 |
17 |
18 | 19 | 20 | 24 |
25 |
26 | 27 | {% if section.type == 'show' %} 28 |
29 | 30 | 31 | 32 |
33 |
34 | 35 | 41 |
42 | {% if season %} 43 |
44 | 45 | {% set sel_season = item.seasons()|selectattr('seasonNumber','equalto',season|int)|first %} 46 | 52 |
53 | {% endif %} 54 |
55 |
56 | {% endif %} 57 | 58 | {# Determine art items based on type and scope #} 59 | {% if section.type=='movie' %} 60 | {% set all_art = art_type=='poster' and item.posters() or item.arts() %} 61 | {% else %} 62 | {% if episode %} 63 | {% set sel_season = item.seasons()|selectattr('seasonNumber','equalto',season|int)|first %} 64 | {% set ep = sel_season.episodes()|selectattr('index','equalto',episode|int)|first %} 65 | {% set all_art = art_type=='poster' and ep.posters() or ep.arts() %} 66 | {% elif season %} 67 | {% set sel_season = item.seasons()|selectattr('seasonNumber','equalto',season|int)|first %} 68 | {% set all_art = art_type=='poster' and sel_season.posters() or sel_season.arts() %} 69 | {% else %} 70 | {% set all_art = art_type=='poster' and item.posters() or item.arts() %} 71 | {% endif %} 72 | {% endif %} 73 | 74 | {% set total_art = all_art|length %} 75 | {% set per_page = 12 %} 76 | {% set art_pages = (total_art + per_page -1) // per_page %} 77 | {% set start = (art_page-1) * per_page %} 78 | {% set end = start + per_page %} 79 | {% set page_items = all_art[start:end] %} 80 | 81 |
82 |
83 | First 84 | Prev 85 | Next 86 | Last 87 |
88 |
89 | 90 | 91 | 92 | 93 | 94 | 95 |
96 |
97 | 98 |
99 | {% for img in page_items %} 100 | {% if img.key.startswith("http") %} 101 | {% set img_url = img.key %} 102 | {% else %} 103 | {% set img_url = session['base_url'] ~ img.key ~ '&X-Plex-Token=' ~ session['token'] %} 104 | {% endif %} 105 |
106 |
107 | 108 |
109 |
110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 |
121 |
122 |
123 |
124 | {% endfor %} 125 |
126 | {% endblock %} 127 | -------------------------------------------------------------------------------- /Plex Image Picker/templates/items.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 |

{{ section.title }} – Pick an Item

5 | 6 | {# ——— Alphabet picker with index‑based active highlighting ——— #} 7 | {% set current_index = 0 %} 8 | {% if letter != 'All' %} 9 | {# Find the position of the selected letter in the alphabet list (0‑based). #} 10 | {% set current_index = alphabet.index(letter) + 1 %} 11 | {% endif %} 12 | 13 |
14 | {% for loop_letter in ['All'] + alphabet %} 15 | {% set idx = loop.index0 %} 16 | 21 | {{ loop_letter }} 22 | 23 | {% endfor %} 24 |
25 | 26 |
    27 | {% for item in items %} 28 |
  • 29 | {# calculate the global index for browse → item_page #} 30 | {% set global_index = (page - 1) * per_page + loop.index %} 31 | 35 | {{ item.title }} 36 | 37 |
  • 38 | {% endfor %} 39 |
40 | 41 | 76 | {% endblock %} 77 | -------------------------------------------------------------------------------- /Plex Image Picker/templates/libraries.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block content %} 3 |

Select Library

4 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /Plex/.env.example: -------------------------------------------------------------------------------- 1 | # PLEX API ENV VARS 2 | PLEXAPI_PLEXAPI_TIMEOUT='360' 3 | PLEXAPI_AUTH_SERVER_BASEURL=https://plex.domain.tld 4 | # Just the base URL, no /web or anything at the end. 5 | # i.e. http://192.168.1.11:32400 or the like 6 | PLEXAPI_AUTH_SERVER_TOKEN=PLEX-TOKEN 7 | PLEXAPI_LOG_BACKUP_COUNT='3' 8 | PLEXAPI_LOG_FORMAT='%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s' # PLEX API ENV VARS 9 | PLEXAPI_LOG_LEVEL='INFO' 10 | PLEXAPI_LOG_PATH='plexapi.log' 11 | PLEXAPI_LOG_ROTATE_BYTES='512000' 12 | PLEXAPI_LOG_SHOW_SECRETS=0 13 | 14 | # GENERAL ENV VARS 15 | TMDB_KEY=TMDB_API_KEY # https://developers.themoviedb.org/3/getting-started/introduction 16 | TVDB_KEY=TVDB_V4_API_KEY # currently not used; https://thetvdb.com/api-information 17 | DELAY=1 # optional delay between items 18 | LIBRARY_NAMES=Movies,TV Shows,Movies 4K # comma-separated list of libraries to act on 19 | 20 | # IMAGE DOWNLOAD ENV VARS 21 | ## what-to-grab 22 | GRAB_SEASONS=1 # should get-all-posters retrieve season posters? 23 | GRAB_EPISODES=1 # should get-all-posters retrieve episode posters? [requires GRAB_SEASONS] 24 | GRAB_BACKGROUNDS=1 # should get-all-posters retrieve backgrounds? 25 | ONLY_CURRENT=0 # should get-all-posters retrieve ONLY current artwork? 26 | ARTWORK=1 # current background is downloaded with current poster 27 | INCLUDE_COLLECTION_ARTWORK=1 # should get-all-posters retrieve collection posters? 28 | ONLY_COLLECTION_ARTWORK=0 # should get-all-posters retrieve ONLY collection posters? 29 | ONLY_THESE_COLLECTIONS=Bing|Bang|Boing # only grab artwork for these collections and items in them 30 | POSTER_DEPTH=20 # grab this many posters [0 grabs all] 31 | KEEP_JUNK=0 # keep files that script would normally delete [incorrect filetypes, mainly] 32 | FIND_OVERLAID_IMAGES=0 # check all downloaded images for overlays 33 | # RETAIN_OVERLAID_IMAGES=0 # keep images that have an overlay EXIF tag [this will override the following two] 34 | RETAIN_KOMETA_OVERLAID_IMAGES=0 # keep images that have the Kometa overlay EXIF tag 35 | RETAIN_TCM_OVERLAID_IMAGES=0 # keep images that have the TCM overlay EXIF tag 36 | 37 | ## where-to-put-it 38 | USE_ASSET_NAMING=1 # should grab-all-posters name images to match Kometa's Asset Directory requirements? 39 | USE_ASSET_FOLDERS=1 # should those Kometa-Asset-Directory names use asset folders? 40 | USE_ASSET_SUBFOLDERS=0 # create asset folders in subfolders ["Collections", "Other", or [0-9, A-Z]] ] 41 | ASSETS_BY_LIBRARIES=1 # should those Kometa-Asset-Directory images be sorted into library folders? 42 | ASSET_DIR=assets # top-level directory for those Kometa-Asset-Directory images 43 | # if asset-directory naming is on, the next three are ignored 44 | POSTER_DIR=extracted_posters # put downloaded posters here 45 | CURRENT_POSTER_DIR=current_posters # put downloaded current posters and artwork here 46 | POSTER_CONSOLIDATE=0 # if false, posters are separated into folders by library 47 | 48 | ## tracking 49 | TRACK_URLS=1 # If set to 1, URLS are tracked and won't be downloaded twice 50 | TRACK_COMPLETION=1 # If set to 1, movies/shows are tracked as complete by rating id 51 | TRACK_IMAGE_SOURCES=1 # keep a file containing file names and source URLs 52 | 53 | ## general 54 | POSTER_DOWNLOAD=1 # if false, generate a script rather than downloading 55 | FOLDERS_ONLY=0 # Just build out the folder hierarchy; no image downloading 56 | DEFAULT_YEARS_BACK=2 # in absence of a "last run date", grab things added this many years back. 57 | # 0 sets the fallback date to the beginning of time 58 | THREADED_DOWNLOADS=0 # should downloads be done in the background in threads? 59 | RESET_LIBRARIES=Bing,Bang,Boing # reset "last time" count to the fallback date for these libraries 60 | RESET_COLLECTIONS=Bing,Bang,Boing # CURRENTLY UNUSED 61 | ADD_SOURCE_EXIF_COMMENT=1 # CURRENTLY UNUSED 62 | 63 | # STATUS ENV VARS 64 | PLEX_OWNER=yournamehere # account name of the server owner 65 | TARGET_PLEX_URL=https://plex.domain2.tld # As above, the target of apply_all_status 66 | TARGET_PLEX_TOKEN=PLEX-TOKEN-TWO # As above, the target of apply_all_status 67 | TARGET_PLEX_OWNER=yournamehere # As above, the target of apply_all_status 68 | LIBRARY_MAP={"LIBRARY_ON_PLEX":"LIBRARY_ON_TARGET_PLEX", ...} 69 | # In apply_all_status, map libraries according to this JSON. 70 | 71 | # RESET-POSTERS ENV VARS 72 | TRACK_RESET_STATUS=1 # should reset-posters-* keep track of status and pick up where it left off? 73 | LOCAL_RESET_ARCHIVE=1 # should reset-posters-tmdb keep a local archive of posters? 74 | TARGET_LABELS=this label, that label # comma-separated list of labels to reset posters on 75 | REMOVE_LABELS=0 # attempt to remove the TARGET_LABELs from items after resetting the poster 76 | RESET_SEASONS=1 # reset-posters-* resets season artwork as well in TV libraries 77 | RESET_EPISODES=1 # reset-posters-* resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] 78 | RETAIN_RESET_STATUS_FILE=0 # Don't delete the reset progress file at the end 79 | FLUSH_STATUS_AT_START=0 # Delete the reset progress file at the start instead of reading it 80 | RESET_SEASONS_WITH_SERIES=0 # If there isn't a season poster, use the series poster 81 | DRY_RUN=0 # [currently only works with reset-posters-*]; don't actually do anything, just log 82 | 83 | # LIST ITEM IDS ENV VARS 84 | INCLUDE_COLLECTION_MEMBERS=0 85 | ONLY_COLLECTION_MEMBERS=0 86 | 87 | # DELETE_COLLECTION ENV VARS 88 | KEEP_COLLECTIONS=bing,bang # List of collections to keep 89 | 90 | # REMATCH-ITEMS ENV VARS 91 | UNMATCHED_ONLY=1 # If 1, only rematch things that are currently unmatched 92 | 93 | # RESET_ADDED_AT 94 | ADJUST_DATE_FUTURES_ONLY=0 # Only look at items that show up as added in the future 95 | ADJUST_DATE_EPOCH_ONLY=1 # Only adjust items that have "originally available" dates of `1970-01-01` 96 | 97 | # REFRESH_METADATA 98 | REFRESH_1970_ONLY=1 # If 1, only refresh things that have an originally-available date of 1970-01-01 99 | 100 | # ACTOR ENV VARS 101 | CAST_DEPTH=20 # how deep to go into the cast for actor collections 102 | TOP_COUNT=10 # how many actors to export 103 | JOB_TYPE=Actor # Actor or Director 104 | KNOWN_FOR_ONLY=0 # ignore cast members who are not primarily known as JOBTYPE 105 | BUILD_COLLECTIONS=0 # build yaml for Kometa config.yml 106 | NUM_COLLECTIONS=20 # this many actors in Kometa yaml 107 | TRACK_GENDER=1 # Pay attention to actor gender [as recorded on TMDB] 108 | MIN_GENDER_NONE=5 # include minimum this many "none" gendered actors in the YAML, if possible 109 | MIN_GENDER_FEMALE=5 # include minimum this many "female" gendered actors in the YAML, if possible 110 | MIN_GENDER_MALE=5 # include minimum this many "male" gendered actors in the YAML, if possible 111 | MIN_GENDER_NB=5 # include minimum this many "non-binary" gendered actors in the YAML, if possible 112 | 113 | # LOW POSTER COUNT 114 | POSTER_THRESHOLD=10 115 | 116 | # CREW COUNT 117 | CREW_DEPTH=20 118 | CREW_COUNT=100 119 | TARGET_JOB=Director 120 | SHOW_JOBS=0 121 | 122 | # attempt to hide sqlalchemy2 warnings 123 | SQLALCHEMY_WARN_20=0 124 | SQLALCHEMY_SILENCE_UBER_WARNING=1 125 | -------------------------------------------------------------------------------- /Plex/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: Current File", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal", 13 | "cwd": "${fileDirname}", 14 | "justMyCode": true 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /Plex/ID-notes.txt: -------------------------------------------------------------------------------- 1 | 2 | after running, set source to 0 on all null 3 | 4 | sqlite3 ids.sqlite "update keys set source=0 where source is null;" ".headers on" ".mode csv" ".output ids.csv" "select * from keys;" ".exit" 5 | 6 | Maybe use this to git add-commit-push the db and csv? 7 | 8 | https://terracoders.com/blog/git-add-commit-and-push-all-once-bash-function 9 | 10 | -------------------------------------------------------------------------------- /Plex/adjust-added-dates.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from datetime import datetime 4 | from pathlib import Path 5 | 6 | from alive_progress import alive_bar 7 | from helpers import ( 8 | booler, 9 | get_all_from_library, 10 | get_ids, 11 | get_plex, 12 | load_and_upgrade_env, 13 | ) 14 | from logs import blogger, logger, plogger, setup_logger 15 | from tmdbapis import TMDbAPIs 16 | 17 | SCRIPT_NAME = Path(__file__).stem 18 | 19 | env_file_path = Path(".env") 20 | 21 | # 0.1.1 Log config details 22 | # 0.1.2 incorporate helper changes, remove testing code 23 | 24 | VERSION = "0.1.2" 25 | 26 | # current dateTime 27 | now = datetime.now() 28 | 29 | # convert to string 30 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 31 | 32 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 33 | 34 | setup_logger("activity_log", ACTIVITY_LOG) 35 | 36 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 37 | 38 | if load_and_upgrade_env(env_file_path) < 0: 39 | exit() 40 | 41 | plex = get_plex() 42 | 43 | logger("connection success", "info", "a") 44 | 45 | ADJUST_DATE_FUTURES_ONLY = booler(os.getenv("ADJUST_DATE_FUTURES_ONLY")) 46 | plogger(f"ADJUST_DATE_FUTURES_ONLY: {ADJUST_DATE_FUTURES_ONLY}", "info", "a") 47 | 48 | ADJUST_DATE_EPOCH_ONLY = booler(os.getenv("ADJUST_DATE_EPOCH_ONLY")) 49 | plogger(f"ADJUST_DATE_EPOCH_ONLY: {ADJUST_DATE_EPOCH_ONLY}", "info", "a") 50 | 51 | EPOCH_DATE = datetime(1970, 1, 1, 0, 0, 0) 52 | 53 | TMDB_KEY = os.getenv("TMDB_KEY") 54 | 55 | tmdb = TMDbAPIs(TMDB_KEY, language="en") 56 | 57 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 58 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 59 | 60 | if LIBRARY_NAMES: 61 | LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] 62 | else: 63 | LIB_ARRAY = [LIBRARY_NAME] 64 | 65 | if LIBRARY_NAMES == "ALL_LIBRARIES": 66 | LIB_ARRAY = [] 67 | all_libs = plex.library.sections() 68 | for lib in all_libs: 69 | if lib.type == "movie" or lib.type == "show": 70 | LIB_ARRAY.append(lib.title.strip()) 71 | 72 | plogger(f"Acting on libraries: {LIB_ARRAY}", "info", "a") 73 | 74 | 75 | def is_epoch(the_date): 76 | ret_val = False 77 | 78 | if the_date is not None: 79 | ret_val = the_date.year == 1970 and the_date.month == 1 and the_date.day == 1 80 | 81 | return ret_val 82 | 83 | 84 | for lib in LIB_ARRAY: 85 | try: 86 | plogger(f"Loading {lib} ...", "info", "a") 87 | the_lib = plex.library.section(lib) 88 | is_movie = the_lib.type == "movie" 89 | is_show = the_lib.type == "show" 90 | 91 | if not is_movie: 92 | print("the script hasn't been tested with non-movie libraries, skipping.") 93 | # continue 94 | 95 | lib_size = the_lib.totalViewSize() 96 | 97 | if ADJUST_DATE_FUTURES_ONLY: 98 | TODAY_STR = now.strftime("%Y-%m-%d") 99 | item_count, items = get_all_from_library( 100 | the_lib, None, {"addedAt>>": TODAY_STR} 101 | ) 102 | else: 103 | item_count, items = get_all_from_library(the_lib) 104 | 105 | if item_count > 0: 106 | logger(f"looping over {item_count} items...", "info", "a") 107 | items_processed = 0 108 | 109 | plex_links = [] 110 | external_links = [] 111 | 112 | with alive_bar( 113 | item_count, dual_line=True, title=f"Adjust added dates {the_lib.title}" 114 | ) as bar: 115 | for item in items: 116 | try: 117 | items_processed += 1 118 | added_too_far_apart = False 119 | orig_too_far_apart = False 120 | sub_items = [item] 121 | 122 | if is_show: 123 | episodes = item.episodes() 124 | sub_items = sub_items + episodes 125 | 126 | for sub_item in sub_items: 127 | try: 128 | imdbid, tmid, tvid = get_ids(sub_item.guids, None) 129 | 130 | if is_movie: 131 | tmdb_item = tmdb.movie(tmid) 132 | release_date = tmdb_item.release_date 133 | else: 134 | if sub_item.type == "show": 135 | tmdb_item = tmdb.tv_show(tmid) 136 | release_date = tmdb_item.first_air_date 137 | else: 138 | parent_show = sub_item.show() 139 | imdbid, tmid, tvid = get_ids( 140 | parent_show.guids, None 141 | ) 142 | season_num = sub_item.seasonNumber 143 | episode_num = sub_item.episodeNumber 144 | 145 | tmdb_item = tmdb.tv_episode( 146 | tmid, season_num, episode_num 147 | ) 148 | release_date = tmdb_item.air_date 149 | 150 | added_date = item.addedAt 151 | orig_date = item.originallyAvailableAt 152 | 153 | if not ADJUST_DATE_EPOCH_ONLY or ( 154 | ADJUST_DATE_EPOCH_ONLY and is_epoch(orig_date) 155 | ): 156 | try: 157 | delta = added_date - release_date 158 | added_too_far_apart = abs(delta.days) > 1 159 | except: 160 | added_too_far_apart = ( 161 | added_date is None 162 | and release_date is not None 163 | ) 164 | 165 | try: 166 | delta = orig_date - release_date 167 | orig_too_far_apart = abs(delta.days) > 1 168 | except: 169 | orig_too_far_apart = ( 170 | orig_date is None 171 | and release_date is not None 172 | ) 173 | 174 | if added_too_far_apart: 175 | try: 176 | item.addedAt = release_date 177 | blogger( 178 | f"Set {sub_item.title} added at to {release_date}", 179 | "info", 180 | "a", 181 | bar, 182 | ) 183 | except Exception as ex: 184 | plogger( 185 | f"Problem processing {item.title}; {ex}", 186 | "info", 187 | "a", 188 | ) 189 | 190 | if orig_too_far_apart: 191 | try: 192 | item.originallyAvailableAt = release_date 193 | blogger( 194 | f"Set {sub_item.title} originally available at to {release_date}", 195 | "info", 196 | "a", 197 | bar, 198 | ) 199 | except Exception as ex: 200 | plogger( 201 | f"Problem processing {item.title}; {ex}", 202 | "info", 203 | "a", 204 | ) 205 | 206 | else: 207 | blogger( 208 | f"skipping {item.title}: EPOCH_ONLY {ADJUST_DATE_EPOCH_ONLY}, originally available date {orig_date}", 209 | "info", 210 | "a", 211 | bar, 212 | ) 213 | 214 | except Exception as ex: 215 | plogger( 216 | f"Problem processing sub_item {item.title}; {ex}", 217 | "info", 218 | "a", 219 | ) 220 | 221 | except Exception as ex: 222 | plogger(f"Problem processing {item.title}; {ex}", "info", "a") 223 | 224 | bar() 225 | 226 | plogger(f"Processed {items_processed} of {item_count}", "info", "a") 227 | 228 | progress_str = "COMPLETE" 229 | logger(progress_str, "info", "a") 230 | 231 | except Exception as ex: 232 | progress_str = f"Problem processing {lib}; {ex}" 233 | plogger(progress_str, "info", "a") 234 | -------------------------------------------------------------------------------- /Plex/apply-all-status.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | import os 4 | import re 5 | import sys 6 | import textwrap 7 | from datetime import datetime 8 | from pathlib import Path 9 | 10 | from helpers import get_plex, load_and_upgrade_env 11 | from logs import plogger, setup_logger 12 | 13 | SCRIPT_NAME = Path(__file__).stem 14 | 15 | VERSION = "0.1.1" 16 | 17 | # DONE 0.1.1: guard against empty library map 18 | 19 | # current dateTime 20 | now = datetime.now() 21 | 22 | # convert to string 23 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 24 | 25 | env_file_path = Path(".env") 26 | 27 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 28 | 29 | setup_logger("activity_log", ACTIVITY_LOG) 30 | 31 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 32 | 33 | if load_and_upgrade_env(env_file_path) < 0: 34 | exit() 35 | 36 | PLEX_OWNER = os.getenv("TARGET_PLEX_OWNER") 37 | 38 | LIBRARY_MAP = os.getenv("LIBRARY_MAP", "{}") 39 | 40 | try: 41 | lib_map = json.loads(LIBRARY_MAP) 42 | except: 43 | plogger( 44 | "LIBRARY_MAP in the .env file appears to be broken. Defaulting to an empty list.", 45 | "info", 46 | "a", 47 | ) 48 | lib_map = json.loads("{}") 49 | 50 | 51 | def progress(count, total, status=""): 52 | bar_len = 40 53 | filled_len = int(round(bar_len * count / float(total))) 54 | 55 | percents = round(100.0 * count / float(total), 1) 56 | bar = "=" * filled_len + "-" * (bar_len - filled_len) 57 | stat_str = textwrap.shorten(status, width=80) 58 | 59 | sys.stdout.write("[%s] %s%s ... %s\r" % (bar, percents, "%", stat_str.ljust(80))) 60 | sys.stdout.flush() 61 | 62 | 63 | def get_user_acct(acct_list, title): 64 | for acct in acct_list: 65 | if acct.title == title: 66 | return acct 67 | 68 | 69 | padwidth = 105 70 | count = 0 71 | connected_plex_user = PLEX_OWNER 72 | connected_plex_library = None 73 | current_show = None 74 | last_library = None 75 | 76 | plex = get_plex() 77 | PMI = plex.machineIdentifier 78 | 79 | account = plex.myPlexAccount() 80 | all_users = account.users() 81 | item = None 82 | 83 | with open("status.txt") as fp: 84 | for line in fp: 85 | item = None 86 | 87 | count += 1 88 | 89 | parts = line.split("\t") 90 | if len(parts) == 1: 91 | continue # this is an error line 92 | 93 | # chazlarson show TV Shows - 4K After Life s01e01 Episode 1 94 | # 0 1 2 3 4 5 95 | # chazlarson movie Movies - 4K 10 Things I Hate About You 1999 PG-13 96 | # 0 1 2 3 4 5 97 | plex_user = parts[0].strip() 98 | plex_type = parts[1].strip() 99 | plex_library = parts[2].strip() 100 | mapped_from = "" 101 | 102 | if plex_library in lib_map: 103 | mapped_from = f" [mapped from {plex_library}]" 104 | plex_library = lib_map[plex_library] 105 | 106 | if plex_type == "show": 107 | # chazlarson show TV Shows - 4K After Life s01e01 Episode 1 108 | # 0 1 2 3 4 5 109 | plex_series = parts[3].strip() 110 | plex_ep = parts[4].strip() 111 | plex_title = parts[5].strip() 112 | 113 | tmp = re.split("[se]", plex_ep) 114 | # ['', '01', '14'] 115 | try: 116 | plex_season = int(tmp[1]) 117 | plex_episode = int(tmp[2]) 118 | except: 119 | continue 120 | else: 121 | # chazlarson movie Movies - 4K 10 Things I Hate About You 1999 PG-13 122 | # 0 1 2 3 4 5 123 | plex_title = parts[3].strip() # Episode 2 124 | plex_year = parts[4].strip() 125 | plex_rating = parts[5].strip() 126 | 127 | if plex_user != connected_plex_user: 128 | plex = None 129 | if plex_user.lower() == PLEX_OWNER.lower(): 130 | plex = get_plex() 131 | else: 132 | user_acct = get_user_acct(all_users, plex_user) 133 | if user_acct: 134 | plex = get_plex(user_acct.get_token(PMI)) 135 | if plex is not None: 136 | connected_plex_user = plex_user 137 | print(f"------------ {connected_plex_user} ------------") 138 | else: 139 | connected_plex_user = None 140 | print(f"---- NOT FOUND: {plex_user} ------------") 141 | 142 | if plex is not None: 143 | if plex_library != connected_plex_library: 144 | try: 145 | items = plex.library.section(plex_library) 146 | connected_plex_library = plex_library 147 | last_library = None 148 | print( 149 | f"\r{os.linesep}------------ {connected_plex_library}{mapped_from} ------------" 150 | ) 151 | except: 152 | if last_library is None: 153 | print( 154 | f"\r{os.linesep}------------ Exception connecting to {plex_library} ------------" 155 | ) 156 | last_library = plex_library 157 | connected_plex_library = None 158 | else: 159 | connected_plex_library = None 160 | 161 | if connected_plex_library is not None: 162 | if plex_type == "show": 163 | plex_target = f"{plex_series} {plex_ep}" 164 | sys.stdout.write( 165 | f"\rSearching for unwatched {plex_series}".ljust(padwidth) 166 | ) 167 | sys.stdout.flush() 168 | if current_show != plex_series: 169 | things = items.searchShows(title=plex_series, unwatched=True) 170 | current_show = plex_series 171 | correct_show = None 172 | unwatched_eps = None 173 | if len(things) > 0: 174 | title_ct = 0 175 | if correct_show is None: 176 | for thing in things: 177 | if item is None: 178 | title_ct += 1 179 | if thing.title == plex_series: 180 | correct_show = thing 181 | if correct_show is not None: 182 | if unwatched_eps is None: 183 | unwatched_eps = correct_show.unwatched() 184 | 185 | for epi in unwatched_eps: 186 | if epi.seasonEpisode == plex_ep: 187 | item = epi 188 | else: 189 | sys.stdout.write( 190 | f"\rSkipping {plex_target} - show is watched".ljust(padwidth) 191 | ) 192 | sys.stdout.flush() 193 | elif plex_type == "movie": 194 | plex_target = f"{plex_title} ({plex_year})" 195 | sys.stdout.write( 196 | f"\rSearching for an unwatched {plex_target}".ljust(padwidth) 197 | ) 198 | sys.stdout.flush() 199 | things = items.search(title=plex_title, unwatched=True) 200 | title_ct = 0 201 | title_match_ct = 0 202 | title_year_ct = 0 203 | title_rating_ct = 0 204 | for thing in things: 205 | if item is None: 206 | title_ct += 1 207 | unWatched = not thing.isPlayed 208 | if thing.title == plex_title: 209 | title_match_ct += 1 210 | if thing.year == int(plex_year): 211 | title_year_ct += 1 212 | if thing.contentRating == plex_rating: 213 | title_rating_ct += 1 214 | if unWatched: 215 | item = thing 216 | 217 | if title_match_ct > 1: 218 | print( 219 | f"\r{title_match_ct} title matches for {plex_title}".ljust( 220 | padwidth 221 | ) 222 | ) 223 | if title_year_ct > 1: 224 | print( 225 | f"\r{title_year_ct} title-year matches for {plex_title}".ljust( 226 | padwidth 227 | ) 228 | ) 229 | if title_rating_ct > 1: 230 | print( 231 | f"\r{title_rating_ct} title-year-rating matches for {plex_title}".ljust( 232 | padwidth 233 | ) 234 | ) 235 | else: 236 | print(f"Unknown type: {plex_type}") 237 | 238 | if item is not None: 239 | # print(f"\rPicked {item.title} - {item.year} - {item.contentRating} for {plex_title}".ljust(padwidth)) 240 | if not item.isPlayed: 241 | print( 242 | f"\rMarked watched for {connected_plex_user} - {plex_target}".ljust( 243 | padwidth 244 | ) 245 | ) 246 | item.markPlayed() 247 | # else: 248 | # print(f"\rAlready marked watched for {connected_plex_user}") 249 | # sys.stdout.write(f"\r ".ljust(padwidth)) 250 | # sys.stdout.flush() 251 | -------------------------------------------------------------------------------- /Plex/changes.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chazlarson/Media-Scripts/aad427055e7ec8031936ffc29a1ad19e0267fd1e/Plex/changes.txt -------------------------------------------------------------------------------- /Plex/crew-count.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import textwrap 4 | from collections import Counter 5 | 6 | from dotenv import load_dotenv 7 | from helpers import booler 8 | from plexapi.server import PlexServer 9 | from tmdbapis import TMDbAPIs 10 | 11 | load_dotenv() 12 | 13 | PLEX_URL = os.getenv("PLEX_URL") 14 | PLEX_TOKEN = os.getenv("PLEX_TOKEN") 15 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 16 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 17 | TMDB_KEY = os.getenv("TMDB_KEY") 18 | TVDB_KEY = os.getenv("TVDB_KEY") 19 | CREW_DEPTH = int(os.getenv("CREW_DEPTH")) 20 | CREW_COUNT = int(os.getenv("CREW_COUNT")) 21 | TARGET_JOB = os.getenv("TARGET_JOB") 22 | DELAY = int(os.getenv("DELAY")) 23 | SHOW_JOBS = booler(os.getenv("SHOW_JOBS")) 24 | 25 | if not DELAY: 26 | DELAY = 0 27 | 28 | if LIBRARY_NAMES: 29 | lib_array = LIBRARY_NAMES.split(",") 30 | else: 31 | lib_array = [LIBRARY_NAME] 32 | 33 | tmdb = TMDbAPIs(TMDB_KEY, language="en") 34 | 35 | tmdb_str = "tmdb://" 36 | tvdb_str = "tvdb://" 37 | 38 | individuals = Counter() 39 | jobs = Counter() 40 | 41 | YAML_STR = "" 42 | COLL_TMPL = "" 43 | 44 | 45 | def getTID(theList): 46 | tmid = None 47 | tvid = None 48 | for guid in theList: 49 | if tmdb_str in guid.id: 50 | tmid = guid.id.replace(tmdb_str, "") 51 | if tvdb_str in guid.id: 52 | tvid = guid.id.replace(tvdb_str, "") 53 | return tmid, tvid 54 | 55 | 56 | def progress(count, total, status=""): 57 | bar_len = 40 58 | filled_len = int(round(bar_len * count / float(total))) 59 | 60 | percents = round(100.0 * count / float(total), 1) 61 | bar = "=" * filled_len + "-" * (bar_len - filled_len) 62 | stat_str = textwrap.shorten(status, width=30) 63 | 64 | sys.stdout.write("[%s] %s%s ... %s\r" % (bar, percents, "%", stat_str.ljust(30))) 65 | sys.stdout.flush() 66 | 67 | 68 | print("connecting to Plex...") 69 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 70 | for lib in lib_array: 71 | print(f"getting items from [{lib}]...") 72 | items = plex.library.section(lib).all() 73 | item_total = len(items) 74 | print(f"looping over {item_total} items...") 75 | item_count = 1 76 | for item in items: 77 | jobDict = {} 78 | tmpDict = {} 79 | tmdb_id, tvdb_id = getTID(item.guids) 80 | item_count = item_count + 1 81 | try: 82 | progress(item_count, item_total, item.title) 83 | crew = None 84 | if item.TYPE == "show": 85 | crew = tmdb.tv_show(tmdb_id).crew 86 | else: 87 | crew = tmdb.movie(tmdb_id).crew 88 | count = 0 89 | for individual in crew: 90 | if count < CREW_DEPTH: 91 | count = count + 1 92 | if individual.job == TARGET_JOB: 93 | tmpDict[f"{individual.name} - {individual.person_id}"] = 1 94 | if SHOW_JOBS: 95 | jobDict[f"{individual.job}"] = 1 96 | 97 | individuals.update(tmpDict) 98 | jobs.update(jobDict) 99 | except Exception: 100 | progress(item_count, item_total, "EX: " + item.title) 101 | 102 | print("\r\r") 103 | 104 | FOUND_COUNT = len(individuals.items()) 105 | count = 0 106 | print( 107 | f"Top {FOUND_COUNT if FOUND_COUNT < CREW_COUNT else CREW_COUNT} {TARGET_JOB} in [{lib}]:" 108 | ) 109 | for individual in sorted(individuals.items(), key=lambda x: x[1], reverse=True): 110 | if count < CREW_COUNT: 111 | print("{}\t{}".format(individual[1], individual[0])) 112 | count = count + 1 113 | 114 | JOB_COUNT = len(jobs.items()) 115 | count = 0 116 | print(f"{JOB_COUNT} defined [{lib}]:") 117 | for job in sorted(jobs.items(), key=lambda x: x[1], reverse=True): 118 | print("{}\t{}".format(job[1], job[0])) 119 | -------------------------------------------------------------------------------- /Plex/delete-collections.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import time 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | from alive_progress import alive_bar 8 | from helpers import get_plex, load_and_upgrade_env 9 | from logs import plogger, setup_logger 10 | 11 | SCRIPT_NAME = Path(__file__).stem 12 | 13 | VERSION = "0.1.0" 14 | 15 | # current dateTime 16 | now = datetime.now() 17 | 18 | # convert to string 19 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 20 | 21 | env_file_path = Path(".env") 22 | 23 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 24 | 25 | setup_logger("activity_log", ACTIVITY_LOG) 26 | 27 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 28 | 29 | if load_and_upgrade_env(env_file_path) < 0: 30 | exit() 31 | 32 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 33 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 34 | DELAY = int(os.getenv("DELAY")) 35 | KEEP_COLLECTIONS = os.getenv("KEEP_COLLECTIONS") 36 | 37 | if not DELAY: 38 | DELAY = 0 39 | 40 | if LIBRARY_NAMES: 41 | LIB_ARRAY = LIBRARY_NAMES.split(",") 42 | else: 43 | LIB_ARRAY = [LIBRARY_NAME] 44 | 45 | plogger(f"Acting on libraries: {LIB_ARRAY}", "info", "a") 46 | 47 | if KEEP_COLLECTIONS: 48 | keeper_array = KEEP_COLLECTIONS.split(",") 49 | else: 50 | keeper_array = [KEEP_COLLECTIONS] 51 | 52 | plex = get_plex() 53 | 54 | if LIBRARY_NAMES == "ALL_LIBRARIES": 55 | LIB_ARRAY = [] 56 | all_libs = plex.library.sections() 57 | for lib in all_libs: 58 | if lib.type == "movie" or lib.type == "show": 59 | LIB_ARRAY.append(lib.title.strip()) 60 | 61 | coll_obj = {} 62 | coll_obj["collections"] = {} 63 | 64 | 65 | def get_sort_text(argument): 66 | switcher = {0: "release", 1: "alpha", 2: "custom"} 67 | return switcher.get(argument, "invalid-sort") 68 | 69 | 70 | for lib in LIB_ARRAY: 71 | the_lib = plex.library.section(lib) 72 | items = the_lib.collections() 73 | item_total = len(items) 74 | print(f"{item_total} collection(s) retrieved...") 75 | item_count = 1 76 | with alive_bar(item_total, dual_line=True, title="Collection delete - Plex") as bar: 77 | for item in items: 78 | title = item.title 79 | 80 | if title in keeper_array: 81 | bar.text = f"-> keeping: {title}" 82 | else: 83 | bar.text = f"-> deleting: {title}" 84 | item.delete() 85 | 86 | bar() 87 | 88 | # Wait between items in case hammering the Plex server turns out badly. 89 | time.sleep(DELAY) 90 | -------------------------------------------------------------------------------- /Plex/grab-all-IDs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | from alive_progress import alive_bar 8 | from database import get_completed, get_count, get_diffs, insert_record, update_record 9 | from helpers import get_all_from_library, get_ids, get_plex, load_and_upgrade_env 10 | 11 | # current dateTime 12 | now = datetime.now() 13 | 14 | # convert to string 15 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 16 | 17 | SCRIPT_NAME = Path(__file__).stem 18 | 19 | VERSION = "0.1.1" 20 | 21 | # 0.1.1 refactoring changes 22 | # 0.2.0 get rid of sqlalchemy, use the same database module as the others 23 | 24 | env_file_path = Path(".env") 25 | 26 | logging.basicConfig( 27 | filename=f"{SCRIPT_NAME}.log", 28 | filemode="w", 29 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 30 | level=logging.INFO, 31 | ) 32 | 33 | logging.info(f"Starting {SCRIPT_NAME}") 34 | print(f"Starting {SCRIPT_NAME}") 35 | 36 | if load_and_upgrade_env(env_file_path) < 0: 37 | exit() 38 | 39 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 40 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 41 | TMDB_KEY = os.getenv("TMDB_KEY") 42 | NEW = [] 43 | UPDATED = [] 44 | 45 | if LIBRARY_NAMES: 46 | LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] 47 | else: 48 | LIB_ARRAY = [LIBRARY_NAME] 49 | 50 | CHANGE_FILE_NAME = "changes.txt" 51 | change_file = Path(CHANGE_FILE_NAME) 52 | # Delete any existing change file 53 | if change_file.is_file(): 54 | change_file.unlink() 55 | 56 | plex = get_plex() 57 | 58 | logging.info("connection success") 59 | 60 | 61 | def get_IDs(type, item): 62 | imdbid = None 63 | tmid = None 64 | tvid = None 65 | raw_guid = item.guid 66 | bits = raw_guid.split("/") 67 | # plex://movie/5d776b59ad5437001f79c6f8 68 | # local://3961921 69 | if bits[0] == "plex:": 70 | try: 71 | guid = bits[3] 72 | 73 | if guid not in COMPLETE_ARRAY: 74 | try: 75 | if item.type != "collection": 76 | logging.info("Getting IDs") 77 | imdbid, tmid, tvid = get_ids(item.guids, TMDB_KEY) 78 | complete = ( 79 | imdbid is not None and tmid is not None and tvid is not None 80 | ) 81 | payload = { 82 | "guid": guid, 83 | "imdb": imdbid, 84 | "tmdb": tmid, 85 | "tvdb": tvid, 86 | "title": item.title, 87 | "year": item.year, 88 | "type": type, 89 | "complete": complete, 90 | } 91 | 92 | diffs = get_diffs(payload) 93 | 94 | if diffs["new"] or diffs["updated"]: 95 | # record change 96 | if diffs["new"]: 97 | action = "new" 98 | NEW.append(guid) 99 | insert_record(payload) 100 | else: 101 | action = "updated" 102 | UPDATED.append(guid) 103 | update_record(payload) 104 | 105 | with open(change_file, "a", encoding="utf-8") as cf: 106 | cf.write(f"{action} - {payload} {os.linesep}") 107 | 108 | except Exception as ex: 109 | print(f"{item.ratingKey}- {item.title} - Exception: {ex}") 110 | logging.info( 111 | f"EXCEPTION: {item.ratingKey}- {item.title} - Exception: {ex}" 112 | ) 113 | else: 114 | logging.info(f"{guid} already complete") 115 | except Exception: 116 | logging.info(f"No guid: {bits}") 117 | 118 | 119 | COMPLETE_ARRAY = [] 120 | 121 | if LIBRARY_NAMES == "ALL_LIBRARIES": 122 | LIB_ARRAY = [] 123 | all_libs = plex.library.sections() 124 | for lib in all_libs: 125 | if lib.type == "movie" or lib.type == "show": 126 | LIB_ARRAY.append(lib.title.strip()) 127 | 128 | with open(change_file, "a", encoding="utf-8") as cf: 129 | cf.write(f"start: {get_count()} records{os.linesep}") 130 | 131 | for lib in LIB_ARRAY: 132 | completed_things = get_completed() 133 | 134 | for thing in completed_things: 135 | COMPLETE_ARRAY.append(thing[0]) 136 | 137 | try: 138 | the_lib = plex.library.section(lib) 139 | 140 | count = plex.library.section(lib).totalSize 141 | print(f"getting {count} {the_lib.type}s from [{lib}]...") 142 | logging.info(f"getting {count} {the_lib.type}s from [{lib}]...") 143 | item_total, items = get_all_from_library(the_lib, the_lib.type) 144 | logging.info(f"looping over {item_total} items...") 145 | item_count = 1 146 | 147 | plex_links = [] 148 | external_links = [] 149 | 150 | with alive_bar(item_total, dual_line=True, title="Grab all IDs") as bar: 151 | for item in items: 152 | logging.info("================================") 153 | logging.info(f"Starting {item.title}") 154 | 155 | get_IDs(the_lib.type, item) 156 | 157 | bar() 158 | 159 | progress_str = "COMPLETE" 160 | logging.info(progress_str) 161 | 162 | bar.text = progress_str 163 | 164 | print(os.linesep) 165 | 166 | except Exception as ex: 167 | progress_str = f"Problem processing {lib}; {ex}" 168 | logging.info(progress_str) 169 | 170 | print(progress_str) 171 | 172 | logging.info("================================") 173 | logging.info(f"NEW: {len(NEW)}; UPDATED: {len(UPDATED)}") 174 | print(f"NEW: {len(NEW)}; UPDATED: {len(UPDATED)}") 175 | 176 | with open(change_file, "a", encoding="utf-8") as cf: 177 | cf.write(f"end: {get_count()} records{os.linesep}") 178 | -------------------------------------------------------------------------------- /Plex/grab-all-info.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | import sqlalchemy as db 8 | from alive_progress import alive_bar 9 | from helpers import get_all_from_library, get_ids, get_plex, load_and_upgrade_env 10 | from sqlalchemy.dialects.sqlite import insert 11 | 12 | # current dateTime 13 | now = datetime.now() 14 | 15 | # convert to string 16 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 17 | 18 | SCRIPT_NAME = Path(__file__).stem 19 | 20 | VERSION = "0.1.0" 21 | 22 | 23 | env_file_path = Path(".env") 24 | 25 | logging.basicConfig( 26 | filename=f"{SCRIPT_NAME}.log", 27 | filemode="w", 28 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 29 | level=logging.INFO, 30 | ) 31 | 32 | logging.info(f"Starting {SCRIPT_NAME}") 33 | print(f"Starting {SCRIPT_NAME}") 34 | 35 | if load_and_upgrade_env(env_file_path) < 0: 36 | exit() 37 | 38 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 39 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 40 | TMDB_KEY = os.getenv("TMDB_KEY") 41 | NEW = [] 42 | UPDATED = [] 43 | 44 | if LIBRARY_NAMES: 45 | LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] 46 | else: 47 | LIB_ARRAY = [LIBRARY_NAME] 48 | 49 | CHANGE_FILE_NAME = "changes.txt" 50 | change_file = Path(CHANGE_FILE_NAME) 51 | # Delete any existing change file 52 | if change_file.is_file(): 53 | change_file.unlink() 54 | 55 | 56 | def get_connection(): 57 | engine = db.create_engine("sqlite:///ids.sqlite") 58 | metadata = db.MetaData() 59 | 60 | connection = engine.connect() 61 | 62 | try: 63 | ids = db.Table("keys", metadata, autoload=True, autoload_with=engine) 64 | ids = ids 65 | except db.exc.NoSuchTableError: 66 | defaultitem = db.Table( 67 | "keys", 68 | metadata, 69 | db.Column("guid", db.String(25), primary_key=True), 70 | db.Column("imdb", db.String(25), nullable=True), 71 | db.Column("tmdb", db.String(25), nullable=True), 72 | db.Column("tvdb", db.String(25), nullable=True), 73 | db.Column("title", db.String(255), nullable=False), 74 | db.Column("year", db.Integer), 75 | db.Column("source", db.Integer), 76 | db.Column("type", db.String(25), nullable=False), 77 | db.Column("complete", db.Boolean), 78 | ) 79 | defaultitem = defaultitem 80 | metadata.create_all(engine) 81 | 82 | return engine, metadata, connection 83 | 84 | 85 | def get_completed(): 86 | engine, metadata, connection = get_connection() 87 | keys = db.Table("keys", metadata, autoload=True, autoload_with=engine) 88 | 89 | query = db.select(keys).where(keys.columns.complete) 90 | ResultProxy = connection.execute(query) 91 | ResultSet = ResultProxy.fetchall() 92 | 93 | connection.close() 94 | 95 | return ResultSet 96 | 97 | 98 | def get_count(): 99 | engine, metadata, connection = get_connection() 100 | keys = db.Table("keys", metadata, autoload=True, autoload_with=engine) 101 | 102 | query = db.select(keys) 103 | ResultProxy = connection.execute(query) 104 | ResultSet = ResultProxy.fetchall() 105 | count = len(ResultSet) 106 | 107 | connection.close() 108 | 109 | return count 110 | 111 | 112 | def insert_record(payload): 113 | engine, metadata, connection = get_connection() 114 | keys = db.Table("keys", metadata, autoload=True, autoload_with=engine) 115 | stmt = insert(keys).values( 116 | guid=payload["guid"], 117 | imdb=payload["imdb"], 118 | tmdb=payload["tmdb"], 119 | tvdb=payload["tvdb"], 120 | title=payload["title"], 121 | year=payload["year"], 122 | type=payload["type"], 123 | complete=payload["complete"], 124 | ) 125 | do_update_stmt = stmt.on_conflict_do_update( 126 | index_elements=["guid"], 127 | set_=dict( 128 | imdb=payload["imdb"], 129 | tmdb=payload["tmdb"], 130 | tvdb=payload["tvdb"], 131 | title=payload["title"], 132 | year=payload["year"], 133 | type=payload["type"], 134 | complete=payload["complete"], 135 | ), 136 | ) 137 | 138 | connection.execute(do_update_stmt) 139 | 140 | connection.close() 141 | 142 | 143 | def get_diffs(payload): 144 | engine, metadata, connection = get_connection() 145 | keys = db.Table("keys", metadata, autoload=True, autoload_with=engine) 146 | 147 | query = db.select(keys).where(keys.columns.guid == payload["guid"]) 148 | ResultProxy = connection.execute(query) 149 | ResultSet = ResultProxy.fetchall() 150 | diffs = {"new": False, "updated": False, "changes": {}} 151 | if len(ResultSet) > 0: 152 | if ResultSet[0]["imdb"] != payload["imdb"]: 153 | diffs["changes"]["imdb"] = payload["imdb"] 154 | if ResultSet[0]["tmdb"] != payload["tmdb"]: 155 | diffs["changes"]["tmdb"] = payload["tmdb"] 156 | if ResultSet[0]["tmdb"] != payload["tmdb"]: 157 | diffs["changes"]["tmdb"] = payload["tmdb"] 158 | if ResultSet[0]["year"] != payload["year"]: 159 | diffs["changes"]["year"] = payload["year"] 160 | diffs["updated"] = len(diffs["changes"]) > 0 161 | else: 162 | diffs["new"] = True 163 | diffs["changes"]["imdb"] = payload["imdb"] 164 | diffs["changes"]["tmdb"] = payload["tmdb"] 165 | diffs["changes"]["tmdb"] = payload["tmdb"] 166 | diffs["changes"]["year"] = payload["year"] 167 | 168 | return diffs 169 | 170 | 171 | plex = get_plex() 172 | 173 | logging.info("connection success") 174 | 175 | 176 | def get_IDs(type, item): 177 | imdbid = None 178 | tmid = None 179 | tvid = None 180 | raw_guid = item.guid 181 | bits = raw_guid.split("/") 182 | # plex://movie/5d776b59ad5437001f79c6f8 183 | # local://3961921 184 | if bits[0] == "plex:": 185 | try: 186 | guid = bits[3] 187 | 188 | if guid not in COMPLETE_ARRAY: 189 | try: 190 | if item.type != "collection": 191 | logging.info("Getting IDs") 192 | imdbid, tmid, tvid = get_ids(item.guids, TMDB_KEY) 193 | complete = ( 194 | imdbid is not None and tmid is not None and tvid is not None 195 | ) 196 | payload = { 197 | "guid": guid, 198 | "imdb": imdbid, 199 | "tmdb": tmid, 200 | "tvdb": tvid, 201 | "title": item.title, 202 | "year": item.year, 203 | "type": type, 204 | "complete": complete, 205 | } 206 | 207 | diffs = get_diffs(payload) 208 | 209 | if diffs["new"] or diffs["updated"]: 210 | # record change 211 | if diffs["new"]: 212 | action = "new" 213 | NEW.append(guid) 214 | else: 215 | action = "updated" 216 | UPDATED.append(guid) 217 | 218 | with open(change_file, "a", encoding="utf-8") as cf: 219 | cf.write(f"{action} - {payload} {os.linesep}") 220 | 221 | insert_record(payload) 222 | except Exception as ex: 223 | print(f"{item.ratingKey}- {item.title} - Exception: {ex}") 224 | logging.info( 225 | f"EXCEPTION: {item.ratingKey}- {item.title} - Exception: {ex}" 226 | ) 227 | else: 228 | logging.info(f"{guid} already complete") 229 | except: 230 | logging.info(f"No guid: {bits}") 231 | 232 | 233 | COMPLETE_ARRAY = [] 234 | 235 | if LIBRARY_NAMES == "ALL_LIBRARIES": 236 | LIB_ARRAY = [] 237 | all_libs = plex.library.sections() 238 | for lib in all_libs: 239 | if lib.type == "movie" or lib.type == "show": 240 | LIB_ARRAY.append(lib.title.strip()) 241 | 242 | with open(change_file, "a", encoding="utf-8") as cf: 243 | cf.write(f"start: {get_count()} records{os.linesep}") 244 | 245 | for lib in LIB_ARRAY: 246 | completed_things = get_completed() 247 | 248 | for thing in completed_things: 249 | COMPLETE_ARRAY.append(thing["guid"]) 250 | 251 | try: 252 | the_lib = plex.library.section(lib) 253 | 254 | count = plex.library.section(lib).totalSize 255 | print(f"getting {count} {the_lib.type}s from [{lib}]...") 256 | logging.info(f"getting {count} {the_lib.type}s from [{lib}]...") 257 | items = get_all_from_library(the_lib) 258 | # items = the_lib.all() 259 | item_total = len(items) 260 | logging.info(f"looping over {item_total} items...") 261 | item_count = 1 262 | 263 | plex_links = [] 264 | external_links = [] 265 | 266 | with alive_bar(item_total, dual_line=True, title="Grab all IDs") as bar: 267 | for item in items: 268 | logging.info("================================") 269 | logging.info(f"Starting {item.title}") 270 | 271 | get_IDs(the_lib.type, item) 272 | 273 | bar() 274 | 275 | progress_str = "COMPLETE" 276 | logging.info(progress_str) 277 | 278 | bar.text = progress_str 279 | 280 | print(os.linesep) 281 | 282 | except Exception as ex: 283 | progress_str = f"Problem processing {lib}; {ex}" 284 | logging.info(progress_str) 285 | 286 | print(progress_str) 287 | 288 | logging.info("================================") 289 | logging.info(f"NEW: {len(NEW)}; UPDATED: {len(UPDATED)}") 290 | print(f"NEW: {len(NEW)}; UPDATED: {len(UPDATED)}") 291 | 292 | with open(change_file, "a", encoding="utf-8") as cf: 293 | cf.write(f"end: {get_count()} records{os.linesep}") 294 | -------------------------------------------------------------------------------- /Plex/grab-all-status.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | import os 4 | import sys 5 | import textwrap 6 | from datetime import datetime 7 | from pathlib import Path 8 | 9 | from alive_progress import alive_bar 10 | from helpers import get_plex, get_xml_libraries, get_xml_watched, load_and_upgrade_env 11 | from logs import plogger, setup_logger 12 | 13 | # current dateTime 14 | now = datetime.now() 15 | 16 | # convert to string 17 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 18 | 19 | SCRIPT_NAME = Path(__file__).stem 20 | 21 | VERSION = "0.1.1" 22 | 23 | # DONE 0.1.1: guard against empty library map 24 | 25 | env_file_path = Path(".env") 26 | 27 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 28 | setup_logger("activity_log", ACTIVITY_LOG) 29 | 30 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 31 | 32 | if load_and_upgrade_env(env_file_path) < 0: 33 | exit() 34 | 35 | target_url_var = "PLEX_URL" 36 | PLEX_URL = os.getenv(target_url_var) 37 | if PLEX_URL is None: 38 | target_url_var = "PLEXAPI_AUTH_SERVER_BASEURL" 39 | PLEX_URL = os.getenv(target_url_var) 40 | 41 | target_token_var = "PLEX_TOKEN" 42 | PLEX_TOKEN = os.getenv(target_token_var) 43 | if PLEX_TOKEN is None: 44 | target_token_var = "PLEXAPI_AUTH_SERVER_TOKEN" 45 | PLEX_TOKEN = os.getenv(target_token_var) 46 | 47 | if PLEX_URL is None or PLEX_URL == "https://plex.domain.tld": 48 | plogger(f"You must specify {target_url_var} in the .env file.", "info", "a") 49 | exit() 50 | 51 | if PLEX_TOKEN is None or PLEX_TOKEN == "PLEX-TOKEN": 52 | plogger(f"You must specify {target_token_var} in the .env file.", "info", "a") 53 | exit() 54 | 55 | PLEX_OWNER = os.getenv("PLEX_OWNER") 56 | 57 | LIBRARY_MAP = os.getenv("LIBRARY_MAP", "{}") 58 | 59 | try: 60 | lib_map = json.loads(LIBRARY_MAP) 61 | except: 62 | plogger( 63 | "LIBRARY_MAP in the .env file appears to be broken. Defaulting to an empty list.", 64 | "info", 65 | "a", 66 | ) 67 | lib_map = json.loads("{}") 68 | 69 | 70 | def progress(count, total, status=""): 71 | bar_len = 40 72 | filled_len = int(round(bar_len * count / float(total))) 73 | 74 | percents = round(100.0 * count / float(total), 1) 75 | bar = "=" * filled_len + "-" * (bar_len - filled_len) 76 | stat_str = textwrap.shorten(status, width=80) 77 | 78 | sys.stdout.write("[%s] %s%s ... %s\r" % (bar, percents, "%", stat_str.ljust(80))) 79 | sys.stdout.flush() 80 | 81 | 82 | def get_user_acct(acct_list, username): 83 | for acct in acct_list: 84 | if acct.username == username: 85 | return acct 86 | 87 | 88 | def get_data_line(username, type, section, video): 89 | file_line = "" 90 | contentRating = ( 91 | video["contentRating"] if "contentRating" in video.keys() else "NONE" 92 | ) 93 | episodeNum = video["index"] if "index" in video.keys() else video["duration"] 94 | if type == "show": 95 | file_line = f"{username}\t{type}\t{section}\t{video['grandparentTitle']}\ts{video['parentIndex']:02}e{episodeNum:02}\t{video['title']}" 96 | elif type == "movie": 97 | file_line = f"{username}\t{type}\t{section}\t{video['title']}\t{video['year']}\t{contentRating}" 98 | return file_line 99 | 100 | 101 | def filter_for_unwatched(list): 102 | watched = [x for x in list if x.isPlayed] 103 | return watched 104 | 105 | 106 | def process_section(username, section): 107 | items = [] 108 | file_string = "" 109 | 110 | print(f"------------ {section['title']} ------------") 111 | items = get_xml_watched(PLEX_URL, PLEX_TOKEN, section["key"], section["type"]) 112 | if len(items) > 0: 113 | with alive_bar(len(items), dual_line=True, title="Saving status") as bar: 114 | for video in items: 115 | status_text = get_data_line( 116 | username, section["type"], section["title"], video 117 | ) 118 | file_string = f"{file_string}{status_text}{os.linesep}" 119 | bar() 120 | return file_string 121 | 122 | 123 | padwidth = 95 124 | count = 0 125 | connected_plex_user = PLEX_OWNER 126 | connected_plex_library = "" 127 | 128 | plex = get_plex() 129 | PMI = plex.machineIdentifier 130 | 131 | account = plex.myPlexAccount() 132 | all_users = account.users() 133 | item = None 134 | file_string = "" 135 | DO_NOTHING = False 136 | 137 | print(f"------------ {account.username} ------------") 138 | try: 139 | # plex_sections = plex.library.sections() 140 | print("------------ getting libraries -------------") 141 | plex_sections = get_xml_libraries(PLEX_URL, PLEX_TOKEN) 142 | 143 | if plex_sections is not None: 144 | for plex_section in plex_sections["MediaContainer"]["Directory"]: 145 | if not DO_NOTHING: 146 | if plex_section["type"] != "artist": 147 | print( 148 | f"- processing {plex_section['type']} library: {plex_section['title']}" 149 | ) 150 | status_text = process_section(account.username, plex_section) 151 | file_string = f"{file_string}{status_text}{os.linesep}" 152 | else: 153 | file_line = f"Skipping {plex_section['title']}" 154 | print(file_line) 155 | file_string = file_string + f"{file_line}{os.linesep}" 156 | else: 157 | print(f"Could not retrieve libraries for {account.username}") 158 | 159 | except Exception as ex: 160 | file_line = f"Exception processing {account.username} - {ex}" 161 | print(file_line) 162 | file_string = file_string + f"{file_line}{os.linesep}" 163 | 164 | user_ct = len(all_users) 165 | user_idx = 0 166 | for plex_user in all_users: 167 | user_acct = account.user(plex_user.title) 168 | user_idx += 1 169 | print(f"------------ {plex_user.title} {user_idx}/{user_ct} ------------") 170 | try: 171 | PLEX_TOKEN = user_acct.get_token(plex.machineIdentifier) 172 | print("------------ getting libraries -------------") 173 | plex_sections = get_xml_libraries(PLEX_URL, PLEX_TOKEN) 174 | if plex_sections is not None: 175 | for plex_section in plex_sections["MediaContainer"]["Directory"]: 176 | if not DO_NOTHING: 177 | if plex_section["type"] != "artist": 178 | status_text = process_section(plex_user.title, plex_section) 179 | file_string = f"{file_string}{status_text}{os.linesep}" 180 | else: 181 | file_line = f"Skipping {plex_section['title']}" 182 | file_string = file_string + f"{file_line}{os.linesep}" 183 | print(file_line) 184 | else: 185 | print(f"Could not retrieve libraries for {plex_user.title}") 186 | 187 | except Exception as ex: 188 | file_line = f"Exception processing {plex_user.title} - {ex}" 189 | file_string = file_string + f"{file_line}{os.linesep}" 190 | print(file_line) 191 | 192 | print(f"{os.linesep}") 193 | if len(file_string) > 0: 194 | with open("status.txt", "w", encoding="utf-8") as myfile: 195 | myfile.write(f"{file_string}{os.linesep}") 196 | -------------------------------------------------------------------------------- /Plex/grab-imdb-posters.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | import sys 5 | import textwrap 6 | from datetime import datetime 7 | from pathlib import Path 8 | 9 | import imdb 10 | from helpers import booler, get_ids, get_plex, load_and_upgrade_env 11 | 12 | # current dateTime 13 | now = datetime.now() 14 | 15 | # convert to string 16 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 17 | 18 | SCRIPT_NAME = Path(__file__).stem 19 | 20 | VERSION = "0.1.0" 21 | 22 | env_file_path = Path(".env") 23 | 24 | logging.basicConfig( 25 | filename=f"{SCRIPT_NAME}.log", 26 | filemode="w", 27 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 28 | level=logging.INFO, 29 | ) 30 | 31 | logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 32 | print(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 33 | 34 | if load_and_upgrade_env(env_file_path) < 0: 35 | exit() 36 | 37 | TMDB_KEY = os.getenv("TMDB_KEY") 38 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 39 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 40 | POSTER_DIR = os.getenv("POSTER_DIR") 41 | POSTER_DEPTH = int(os.getenv("POSTER_DEPTH")) 42 | POSTER_DOWNLOAD = booler(os.getenv("POSTER_DOWNLOAD")) 43 | POSTER_CONSOLIDATE = booler(os.getenv("POSTER_CONSOLIDATE")) 44 | 45 | if POSTER_DEPTH is None: 46 | POSTER_DEPTH = 0 47 | 48 | if POSTER_DOWNLOAD: 49 | script_string = f'#!/bin/bash{os.linesep}{os.linesep}# SCRIPT TO DO STUFF{os.linesep}{os.linesep}cd "{POSTER_DIR}"{os.linesep}{os.linesep}' 50 | else: 51 | script_string = "" 52 | 53 | if LIBRARY_NAMES: 54 | lib_array = LIBRARY_NAMES.split(",") 55 | else: 56 | lib_array = [LIBRARY_NAME] 57 | 58 | imdb_str = "imdb://" 59 | tmdb_str = "tmdb://" 60 | tvdb_str = "tvdb://" 61 | 62 | 63 | def progress(count, total, status=""): 64 | bar_len = 40 65 | filled_len = int(round(bar_len * count / float(total))) 66 | 67 | percents = round(100.0 * count / float(total), 1) 68 | bar = "=" * filled_len + "-" * (bar_len - filled_len) 69 | stat_str = textwrap.shorten(status, width=80) 70 | 71 | sys.stdout.write("[%s] %s%s ... %s\r" % (bar, percents, "%", stat_str.ljust(80))) 72 | sys.stdout.flush() 73 | 74 | 75 | all_items = [] 76 | 77 | plex = get_plex() 78 | 79 | logging.info("connection success") 80 | 81 | for lib in lib_array: 82 | print(f"getting items from [{lib}]...") 83 | items = plex.library.section(lib).all() 84 | item_total = len(items) 85 | print(f"looping over {item_total} items...") 86 | item_count = 0 87 | 88 | plex_links = [] 89 | external_links = [] 90 | 91 | for item in items: 92 | item_count = item_count + 1 93 | imdb_id, tmdb_id, tvdb_id = get_ids(item.guids, TMDB_KEY) 94 | 95 | tmpDict = {} 96 | tmpDict["title"] = item.title 97 | tmpDict["ratingKey"] = item.ratingKey 98 | tmpDict["imdb"] = imdb_id 99 | tmpDict["tmdb"] = tmdb_id 100 | tmpDict["tvdb"] = tvdb_id 101 | all_items.append(tmpDict) 102 | 103 | progress_str = f"{item.title}" 104 | progress(item_count, item_total, progress_str) 105 | 106 | print("{os.linesep}") 107 | 108 | print("processing items...") 109 | item_total = len(all_items) 110 | print(f"looping over {item_total} items...") 111 | item_count = 0 112 | 113 | # creating instance of IMDb 114 | ia = imdb.IMDb() 115 | 116 | for item in all_items: 117 | item_count = item_count + 1 118 | 119 | progress_str = f"{item['title']}" 120 | progress(item_count, item_total, progress_str) 121 | 122 | # id 123 | code = "6468322" 124 | imdid = item["imdb"].replace("tt", "") 125 | 126 | # getting information 127 | series = ia.get_movie(imdid) 128 | 129 | # getting cover url of the series 130 | cover = series.data["cover url"] 131 | -------------------------------------------------------------------------------- /Plex/ids.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chazlarson/Media-Scripts/aad427055e7ec8031936ffc29a1ad19e0267fd1e/Plex/ids.sqlite -------------------------------------------------------------------------------- /Plex/import-IDs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import ast 3 | import logging 4 | import os 5 | from datetime import datetime 6 | from pathlib import Path 7 | 8 | import sqlalchemy as db 9 | from alive_progress import alive_bar 10 | from dotenv import load_dotenv 11 | from sqlalchemy.dialects.sqlite import insert 12 | 13 | # current dateTime 14 | now = datetime.now() 15 | 16 | # convert to string 17 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 18 | 19 | SCRIPT_NAME = Path(__file__).stem 20 | 21 | VERSION = "0.1.0" 22 | 23 | 24 | env_file_path = Path(".env") 25 | 26 | logging.basicConfig( 27 | filename=f"{SCRIPT_NAME}.log", 28 | filemode="w", 29 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 30 | level=logging.INFO, 31 | ) 32 | 33 | logging.info(f"Starting {SCRIPT_NAME}") 34 | print(f"Starting {SCRIPT_NAME}") 35 | 36 | CHANGE_FILE_NAME = "changes.txt" 37 | change_file = Path(CHANGE_FILE_NAME) 38 | 39 | 40 | def get_connection(): 41 | engine = db.create_engine("sqlite:///ids.sqlite") 42 | metadata = db.MetaData() 43 | 44 | connection = engine.connect() 45 | 46 | try: 47 | ids = db.Table("keys", metadata, autoload=True, autoload_with=engine) 48 | ids = ids 49 | except db.exc.NoSuchTableError: 50 | defaultitem = db.Table( 51 | "keys", 52 | metadata, 53 | db.Column("guid", db.String(25), primary_key=True), 54 | db.Column("imdb", db.String(25), nullable=True), 55 | db.Column("tmdb", db.String(25), nullable=True), 56 | db.Column("tvdb", db.String(25), nullable=True), 57 | db.Column("title", db.String(255), nullable=False), 58 | db.Column("year", db.Integer), 59 | db.Column("source", db.Integer), 60 | db.Column("type", db.String(25), nullable=False), 61 | db.Column("complete", db.Boolean), 62 | ) 63 | defaultitem = defaultitem 64 | metadata.create_all(engine) 65 | 66 | return engine, metadata, connection 67 | 68 | 69 | def get_completed(): 70 | engine, metadata, connection = get_connection() 71 | keys = db.Table("keys", metadata, autoload=True, autoload_with=engine) 72 | 73 | query = db.select(keys).where(keys.columns.complete) 74 | ResultProxy = connection.execute(query) 75 | ResultSet = ResultProxy.fetchall() 76 | 77 | connection.close() 78 | 79 | return ResultSet 80 | 81 | 82 | def get_current(the_guid): 83 | engine, metadata, connection = get_connection() 84 | keys = db.Table("keys", metadata, autoload=True, autoload_with=engine) 85 | 86 | query = db.select(keys).where(keys.columns.guid == the_guid) 87 | ResultProxy = connection.execute(query) 88 | ResultSet = ResultProxy.fetchall() 89 | 90 | connection.close() 91 | 92 | return ResultSet 93 | 94 | 95 | def get_count(): 96 | engine, metadata, connection = get_connection() 97 | keys = db.Table("keys", metadata, autoload=True, autoload_with=engine) 98 | 99 | query = db.select(keys) 100 | ResultProxy = connection.execute(query) 101 | ResultSet = ResultProxy.fetchall() 102 | count = len(ResultSet) 103 | 104 | connection.close() 105 | 106 | return count 107 | 108 | 109 | def insert_record(payload): 110 | engine, metadata, connection = get_connection() 111 | keys = db.Table("keys", metadata, autoload=True, autoload_with=engine) 112 | stmt = insert(keys).values( 113 | guid=payload["guid"], 114 | imdb=payload["imdb"], 115 | tmdb=payload["tmdb"], 116 | tvdb=payload["tvdb"], 117 | title=payload["title"], 118 | year=payload["year"], 119 | type=payload["type"], 120 | complete=payload["complete"], 121 | ) 122 | do_update_stmt = stmt.on_conflict_do_update( 123 | index_elements=["guid"], 124 | set_=dict( 125 | imdb=payload["imdb"], 126 | tmdb=payload["tmdb"], 127 | tvdb=payload["tvdb"], 128 | title=payload["title"], 129 | year=payload["year"], 130 | type=payload["type"], 131 | complete=payload["complete"], 132 | ), 133 | ) 134 | 135 | connection.execute(do_update_stmt) 136 | 137 | # for Sql 138 | # print(do_update_stmt.compile(compile_kwargs={"literal_binds": True})) 139 | # INSERT INTO keys (guid, imdb, tmdb, tvdb, title, type, complete) VALUES ('5d77709531d95e001f1a5216', NULL, '557680', NULL, '"Eiyuu" Kaitai', 'movie', 0) ON CONFLICT (guid) DO UPDATE SET imdb = ?, tmdb = ?, tvdb = ?, title = ?, type = ?, complete = ? 140 | # need to update that second set of '?' 141 | 142 | connection.close() 143 | 144 | 145 | def get_diffs(payload): 146 | engine, metadata, connection = get_connection() 147 | keys = db.Table("keys", metadata, autoload=True, autoload_with=engine) 148 | 149 | query = db.select(keys).where(keys.columns.guid == payload["guid"]) 150 | ResultProxy = connection.execute(query) 151 | ResultSet = ResultProxy.fetchall() 152 | diffs = {"new": False, "updated": False, "changes": {}} 153 | if len(ResultSet) > 0: 154 | if ResultSet[0]["imdb"] != payload["imdb"]: 155 | diffs["changes"]["imdb"] = payload["imdb"] 156 | if ResultSet[0]["tmdb"] != payload["tmdb"]: 157 | diffs["changes"]["tmdb"] = payload["tmdb"] 158 | if ResultSet[0]["tmdb"] != payload["tmdb"]: 159 | diffs["changes"]["tmdb"] = payload["tmdb"] 160 | diffs["updated"] = len(diffs["changes"]) > 0 161 | else: 162 | diffs["new"] = True 163 | diffs["changes"]["imdb"] = payload["imdb"] 164 | diffs["changes"]["tmdb"] = payload["tmdb"] 165 | diffs["changes"]["tmdb"] = payload["tmdb"] 166 | diffs["changes"]["year"] = payload["year"] 167 | 168 | return diffs 169 | 170 | 171 | logging.basicConfig( 172 | filename="grab-all-IDs.log", 173 | filemode="w", 174 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 175 | level=logging.INFO, 176 | ) 177 | 178 | if os.path.exists(".env"): 179 | load_dotenv() 180 | else: 181 | logging.info("No environment [.env] file. Exiting.") 182 | print("No environment [.env] file. Exiting.") 183 | exit() 184 | 185 | change_records = None 186 | 187 | COMPLETE_ARRAY = [] 188 | 189 | completed_things = get_completed() 190 | 191 | for thing in completed_things: 192 | COMPLETE_ARRAY.append(thing["guid"]) 193 | 194 | with open(change_file, "r", encoding="utf-8") as cf: 195 | data = cf.read() 196 | items = data.split("\n") 197 | 198 | item_total = len(items) 199 | 200 | with alive_bar(item_total, dual_line=True, title="Import changes") as bar: 201 | for item in items: 202 | parts = item.strip().split(" - ") 203 | 204 | if len(parts) == 2: 205 | action = parts[0] 206 | values = ast.literal_eval(parts[1]) 207 | logging.info("================================") 208 | logging.info(f"Importing {action} {values['guid']}") 209 | bar.text = f"Importing {action} {values['guid']}" 210 | payload = {} 211 | 212 | for key in values.keys(): 213 | payload[key] = values[key] 214 | 215 | is_complete = ( 216 | payload["imdb"] is not None 217 | and payload["tmdb"] is not None 218 | and payload["tvdb"] is not None 219 | and payload["year"] is not None 220 | ) 221 | 222 | payload["complete"] = is_complete 223 | 224 | logging.info(f"{payload}") 225 | 226 | insert_record(payload) 227 | 228 | bar() 229 | -------------------------------------------------------------------------------- /Plex/list-collections.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | import time 5 | from datetime import datetime 6 | from pathlib import Path 7 | 8 | from alive_progress import alive_bar 9 | from helpers import get_plex, load_and_upgrade_env 10 | 11 | SCRIPT_NAME = Path(__file__).stem 12 | 13 | VERSION = "0.1.0" 14 | 15 | # current dateTime 16 | now = datetime.now() 17 | 18 | # convert to string 19 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 20 | 21 | env_file_path = Path(".env") 22 | 23 | logging.basicConfig( 24 | filename=f"{SCRIPT_NAME}.log", 25 | filemode="w", 26 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 27 | level=logging.INFO, 28 | ) 29 | 30 | logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") 31 | print(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") 32 | 33 | if load_and_upgrade_env(env_file_path) < 0: 34 | exit() 35 | 36 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 37 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 38 | DELAY = int(os.getenv("DELAY")) 39 | 40 | if not DELAY: 41 | DELAY = 0 42 | 43 | if LIBRARY_NAMES: 44 | lib_array = LIBRARY_NAMES.split(",") 45 | else: 46 | lib_array = [LIBRARY_NAME] 47 | 48 | plex = get_plex() 49 | 50 | coll_obj = {} 51 | coll_obj["collections"] = {} 52 | 53 | 54 | def get_sort_text(argument): 55 | switcher = {0: "release", 1: "alpha", 2: "custom"} 56 | return switcher.get(argument, "invalid-sort") 57 | 58 | 59 | for lib in lib_array: 60 | print(f"{lib} collection(s):") 61 | movies = plex.library.section(lib) 62 | items = movies.collections() 63 | item_total = len(items) 64 | print(f"{item_total} collection(s) retrieved...") 65 | item_count = 1 66 | with alive_bar(item_total, dual_line=True, title="Collection list - Plex") as bar: 67 | for item in items: 68 | title = item.title 69 | print(f"{title}") 70 | 71 | bar() 72 | 73 | # Wait between items in case hammering the Plex server turns out badly. 74 | time.sleep(DELAY) 75 | -------------------------------------------------------------------------------- /Plex/list-item-ids.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import platform 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | from alive_progress import alive_bar 8 | from helpers import ( 9 | booler, 10 | get_all_from_library, 11 | get_ids, 12 | get_plex, 13 | load_and_upgrade_env, 14 | ) 15 | from logs import logger, plogger, setup_logger 16 | 17 | SCRIPT_NAME = Path(__file__).stem 18 | 19 | VERSION = "0.1.0" 20 | 21 | env_file_path = Path(".env") 22 | 23 | # current dateTime 24 | now = datetime.now() 25 | 26 | IS_WINDOWS = platform.system() == "Windows" 27 | 28 | # convert to string 29 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 30 | 31 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 32 | DOWNLOAD_LOG = f"{SCRIPT_NAME}-dl.log" 33 | SUPERCHAT = False 34 | 35 | 36 | def superchat(msg, level, logfile): 37 | if SUPERCHAT: 38 | logger(msg, level, logfile) 39 | 40 | 41 | setup_logger("activity_log", ACTIVITY_LOG) 42 | setup_logger("download_log", DOWNLOAD_LOG) 43 | 44 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 45 | 46 | if load_and_upgrade_env(env_file_path) < 0: 47 | exit() 48 | 49 | ID_FILES = True 50 | 51 | URL_ARRAY = [] 52 | # no one using this yet 53 | # QUEUED_DOWNLOADS = {} 54 | 55 | target_url_var = "PLEX_URL" 56 | PLEX_URL = os.getenv(target_url_var) 57 | if PLEX_URL is None: 58 | target_url_var = "PLEXAPI_AUTH_SERVER_BASEURL" 59 | PLEX_URL = os.getenv(target_url_var) 60 | 61 | target_token_var = "PLEX_TOKEN" 62 | PLEX_TOKEN = os.getenv(target_token_var) 63 | if PLEX_TOKEN is None: 64 | target_token_var = "PLEXAPI_AUTH_SERVER_TOKEN" 65 | PLEX_TOKEN = os.getenv(target_token_var) 66 | 67 | if PLEX_URL is None or PLEX_URL == "https://plex.domain.tld": 68 | plogger(f"You must specify {target_url_var} in the .env file.", "info", "a") 69 | exit() 70 | 71 | if PLEX_TOKEN is None or PLEX_TOKEN == "PLEX-TOKEN": 72 | plogger(f"You must specify {target_token_var} in the .env file.", "info", "a") 73 | exit() 74 | 75 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 76 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 77 | POSTER_DIR = os.getenv("POSTER_DIR") 78 | 79 | SUPERCHAT = os.getenv("SUPERCHAT") 80 | 81 | INCLUDE_COLLECTION_MEMBERS = booler(os.getenv("INCLUDE_COLLECTION_MEMBERS")) 82 | ONLY_COLLECTION_MEMBERS = booler(os.getenv("ONLY_COLLECTION_MEMBERS")) 83 | DELAY = int(os.getenv("DELAY")) 84 | 85 | if not DELAY: 86 | DELAY = 0 87 | 88 | if LIBRARY_NAMES: 89 | LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] 90 | else: 91 | LIB_ARRAY = [LIBRARY_NAME] 92 | 93 | ONLY_THESE_COLLECTIONS = os.getenv("ONLY_THESE_COLLECTIONS") 94 | 95 | if ONLY_THESE_COLLECTIONS: 96 | COLLECTION_ARRAY = [s.strip() for s in ONLY_THESE_COLLECTIONS.split("|")] 97 | else: 98 | COLLECTION_ARRAY = [] 99 | 100 | imdb_str = "imdb://" 101 | tmdb_str = "tmdb://" 102 | tvdb_str = "tvdb://" 103 | 104 | redaction_list = [] 105 | redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_BASEURL")) 106 | redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_TOKEN")) 107 | 108 | plex = get_plex() 109 | 110 | logger("Plex connection succeeded", "info", "a") 111 | 112 | 113 | def lib_type_supported(lib): 114 | return lib.type == "movie" or lib.type == "show" 115 | 116 | 117 | ALL_LIBS = plex.library.sections() 118 | ALL_LIB_NAMES = [] 119 | 120 | logger(f"{len(ALL_LIBS)} libraries found:", "info", "a") 121 | for lib in ALL_LIBS: 122 | logger( 123 | f"{lib.title.strip()}: {lib.type} - supported: {lib_type_supported(lib)}", 124 | "info", 125 | "a", 126 | ) 127 | ALL_LIB_NAMES.append(f"{lib.title.strip()}") 128 | 129 | if LIBRARY_NAMES == "ALL_LIBRARIES": 130 | LIB_ARRAY = [] 131 | for lib in ALL_LIBS: 132 | if lib_type_supported(lib): 133 | LIB_ARRAY.append(lib.title.strip()) 134 | 135 | TOPLEVEL_TMID = "" 136 | TOPLEVEL_TVID = "" 137 | 138 | 139 | def get_lib_setting(the_lib, the_setting): 140 | settings = the_lib.settings() 141 | for setting in settings: 142 | if setting.id == the_setting: 143 | return setting.value 144 | 145 | 146 | for lib in LIB_ARRAY: 147 | if lib in ALL_LIB_NAMES: 148 | try: 149 | highwater = 0 150 | 151 | plogger(f"Loading {lib} ...", "info", "a") 152 | the_lib = plex.library.section(lib) 153 | the_uuid = the_lib.uuid 154 | superchat(f"{the_lib} uuid {the_uuid}", "info", "a") 155 | ID_ARRAY = [] 156 | the_title = the_lib.title 157 | superchat(f"This library is called {the_title}", "info", "a") 158 | 159 | if INCLUDE_COLLECTION_MEMBERS: 160 | plogger(f"getting collections from [{lib}]...", "info", "a") 161 | 162 | items = the_lib.collections() 163 | item_total = len(items) 164 | plogger(f"{item_total} collection(s) retrieved...", "info", "a") 165 | 166 | tgt_ext = ".dat" 167 | 168 | if item_total > 0: 169 | with alive_bar( 170 | item_total, dual_line=True, title="Grab Collection details" 171 | ) as bar: 172 | for item in items: 173 | plogger( 174 | f"This collection is called {item.title}", "info", "a" 175 | ) 176 | 177 | collection_items = item.items() 178 | coll_item_total = len(collection_items) 179 | coll_idx = 1 180 | for collection_item in collection_items: 181 | imdbid, tmid, tvid = get_ids( 182 | collection_item.guids, None 183 | ) 184 | if the_lib.TYPE == "movie": 185 | plogger( 186 | f"Collection: {item.title} item {coll_idx: >5}/{coll_item_total: >5} | TMDb ID: {tmid: >7} | IMDb ID: {imdbid: >10} | {collection_item.title}", 187 | "info", 188 | "a", 189 | ) 190 | else: 191 | plogger( 192 | f"Collection: {item.title} item {coll_idx: >5}/{coll_item_total: >5} | TVDb ID: {tvid: >6} | IMDb ID: {imdbid: >10} | {collection_item.title}", 193 | "info", 194 | "a", 195 | ) 196 | coll_idx += 1 197 | bar() 198 | 199 | else: 200 | plogger("Skipping collection members ...", "info", "a") 201 | 202 | if not ONLY_COLLECTION_MEMBERS: 203 | if len(COLLECTION_ARRAY) == 0: 204 | COLLECTION_ARRAY = ["placeholder_collection_name"] 205 | 206 | for coll in COLLECTION_ARRAY: 207 | lib_key = f"{the_uuid}-{coll}" 208 | 209 | items = [] 210 | 211 | if coll == "placeholder_collection_name": 212 | plogger(f"Loading {the_lib.TYPE}s ...", "info", "a") 213 | item_total, items = get_all_from_library(the_lib, None, None) 214 | plogger( 215 | f"Completed loading {len(items)} of {the_lib.totalViewSize()} {the_lib.TYPE}(s) from {the_lib.title}", 216 | "info", 217 | "a", 218 | ) 219 | 220 | else: 221 | plogger( 222 | f"Loading everything in collection {coll} ...", "info", "a" 223 | ) 224 | item_total, items = get_all_from_library( 225 | the_lib, None, {"collection": coll} 226 | ) 227 | plogger( 228 | f"Completed loading {len(items)} from collection {coll}", 229 | "info", 230 | "a", 231 | ) 232 | 233 | if item_total > 0: 234 | logger(f"looping over {item_total} items...", "info", "a") 235 | item_count = 0 236 | 237 | plex_links = [] 238 | external_links = [] 239 | 240 | with alive_bar( 241 | item_total, 242 | dual_line=True, 243 | title=f"Grab all posters {the_lib.title}", 244 | ) as bar: 245 | for item in items: 246 | try: 247 | imdbid, tmid, tvid = get_ids(item.guids, None) 248 | imdbid_format = ( 249 | f"{imdbid: >10}" if imdbid else " N/A" 250 | ) 251 | tmid_format = f"{tmid: >7}" if tmid else " N/A" 252 | tvid_format = f"{tvid: >6}" if imdbid else " N/A" 253 | 254 | if the_lib.TYPE == "movie": 255 | plogger( 256 | f"item {item_count: >5}/{item_total: >5} | TMDb ID: {tmid_format} | IMDb ID: {imdbid_format} | {item.title}", 257 | "info", 258 | "a", 259 | ) 260 | else: 261 | plogger( 262 | f"item {item_count: >5}/{item_total: >5} | TVDb ID: {tvid_format} | IMDb ID: {imdbid_format} | {item.title}", 263 | "info", 264 | "a", 265 | ) 266 | 267 | item_count += 1 268 | except Exception as ex: 269 | plogger( 270 | f"Problem processing {item.title}; {ex}", 271 | "info", 272 | "a", 273 | ) 274 | 275 | bar() 276 | 277 | plogger(f"Processed {item_count} of {item_total}", "info", "a") 278 | 279 | progress_str = "COMPLETE" 280 | logger(progress_str, "info", "a") 281 | 282 | except Exception as ex: 283 | progress_str = f"Problem processing {lib}; {ex}" 284 | plogger(progress_str, "info", "a") 285 | else: 286 | logger( 287 | f"Library {lib} not found: available libraries on this server are: {ALL_LIB_NAMES}", 288 | "info", 289 | "a", 290 | ) 291 | 292 | plogger("Complete!", "info", "a") 293 | -------------------------------------------------------------------------------- /Plex/list-libraries.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import time 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | from alive_progress import alive_bar 8 | from helpers import get_plex, load_and_upgrade_env 9 | from tabulate import tabulate 10 | 11 | # current dateTime 12 | now = datetime.now() 13 | 14 | # convert to string 15 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 16 | 17 | env_file_path = Path(".env") 18 | 19 | if load_and_upgrade_env(env_file_path) < 0: 20 | exit() 21 | 22 | DELAY = int(os.getenv("DELAY")) 23 | 24 | if not DELAY: 25 | DELAY = 0 26 | 27 | plex = get_plex() 28 | 29 | coll_obj = {} 30 | coll_obj["libraries"] = {} 31 | 32 | 33 | def get_sort_text(argument): 34 | switcher = {0: "release", 1: "alpha", 2: "custom"} 35 | return switcher.get(argument, "invalid-sort") 36 | 37 | 38 | sections = plex.library.sections() 39 | item_total = len(sections) 40 | table = [["Name", "Type", "Size"]] 41 | 42 | with alive_bar(item_total, dual_line=True, title="Library list - Plex") as bar: 43 | for section in sections: 44 | info = [] 45 | info.append(section.title) 46 | info.append(section.type) 47 | info.append(section.totalSize) 48 | 49 | table.append(info) 50 | 51 | bar() 52 | 53 | # Wait between items in case hammering the Plex server turns out badly. 54 | time.sleep(DELAY) 55 | 56 | print(tabulate(table, headers="firstrow", tablefmt="fancy_grid")) 57 | -------------------------------------------------------------------------------- /Plex/list-low-poster-counts.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import platform 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | from alive_progress import alive_bar 8 | from helpers import get_all_from_library, get_plex, load_and_upgrade_env 9 | from logs import logger, plogger, setup_logger 10 | 11 | SCRIPT_NAME = Path(__file__).stem 12 | 13 | VERSION = "0.1.0" 14 | 15 | env_file_path = Path(".env") 16 | 17 | # current dateTime 18 | now = datetime.now() 19 | 20 | IS_WINDOWS = platform.system() == "Windows" 21 | 22 | # convert to string 23 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 24 | 25 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 26 | DOWNLOAD_LOG = f"{SCRIPT_NAME}-dl.log" 27 | 28 | setup_logger("activity_log", ACTIVITY_LOG) 29 | 30 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 31 | 32 | if load_and_upgrade_env(env_file_path) < 0: 33 | exit() 34 | 35 | ID_FILES = True 36 | 37 | URL_ARRAY = [] 38 | # no one using this yet 39 | # QUEUED_DOWNLOADS = {} 40 | 41 | target_url_var = "PLEX_URL" 42 | PLEX_URL = os.getenv(target_url_var) 43 | if PLEX_URL is None: 44 | target_url_var = "PLEXAPI_AUTH_SERVER_BASEURL" 45 | PLEX_URL = os.getenv(target_url_var) 46 | 47 | target_token_var = "PLEX_TOKEN" 48 | PLEX_TOKEN = os.getenv(target_token_var) 49 | if PLEX_TOKEN is None: 50 | target_token_var = "PLEXAPI_AUTH_SERVER_TOKEN" 51 | PLEX_TOKEN = os.getenv(target_token_var) 52 | 53 | if PLEX_URL is None or PLEX_URL == "https://plex.domain.tld": 54 | plogger(f"You must specify {target_url_var} in the .env file.", "info", "a") 55 | exit() 56 | 57 | if PLEX_TOKEN is None or PLEX_TOKEN == "PLEX-TOKEN": 58 | plogger(f"You must specify {target_token_var} in the .env file.", "info", "a") 59 | exit() 60 | 61 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 62 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 63 | 64 | SUPERCHAT = os.getenv("SUPERCHAT") 65 | 66 | DELAY = int(os.getenv("DELAY")) 67 | 68 | if not DELAY: 69 | DELAY = 0 70 | 71 | POSTER_THRESHOLD = int(os.getenv("POSTER_THRESHOLD")) 72 | 73 | if LIBRARY_NAMES: 74 | LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] 75 | else: 76 | LIB_ARRAY = [LIBRARY_NAME] 77 | 78 | imdb_str = "imdb://" 79 | tmdb_str = "tmdb://" 80 | tvdb_str = "tvdb://" 81 | 82 | redaction_list = [] 83 | redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_BASEURL")) 84 | redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_TOKEN")) 85 | 86 | plex = get_plex() 87 | 88 | logger("Plex connection succeeded", "info", "a") 89 | 90 | 91 | def lib_type_supported(lib): 92 | return lib.type == "movie" or lib.type == "show" 93 | 94 | 95 | ALL_LIBS = plex.library.sections() 96 | ALL_LIB_NAMES = [] 97 | 98 | logger(f"{len(ALL_LIBS)} libraries found:", "info", "a") 99 | for lib in ALL_LIBS: 100 | logger( 101 | f"{lib.title.strip()}: {lib.type} - supported: {lib_type_supported(lib)}", 102 | "info", 103 | "a", 104 | ) 105 | ALL_LIB_NAMES.append(f"{lib.title.strip()}") 106 | 107 | if LIBRARY_NAMES == "ALL_LIBRARIES": 108 | LIB_ARRAY = [] 109 | for lib in ALL_LIBS: 110 | if lib_type_supported(lib): 111 | LIB_ARRAY.append(lib.title.strip()) 112 | 113 | TOPLEVEL_TMID = "" 114 | TOPLEVEL_TVID = "" 115 | 116 | 117 | def get_lib_setting(the_lib, the_setting): 118 | settings = the_lib.settings() 119 | for setting in settings: 120 | if setting.id == the_setting: 121 | return setting.value 122 | 123 | 124 | for lib in LIB_ARRAY: 125 | if lib in ALL_LIB_NAMES: 126 | try: 127 | highwater = 0 128 | 129 | plogger(f"Loading {lib} ...", "info", "a") 130 | the_lib = plex.library.section(lib) 131 | the_uuid = the_lib.uuid 132 | ID_ARRAY = [] 133 | the_title = the_lib.title 134 | 135 | item_total, items = get_all_from_library(the_lib, None, None) 136 | plogger( 137 | f"Completed loading {item_total} of {the_lib.totalViewSize()} {the_lib.TYPE}(s) from {the_lib.title}", 138 | "info", 139 | "a", 140 | ) 141 | 142 | if item_total > 0: 143 | logger(f"looping over {item_total} items...", "info", "a") 144 | item_count = 0 145 | 146 | plex_links = [] 147 | external_links = [] 148 | 149 | with alive_bar( 150 | item_total, 151 | dual_line=True, 152 | title=f"Low poster counts {the_lib.title}", 153 | ) as bar: 154 | for item in items: 155 | try: 156 | all_posters = item.posters() 157 | if len(all_posters) < POSTER_THRESHOLD: 158 | plogger( 159 | f"{item.title} poster count: {len(all_posters)}", 160 | "info", 161 | "a", 162 | ) 163 | 164 | item_count += 1 165 | except Exception as ex: 166 | plogger( 167 | f"Problem processing {item.title}; {ex}", "info", "a" 168 | ) 169 | 170 | bar() 171 | 172 | plogger(f"Processed {item_count} of {item_total}", "info", "a") 173 | 174 | progress_str = "COMPLETE" 175 | logger(progress_str, "info", "a") 176 | 177 | except Exception as ex: 178 | progress_str = f"Problem processing {lib}; {ex}" 179 | plogger(progress_str, "info", "a") 180 | else: 181 | logger( 182 | f"Library {lib} not found: available libraries on this server are: {ALL_LIB_NAMES}", 183 | "info", 184 | "a", 185 | ) 186 | 187 | plogger("Complete!", "info", "a") 188 | -------------------------------------------------------------------------------- /Plex/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chazlarson/Media-Scripts/aad427055e7ec8031936ffc29a1ad19e0267fd1e/Plex/loading.gif -------------------------------------------------------------------------------- /Plex/logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def setup_logger(logger_name, log_file, level=logging.INFO): 5 | log_setup = logging.getLogger(logger_name) 6 | formatter = logging.Formatter( 7 | "%(levelname)s: %(asctime)s %(message)s", datefmt="%m/%d/%Y %I:%M:%S %p" 8 | ) 9 | fileHandler = logging.FileHandler(log_file, mode="a", encoding="utf-8") 10 | fileHandler.setFormatter(formatter) 11 | log_setup.setLevel(level) 12 | log_setup.addHandler(fileHandler) 13 | 14 | 15 | def setup_dual_logger(logger_name, log_file, level=logging.INFO): 16 | log_setup = logging.getLogger(logger_name) 17 | formatter = logging.Formatter( 18 | "%(levelname)s: %(asctime)s %(message)s", datefmt="%m/%d/%Y %I:%M:%S %p" 19 | ) 20 | fileHandler = logging.FileHandler(log_file, mode="a", encoding="utf-8") 21 | fileHandler.setFormatter(formatter) 22 | streamHandler = logging.StreamHandler() 23 | streamHandler.setFormatter(formatter) 24 | log_setup.setLevel(level) 25 | log_setup.addHandler(fileHandler) 26 | log_setup.addHandler(streamHandler) 27 | 28 | 29 | def logger(msg, level, logfile): 30 | if logfile == "a": 31 | log = logging.getLogger("activity_log") 32 | if logfile == "d": 33 | log = logging.getLogger("download_log") 34 | if level == "info": 35 | log.info(msg) 36 | if level == "warning": 37 | log.warning(msg) 38 | if level == "error": 39 | log.error(msg) 40 | 41 | 42 | def plogger(msg, level, logfile): 43 | logger(msg, level, logfile) 44 | print(msg) 45 | 46 | 47 | def blogger(msg, level, logfile, bar): 48 | logger(msg, level, logfile) 49 | bar.text(msg) 50 | -------------------------------------------------------------------------------- /Plex/mediascripts.sqlite.HIDDEN: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chazlarson/Media-Scripts/aad427055e7ec8031936ffc29a1ad19e0267fd1e/Plex/mediascripts.sqlite.HIDDEN -------------------------------------------------------------------------------- /Plex/pumpanddump.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Original code written by the crew at zd and good to share because `This is the way` https://www.youtube.com/watch?v=1iSz5cuCXdY 3 | 4 | echo "Beginning Plex DB pump and dump" 5 | echo "================================================" 6 | 7 | sqplex="/opt/plexsql/Plex Media Server" 8 | 9 | function usage { 10 | echo "" 11 | echo "Usage: pumpandump.sh plex " 12 | echo "" 13 | echo "where plex is the name of your plex docker container, plex plex2 plex3" 14 | exit 1 15 | } 16 | 17 | if [ -z "$1" ]; then 18 | echo "please provide the name of your plex docker container" 19 | usage 20 | fi 21 | # install JQ if not installed 22 | if hash jq 2> /dev/null; then echo "OK, you have jq installed. We will use that."; else sudo apt install jq -y; fi 23 | echo "================================================" 24 | 25 | echo "Attempting to inspect your Plex Docker container" 26 | echo "================================================" 27 | dbp1=$(docker inspect "${1}" | jq -r ' .[].HostConfig.Binds[] | select( . | contains("/config:rw"))') 28 | if [ -z "$dbp1" ] 29 | then 30 | echo "Unable to extract config path from ${1} container." 31 | echo "Cannot continue, exiting." 32 | echo "================================================" 33 | exit 1 34 | fi 35 | dbp1=${dbp1%%:*} 36 | dbp1=${dbp1#/} 37 | dbp1=${dbp1%/} 38 | dbp2="Library/Application Support/Plex Media Server/Plug-in Support/Databases" 39 | dbpath="${dbp1}/${dbp2}" 40 | plexdbpath="/${dbpath}" 41 | USER=$(stat -c '%U' "$plexdbpath/com.plexapp.plugins.library.db") 42 | GROUP=$(stat -c '%G' "$plexdbpath/com.plexapp.plugins.library.db") 43 | plexdocker="${1}" 44 | 45 | echo "Plex DB Path:" 46 | echo "${plexdbpath}" 47 | echo "Plex Docker:" 48 | echo "${plexdocker}" 49 | echo "================================================" 50 | echo "stopping ${plexdocker} container" 51 | docker stop "${plexdocker}" 52 | echo "copying PMS binary out of ${plexdocker}" 53 | docker cp "${plexdocker}":/usr/lib/plexmediaserver/ /opt/plexsql 54 | cd "$plexdbpath" 55 | echo "================================================" 56 | echo "backing up database" 57 | cp com.plexapp.plugins.library.db com.plexapp.plugins.library.db.original 58 | if [ -z com.plexapp.plugins.library.db.original ] 59 | then 60 | echo "Database backup failed." 61 | echo "Cannot continue, exiting." 62 | echo "================================================" 63 | exit 1 64 | fi 65 | echo "cleaning/resetting folders" 66 | rm -rf "/${dbp1}"/Library/Application Support/Plex Media Server/Codecs/* 67 | 68 | 69 | echo "================================================" 70 | echo "starting database size:" 71 | ls -alh *.db 72 | echo "free space:" 73 | df -h . 74 | 75 | echo "================================================" 76 | echo "removing pointless items from database; errors here are safe to ignore" 77 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "DROP index 'index_title_sort_naturalsort'" 78 | echo "schema_migrations..." 79 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "DELETE from schema_migrations where version='20180501000000'" 80 | echo "statistics_bandwidth..." 81 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "DELETE FROM statistics_bandwidth;" 82 | echo "statistics_media..." 83 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "DELETE FROM statistics_media;" 84 | echo "statistics_resources..." 85 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "DELETE FROM statistics_resources;" 86 | echo "accounts..." 87 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "DELETE FROM accounts;" 88 | echo "devices..." 89 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "DELETE FROM devices;" 90 | echo "================================================" 91 | echo "fixing dates on stuck files" 92 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "UPDATE metadata_items SET added_at = originally_available_at WHERE added_at <> originally_available_at AND originally_available_at IS NOT NULL;" 93 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "UPDATE metadata_items SET added_at = DATETIME('now', '-1 days') WHERE DATETIME(added_at) > DATETIME('now');" 94 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "UPDATE metadata_items SET added_at = DATETIME('now', '-1 days') WHERE DATETIME(originally_available_at) > DATETIME('now');" 95 | echo "database size:" 96 | ls -alh *.db 97 | echo "free space:" 98 | df -h . 99 | echo "================================================" 100 | echo "dumping and removing old database" 101 | "${sqplex}" --sqlite com.plexapp.plugins.library.db .dump > dump.sql 102 | rm com.plexapp.plugins.library.db 103 | echo "sql size:" 104 | ls -alh *.sql 105 | echo "free space:" 106 | df -h . 107 | echo "================================================" 108 | echo "making adjustments to new db" 109 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "pragma page_size=32768; vacuum;" 110 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "pragma default_cache_size = 20000000; vacuum;" 111 | echo "================================================" 112 | echo "importing old data" 113 | "${sqplex}" --sqlite com.plexapp.plugins.library.db originally_available_at AND originally_available_at IS NOT NULL;" 126 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "UPDATE metadata_items SET added_at = DATETIME('now', '-1 days') WHERE DATETIME(added_at) > DATETIME('now');" 127 | "${sqplex}" --sqlite com.plexapp.plugins.library.db "UPDATE metadata_items SET added_at = DATETIME('now', '-1 days') WHERE DATETIME(originally_available_at) > DATETIME('now');" 128 | echo "================================================" 129 | echo "reown to $USER:$GROUP" 130 | sudo chown "$USER:$GROUP" "${plex}"/* 131 | 132 | # Start Applications 133 | echo "================================================" 134 | echo "restarting plex container" 135 | docker start "${plexdocker}" 136 | 137 | echo "================================================" 138 | echo "Plex DB PnD complete" 139 | -------------------------------------------------------------------------------- /Plex/refresh-metadata.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | import sys 5 | import textwrap 6 | import time 7 | from datetime import datetime 8 | from pathlib import Path 9 | 10 | import urllib3.exceptions 11 | from alive_progress import alive_bar 12 | from helpers import booler, get_all_from_library, get_plex, load_and_upgrade_env 13 | from logs import logger, plogger, setup_logger 14 | from requests import ReadTimeout 15 | from urllib3.exceptions import ReadTimeoutError 16 | 17 | # current dateTime 18 | now = datetime.now() 19 | 20 | # convert to string 21 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 22 | 23 | SCRIPT_NAME = Path(__file__).stem 24 | 25 | VERSION = "0.1.0" 26 | 27 | env_file_path = Path(".env") 28 | 29 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 30 | setup_logger("activity_log", ACTIVITY_LOG) 31 | 32 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 33 | 34 | if load_and_upgrade_env(env_file_path) < 0: 35 | exit() 36 | 37 | plex = get_plex() 38 | 39 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 40 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 41 | DELAY = int(os.getenv("DELAY")) 42 | REFRESH_1970_ONLY = booler(os.getenv("REFRESH_1970_ONLY")) 43 | 44 | if LIBRARY_NAMES: 45 | LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] 46 | else: 47 | LIB_ARRAY = [LIBRARY_NAME] 48 | 49 | if LIBRARY_NAMES == "ALL_LIBRARIES": 50 | LIB_ARRAY = [] 51 | all_libs = plex.library.sections() 52 | for lib in all_libs: 53 | if lib.type == "movie" or lib.type == "show": 54 | LIB_ARRAY.append(lib.title.strip()) 55 | 56 | tmdb_str = "tmdb://" 57 | tvdb_str = "tvdb://" 58 | 59 | 60 | def progress(count, total, status=""): 61 | bar_len = 40 62 | filled_len = int(round(bar_len * count / float(total))) 63 | 64 | percents = round(100.0 * count / float(total), 1) 65 | bar = "=" * filled_len + "-" * (bar_len - filled_len) 66 | stat_str = textwrap.shorten(status, width=80) 67 | 68 | sys.stdout.write("[%s] %s%s ... %s\r" % (bar, percents, "%", stat_str.ljust(80))) 69 | sys.stdout.flush() 70 | 71 | 72 | for lib in LIB_ARRAY: 73 | print(f"getting items from [{lib}]...") 74 | logger(f"getting items from [{lib}]...", "info", "a") 75 | the_lib = plex.library.section(lib) 76 | item_total, items = get_all_from_library(the_lib) 77 | logger(f"looping over {item_total} items...", "info", "a") 78 | item_count = 1 79 | 80 | plex_links = [] 81 | external_links = [] 82 | 83 | with alive_bar( 84 | item_total, dual_line=True, title=f"Refresh Metadata: {the_lib.title}" 85 | ) as bar: 86 | for item in items: 87 | tmpDict = {} 88 | item_count = item_count + 1 89 | attempts = 0 90 | 91 | progress_str = f"{item.title}" 92 | 93 | progress(item_count, item_total, progress_str) 94 | 95 | while attempts < 5: 96 | try: 97 | progress_str = f"{item.title} - attempt {attempts + 1}" 98 | logger(progress_str, "info", "a") 99 | 100 | item.refresh() 101 | 102 | time.sleep(DELAY) 103 | progress_str = f"{item.title} - DONE" 104 | progress(item_count, item_total, progress_str) 105 | 106 | attempts = 6 107 | except urllib3.exceptions.ReadTimeoutError: 108 | progress(item_count, item_total, "ReadTimeoutError: " + item.title) 109 | except urllib3.exceptions.HTTPError: 110 | progress(item_count, item_total, "HTTPError: " + item.title) 111 | except ReadTimeoutError: 112 | progress( 113 | item_count, item_total, "ReadTimeoutError-2: " + item.title 114 | ) 115 | except ReadTimeout: 116 | progress(item_count, item_total, "ReadTimeout: " + item.title) 117 | except Exception as ex: 118 | progress(item_count, item_total, "EX: " + item.title) 119 | logging.error(ex) 120 | 121 | attempts += 1 122 | 123 | print(os.linesep) 124 | -------------------------------------------------------------------------------- /Plex/rematch-items.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | import sys 5 | import textwrap 6 | from datetime import datetime 7 | from pathlib import Path 8 | 9 | import urllib3.exceptions 10 | from alive_progress import alive_bar 11 | from helpers import booler, get_all_from_library, get_plex, load_and_upgrade_env 12 | from logs import plogger, setup_logger 13 | from requests import ReadTimeout 14 | from urllib3.exceptions import ReadTimeoutError 15 | 16 | # current dateTime 17 | now = datetime.now() 18 | 19 | # convert to string 20 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 21 | 22 | SCRIPT_NAME = Path(__file__).stem 23 | 24 | # DONE 0.2.0: chattier about where we're getting items 25 | # DONE 0.2.1: Use booler helper to ensure correct var reading 26 | 27 | VERSION = "0.2.1" 28 | 29 | env_file_path = Path(".env") 30 | 31 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 32 | setup_logger("activity_log", ACTIVITY_LOG) 33 | 34 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 35 | 36 | if load_and_upgrade_env(env_file_path) < 0: 37 | exit() 38 | 39 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 40 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 41 | UNMATCHED_ONLY = booler(os.getenv("UNMATCHED_ONLY")) 42 | 43 | if LIBRARY_NAMES: 44 | LIB_ARRAY = LIBRARY_NAMES.split(",") 45 | else: 46 | LIB_ARRAY = [LIBRARY_NAME] 47 | 48 | tmdb_str = "tmdb://" 49 | tvdb_str = "tvdb://" 50 | 51 | 52 | def progress(count, total, status=""): 53 | bar_len = 40 54 | filled_len = int(round(bar_len * count / float(total))) 55 | 56 | percents = round(100.0 * count / float(total), 1) 57 | bar = "=" * filled_len + "-" * (bar_len - filled_len) 58 | stat_str = textwrap.shorten(status, width=80) 59 | 60 | sys.stdout.write("[%s] %s%s ... %s\r" % (bar, percents, "%", stat_str.ljust(80))) 61 | sys.stdout.flush() 62 | 63 | 64 | plex = get_plex() 65 | 66 | if LIBRARY_NAMES == "ALL_LIBRARIES": 67 | LIB_ARRAY = [] 68 | all_libs = plex.library.sections() 69 | for lib in all_libs: 70 | if lib.type == "movie" or lib.type == "show": 71 | LIB_ARRAY.append(lib.title.strip()) 72 | 73 | for lib in LIB_ARRAY: 74 | the_lib = plex.library.section(lib) 75 | plogger(f"getting items from [{lib}]...", "info", "a") 76 | 77 | if UNMATCHED_ONLY: 78 | print(f"getting UNMATCHED items from [{lib}]...") 79 | item_total, items = get_all_from_library(the_lib, None, {"unmatched": True}) 80 | else: 81 | item_total, items = get_all_from_library(the_lib) 82 | 83 | plogger(f"looping over {item_total} items...", "info", "a") 84 | item_count = 0 85 | 86 | plex_links = [] 87 | external_links = [] 88 | 89 | if the_lib.type == "movie": 90 | agents = [ 91 | "com.plexapp.agents.imdb", 92 | "tv.plex.agents.movie", 93 | "com.plexapp.agents.themoviedb", 94 | ] 95 | elif the_lib.type == "show": 96 | agents = ["com.plexapp.agents.thetvdb", "tv.plex.agents.series"] 97 | else: 98 | agents = [ 99 | "com.plexapp.agents.fanarttv", 100 | "com.plexapp.agents.none", 101 | "tv.plex.agents.music", 102 | "com.plexapp.agents.opensubtitles", 103 | "com.plexapp.agents.imdb", 104 | "com.plexapp.agents.lyricfind", 105 | "com.plexapp.agents.thetvdb", 106 | "tv.plex.agents.movie", 107 | "tv.plex.agents.series", 108 | "com.plexapp.agents.plexthememusic", 109 | "org.musicbrainz.agents.music", 110 | "com.plexapp.agents.themoviedb", 111 | "com.plexapp.agents.htbackdrops", 112 | "com.plexapp.agents.movieposterdb", 113 | "com.plexapp.agents.localmedia", 114 | "com.plexapp.agents.lastfm", 115 | ] 116 | 117 | with alive_bar(len(items), dual_line=True, title="Rematching") as bar: 118 | for item in items: 119 | tmpDict = {} 120 | item_count = item_count + 1 121 | matched_it = False 122 | 123 | for agt in agents: 124 | if not matched_it: 125 | try: 126 | progress_str = f"{item.title} - agent {agt}" 127 | bar.text(progress_str) 128 | 129 | item.fixMatch(auto=True, agent=agt) 130 | 131 | matched_it = True 132 | 133 | progress_str = f"{item.title} - DONE" 134 | bar.text(progress_str) 135 | 136 | except urllib3.exceptions.ReadTimeoutError: 137 | progress( 138 | item_count, item_total, "ReadTimeoutError: " + item.title 139 | ) 140 | except urllib3.exceptions.HTTPError: 141 | progress(item_count, item_total, "HTTPError: " + item.title) 142 | except ReadTimeoutError: 143 | progress( 144 | item_count, item_total, "ReadTimeoutError-2: " + item.title 145 | ) 146 | except ReadTimeout: 147 | progress(item_count, item_total, "ReadTimeout: " + item.title) 148 | except Exception as ex: 149 | progress(item_count, item_total, "EX: " + item.title) 150 | logging.error(ex) 151 | bar() 152 | -------------------------------------------------------------------------------- /Plex/reverse-genres.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from datetime import datetime 4 | from pathlib import Path 5 | 6 | from alive_progress import alive_bar 7 | from helpers import get_all_from_library, get_plex, load_and_upgrade_env 8 | from logs import logger, plogger, setup_logger 9 | 10 | # current dateTime 11 | now = datetime.now() 12 | 13 | # convert to string 14 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 15 | 16 | SCRIPT_NAME = Path(__file__).stem 17 | 18 | VERSION = "0.1.0" 19 | 20 | env_file_path = Path(".env") 21 | 22 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 23 | setup_logger("activity_log", ACTIVITY_LOG) 24 | 25 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 26 | 27 | if load_and_upgrade_env(env_file_path) < 0: 28 | exit() 29 | 30 | LIBRARY_NAME = os.getenv("LIBRARY_NAME") 31 | LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") 32 | NEW = [] 33 | UPDATED = [] 34 | 35 | if LIBRARY_NAMES: 36 | LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] 37 | else: 38 | LIB_ARRAY = [LIBRARY_NAME] 39 | 40 | plex = get_plex() 41 | 42 | logger(("connection success"), "info", "a") 43 | 44 | 45 | def reverse_genres(item): 46 | reversed_list = [] 47 | 48 | item.reload() 49 | genres = item.genres 50 | 51 | print(f"{item.title} before: {genres}") 52 | 53 | item.removeGenre(genres) 54 | 55 | for genre in genres: 56 | reversed_list.insert(0, genre) 57 | 58 | print(f"{item.title} reversed: {reversed_list}") 59 | 60 | for genre in reversed_list: 61 | print(f"{item.title} adding: {genre}") 62 | item.addGenre(genre) 63 | item.reload() 64 | 65 | item.reload() 66 | new_genres = item.genres 67 | 68 | print(f"{item.title} after: {new_genres}") 69 | 70 | 71 | if LIBRARY_NAMES == "ALL_LIBRARIES": 72 | LIB_ARRAY = [] 73 | all_libs = plex.library.sections() 74 | for lib in all_libs: 75 | if lib.type == "movie" or lib.type == "show": 76 | LIB_ARRAY.append(lib.title.strip()) 77 | 78 | for lib in LIB_ARRAY: 79 | try: 80 | the_lib = plex.library.section(lib) 81 | 82 | count = plex.library.section(lib).totalSize 83 | print(f"getting {count} {the_lib.type}s from [{lib}]...") 84 | logger((f"getting {count} {the_lib.type}s from [{lib}]..."), "info", "a") 85 | item_total, items = get_all_from_library(the_lib) 86 | logger((f"looping over {item_total} items..."), "info", "a") 87 | item_count = 1 88 | 89 | plex_links = [] 90 | external_links = [] 91 | 92 | with alive_bar(item_total, dual_line=True, title="Reverse Genres") as bar: 93 | for item in items: 94 | logger(("================================"), "info", "a") 95 | logger((f"Starting {item.title}"), "info", "a") 96 | 97 | reverse_genres(item) 98 | 99 | bar() 100 | 101 | progress_str = "COMPLETE" 102 | logger((progress_str), "info", "a") 103 | 104 | bar.text = progress_str 105 | 106 | print(os.linesep) 107 | 108 | except Exception as ex: 109 | progress_str = f"Problem processing {lib}; {ex}" 110 | logger((progress_str), "info", "a") 111 | 112 | print(progress_str) 113 | -------------------------------------------------------------------------------- /Plex/set-user-rating.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import random 3 | from datetime import datetime 4 | from pathlib import Path 5 | 6 | from helpers import get_all_from_library, get_plex, load_and_upgrade_env 7 | from logs import plogger, setup_logger 8 | 9 | # current dateTime 10 | now = datetime.now() 11 | 12 | # convert to string 13 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 14 | 15 | SCRIPT_NAME = Path(__file__).stem 16 | 17 | VERSION = "0.0.1" 18 | 19 | env_file_path = Path(".env") 20 | 21 | ACTIVITY_LOG = f"{SCRIPT_NAME}.log" 22 | setup_logger("activity_log", ACTIVITY_LOG) 23 | 24 | plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") 25 | 26 | if load_and_upgrade_env(env_file_path) < 0: 27 | exit() 28 | 29 | plex = get_plex() 30 | plogger("connection success", "info", "a") 31 | plogger(f"Plex version {plex.version}", "info", "a") 32 | 33 | new_rating = round(random.random() * 10, 1) 34 | 35 | the_lib = plex.library.section("Test-Movies") 36 | the_type = the_lib.type 37 | 38 | print(f"getting first item from the {the_type} library [{the_lib.title}]...") 39 | items = get_all_from_library(the_lib, the_type) 40 | 41 | item = items[1][0] 42 | 43 | item_title = item.title 44 | print(f"Working with: {item_title}") 45 | print(f"Random rating: {new_rating}") 46 | 47 | audience_rating = item.audienceRating 48 | critic_rating = item.rating 49 | user_rating = item.userRating 50 | 51 | print(f"current audience rating on: {item_title}: {audience_rating}") 52 | print(f"current critic rating on: {item_title}: {critic_rating}") 53 | print(f"current user rating on: {item_title}: {user_rating}") 54 | 55 | print(f"setting audience rating on: {item_title} to {new_rating}") 56 | item.editField("audienceRating", new_rating) 57 | 58 | print(f"setting critic rating on: {item_title} to {new_rating}") 59 | item.editField("rating", new_rating) 60 | 61 | print(f"setting user rating on: {item_title} to {new_rating}") 62 | item.editUserRating(new_rating, locked=False) 63 | 64 | print(f"reloading: {item_title}") 65 | item.reload() 66 | 67 | print(f"retrieving ratings for: {item_title}") 68 | user_rating = item.userRating 69 | audience_rating = item.audienceRating 70 | critic_rating = item.rating 71 | 72 | print(f"current audience rating on: {item_title}: {audience_rating}") 73 | print(f"current critic rating on: {item_title}: {critic_rating}") 74 | print(f"current user rating on: {item_title}: {user_rating}") 75 | -------------------------------------------------------------------------------- /Plex/show-all-playlists.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | import os 4 | from datetime import datetime 5 | from pathlib import Path 6 | 7 | from helpers import get_plex, load_and_upgrade_env 8 | 9 | # current dateTime 10 | now = datetime.now() 11 | 12 | # convert to string 13 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 14 | 15 | SCRIPT_NAME = Path(__file__).stem 16 | 17 | VERSION = "0.1.0" 18 | 19 | 20 | env_file_path = Path(".env") 21 | 22 | logging.basicConfig( 23 | filename=f"{SCRIPT_NAME}.log", 24 | filemode="w", 25 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 26 | level=logging.INFO, 27 | ) 28 | 29 | logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") 30 | print(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") 31 | 32 | if load_and_upgrade_env(env_file_path) < 0: 33 | exit() 34 | 35 | PLEX_OWNER = os.getenv("PLEX_OWNER") 36 | 37 | plex = get_plex() 38 | 39 | PMI = plex.machineIdentifier 40 | 41 | account = plex.myPlexAccount() 42 | all_users = account.users() 43 | item = None 44 | 45 | user_ct = len(all_users) 46 | user_idx = 0 47 | for plex_user in all_users: 48 | user_acct = account.user(plex_user.title) 49 | user_idx += 1 50 | try: 51 | user_plex = get_plex(user_acct.get_token(PMI)) 52 | 53 | playlists = user_plex.playlists() 54 | if len(playlists) > 0: 55 | print(f"\n------------ {plex_user.title} ------------") 56 | 57 | for pl in playlists: 58 | print( 59 | f"------------ {plex_user.title} playlist: {pl.title} ------------" 60 | ) 61 | items = pl.items() 62 | for item in items: 63 | typestr = f"{item.type}".ljust(7) 64 | output = item.title 65 | if item.type == "episode": 66 | output = ( 67 | f"{item.grandparentTitle} {item.seasonEpisode} {item.title}" 68 | ) 69 | print(f"{typestr} - {output}") 70 | except: 71 | handle_this_silently = "please" 72 | -------------------------------------------------------------------------------- /Plex/templates/category.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ library_name }} - {{ category_name }} 6 | 7 | 8 | 9 |
10 | 17 |

{{ category_name }}

18 |
    19 | {% for show in shows %} 20 |
  • 21 | {{ show }} 22 |
  • 23 | {% endfor %} 24 |
25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /Plex/templates/direct_shows.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ library_name }} 6 | 7 | 8 | 9 |
10 | 16 |

Shows/Movies in {{ library_name }}

17 |
    18 | {% for show in shows %} 19 |
  • 20 | {{ show }} 21 |
  • 22 | {% endfor %} 23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /Plex/templates/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Media Libraries 6 | 7 | 8 | 9 |
10 | 15 |

Media Libraries

16 | Reading from:

{{ asset_dir }}

17 | Copying to:

{{ active_dir }}

18 |
    19 | {% for library in libraries %} 20 |
  • 21 | {{ library }} 22 |
  • 23 | {% endfor %} 24 |
25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /Plex/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Image Gallery 6 | 7 | 22 | 23 | 24 |
25 |

Image Gallery

26 |
27 | {% for item in items %} 28 | {% if item.endswith('/') %} 29 |
30 |
31 | {{ item }} 32 |
33 | {% else %} 34 |
35 | 36 | 37 | 38 |
39 | {% endif %} 40 | {% endfor %} 41 |
42 |
43 | 44 | 45 | -------------------------------------------------------------------------------- /Plex/templates/library.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ library_name }} Library 6 | 7 | 8 | 9 |
10 | 16 |

{{ library_name }}

17 |
    18 | {% for item in categories %} 19 |
  • 20 | {{ item }} 21 |
  • 22 | {% endfor %} 23 |
24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /Plex/templates/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chazlarson/Media-Scripts/aad427055e7ec8031936ffc29a1ad19e0267fd1e/Plex/templates/loading.gif -------------------------------------------------------------------------------- /Plex/user-emails.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | from datetime import datetime 4 | from pathlib import Path 5 | 6 | from helpers import get_plex, load_and_upgrade_env 7 | 8 | # current dateTime 9 | now = datetime.now() 10 | 11 | # convert to string 12 | RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") 13 | 14 | SCRIPT_NAME = Path(__file__).stem 15 | 16 | VERSION = "0.1.0" 17 | 18 | env_file_path = Path(".env") 19 | 20 | logging.basicConfig( 21 | filename=f"{SCRIPT_NAME}.log", 22 | filemode="w", 23 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 24 | level=logging.INFO, 25 | ) 26 | 27 | logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") 28 | print(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") 29 | 30 | if load_and_upgrade_env(env_file_path) < 0: 31 | exit() 32 | 33 | print("connecting...") 34 | plex = get_plex() 35 | plexacc = plex.myPlexAccount() 36 | print("getting users...") 37 | users = plexacc.users() 38 | user_total = len(users) 39 | print(f"looping over {user_total} users...") 40 | for u in users: 41 | print(f"{u.username} - {u.email}") 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Media-Scripts 2 | 3 | Misc scripts and tools. Undocumented scripts probably do what I need them to but aren't finished yet. 4 | 5 | ## Requirements 6 | 7 | 1. A system that can run Python 3.7 [or newer] 8 | 1. Python 3.7 [or newer] installed on that system 9 | 10 | One of the requirements of these scripts is alive-progress 2.4.1, which requires Python 3.7. 11 | 12 | 1. A basic knowledge of how to run Python scripts. 13 | 14 | ## Setup 15 | 16 | ### if you use [`direnv`](https://github.com/direnv/direnv): 17 | 1. clone the repo 18 | 1. cd into the repo dir 19 | 1. run `direnv allow` as the prompt will tell you to 20 | 1. direnv will build the virtual env and keep requirements up to date 21 | 22 | ### if you don't use [`direnv`](https://github.com/direnv/direnv): 23 | 1. install direnv 24 | 2. go to the previous section 25 | 26 | ok not really 27 | 28 | ### if you don't use [`direnv`](https://github.com/direnv/direnv): 29 | 30 | 1. clone repo 31 | ``` 32 | git clone https://github.com/chazlarson/Media-Scripts.git 33 | ``` 34 | 1. cd to repo directory 35 | ``` 36 | cd Media-Scripts 37 | ``` 38 | 1. Install requirements with `python3 -m pip install -r requirements.txt` [I'd suggest doing this in a virtual environment] 39 | 40 | Creating a virtual environment is described [here](https://docs.python.org/3/library/venv.html); there's also a step-by-step in the local walkthrough in the Kometa wiki. 41 | 42 | ### After you've done one of the above: 43 | Once you have the requirements installed via whatever means, you are ready to set up the script-specific stuff. 44 | 45 | 1. cd to script directory [`Plex`, `Kometa`, `TMDB`, etc] 46 | for example: 47 | ``` 48 | cd Plex 49 | ``` 50 | 1. Copy `.env.example` to `.env` 51 | 52 | Linux or Mac: 53 | ``` 54 | cp .env.example .env 55 | ``` 56 | Windows: 57 | ``` 58 | copy .env.example .env 59 | ``` 60 | 1. Edit .env to suit your environment [plex url, token, libraries] and your requirements [what to do, where to download things, etc.]; the settings for each script are detailed in the readme within each folder as shown below. 61 | 62 | Edit the file with whatever text editor you wish. 63 | 1. Run the desired script. 64 | 65 | 66 | All these scripts use the same `.env` and requirements. 67 | 68 | ## Plex scripts: 69 | 70 | 1. `adjust-added-dates.py` - fix broken added and perhaps originally available dates in your library 71 | 1. `user-emails.py` - extract user emails from your shares 72 | 1. `reset-posters-tmdb.py` - reset all artwork in a library to TMDB default 73 | 1. `reset-posters-plex.py` - reset all artwork in a library to Plex default 74 | 1. `grab-all-IDs.py` - grab [into a sqlite DB] ratingKey, IMDB ID, TMDB ID, TVDB ID for everything in a library from plex 75 | 1. `grab-all-posters.py` - grab some or all of the artwork for a library from plex 76 | 1. `grab-all-status.py` - grab watch status for all users all libraries from plex 77 | 1. `apply-all-status.py` - apply watch status for all users all libraries to plex from the file emitted by the previous script 78 | 1. `show-all-playlists.py` - Show contents of all user playlists 79 | 1. `delete-collections.py` - delete most or all collections from one or more libraries 80 | 1. `refresh-metadata.py` - Refresh metadata individually on items in a library 81 | 1. `list-item-ids.py` - Generate a list of IDs in libraries and/or collections 82 | 1. `actor-count.py` - Generate a list of actor credit counts 83 | 1. `crew-count.py` - Generate a list of crew credit counts 84 | 1. `list-low-poster-counts.py` - Generate a list of items that have fewer than some number of posters in Plex 85 | 86 | See the [Plex Scripts README](Plex/README.md) for details. 87 | 88 | ## Kometa scripts 89 | 90 | 1. `extract-collections.py` - extract collections from a library 91 | 1. `kometa-trakt-auth.py` - generate trakt auth block for Kometa config.yml 92 | 1. `kometa-mal-auth.py` - generate mal auth block for Kometa config.yml 93 | 1. `original-to-assets.py` - Copy image files from an "Original Posters" directory to an asset directory 94 | 1. `metadata-extractor.py` - Back up all metadata into a Kometa-compatible metadata file 95 | 96 | See the [Kometa Scripts README](Kometa/README.md) for details. 97 | 98 | ## TMDB scripts 99 | 100 | 1. `tmdb-people.py` - retrieve TMDB images for a list of people 101 | 102 | See the [TMDB Scripts README](TMDB/README.md) for details. 103 | 104 | ## Plex Image Picker 105 | 106 | This presents a web UI that lets you scroll through the images that Plex provides for each item [movie, show, season, episode], selecting the one you want by clicking a button, which downloads it to an asset directory 107 | 108 | See the [Plex Image Picker README](Plex%20Image%20Picker/README.md) for details. 109 | 110 | ## Other script repos of interest 111 | 112 | 1. [bullmoose](https://github.com/bullmoose20/Plex-Stuff) 113 | 2. [Casvt](https://github.com/Casvt/Plex-scripts) 114 | 3. [maximuskowalski](https://github.com/maximuskowalski/maxmisc) 115 | -------------------------------------------------------------------------------- /TMDB/.env.example: -------------------------------------------------------------------------------- 1 | TMDB_KEY=TMDB_API_KEY 2 | DELAY=0 3 | POSTER_DIR=people_posters 4 | PERSON_DEPTH=10 5 | -------------------------------------------------------------------------------- /TMDB/README.md: -------------------------------------------------------------------------------- 1 | # TMDB scripts 2 | 3 | Misc scripts and tools. Undocumented scripts probably do what I need them to but aren't finished yet. 4 | 5 | ## Setup 6 | 7 | See the top-level [README](../README.md) for setup instructions. 8 | 9 | All these scripts use the same `.env` and requirements. 10 | 11 | ### `.env` contents 12 | 13 | ``` 14 | TMDB_KEY=TMDB_API_KEY # https://developers.themoviedb.org/3/getting-started/introduction 15 | DELAY=1 # optional delay between items 16 | POSTER_DIR=people_posters # put downloaded posters here 17 | PERSON_DEPTH=10 # how deep to go into the search results for people 18 | ``` 19 | 20 | ## Scripts: 21 | 1. [tmdb-people.py](#tmdb-peoplepy) - retrieve TMDB images for a list of people 22 | 23 | ## tmdb-people.py.py 24 | 25 | You want a bunch of person images from TMDB. 26 | 27 | ### Usage 28 | 1. setup as above 29 | 2. enter names or TMDB IDs into people_list.txt 30 | 3. Run with `python tmdb-people.py` 31 | 32 | The script will loop through all the names in the list and download their profile images from TMDB. 33 | 34 | ```shell 35 | $ python tmdb-people.py 36 | 526 item(s) retrieved... 37 | on 33: -> exception: Archie Bunker - No Results Found 38 | on 48: -> exception: Benson & Moorhead - No Results Found 39 | TMDB people |████████▌ | █▆▄ 111/526 [21%] in 33s (3.4/s, eta: 2:03) 40 | -> starting: Chuck Russell 41 | ``` 42 | 43 | If the name search returns more than one result, the script will attempt to download images for the first `PERSON_DEPTH` results. 44 | -------------------------------------------------------------------------------- /TMDB/people_list.txt: -------------------------------------------------------------------------------- 1 | 3131 2 | 20907 3 | 4173 4 | 1223677 5 | 1813 6 | 82809 7 | 11701 8 | 8452 9 | 1012 10 | 224513 11 | 440414 12 | Aaron Sorkin 13 | Abel Ferrara 14 | Adam Driver 15 | Adam Sandler 16 | Agnes Varda 17 | Akira Kurosawa 18 | Al Pacino 19 | Alan J. Pakula 20 | Alan Parker 21 | Alejandro González Iñárritu 22 | Alejandro Jodorowsky 23 | Alex Garland 24 | Alex Proyas 25 | Alexandra Daddario 26 | Alexandre Aja 27 | Alfred Hitchcock 28 | Alice Eve 29 | Alicia Vikander 30 | Amanda Seyfried 31 | Amber Heard 32 | Amy Adams 33 | Amy Heckerling 34 | Amy Schumer 35 | Ana de Armas 36 | Anders Thomas Jensen 37 | Andrei Tarkovsky 38 | Angelina Jolie 39 | Annabelle Wallis 40 | Anne Hathaway 41 | Anthony Bourdain 42 | Anthony Hopkins 43 | Antoine Fuqua 44 | Antonio Banderas 45 | Archie Bunker 46 | Ari Aster 47 | Arnold Schwarzenegger 48 | Arthur Hiller 49 | Arthur Penn 50 | Asghar Farhadi 51 | Aubrey Plaza 52 | Audrey Hepburn 53 | Barry Jenkins 54 | Barry Levinson 55 | Barry Sonnenfeld 56 | Baz Luhrmann 57 | Ben Affleck 58 | Ben Stiller 59 | Ben Wheatley 60 | Benson & Moorhead 61 | Bernardo Bertolucci 62 | Bette Davis 63 | Bill Condon 64 | Bill Murray 65 | Bill Paxton 66 | Billy Bob Thornton 67 | Billy Wilder 68 | Blake Edwards 69 | Blake Lively 70 | Bo Burnham 71 | Bob Clark 72 | Bob Fosse 73 | Bob Rafelson 74 | Bobcat Goldthwait 75 | Bong Joon-ho 76 | Brad Bird 77 | Brad Pitt 78 | Bradley Cooper 79 | Brendan Fraser 80 | Brian De Palma 81 | Brian G. Hutton 82 | Brian Helgeland 83 | Brie Larson 84 | Brittany Murphy 85 | Bruce Lee 86 | Bruce Willis 87 | Burt Reynolds 88 | Busby Berkeley 89 | Buster Keaton 90 | Cameron Crowe 91 | Cameron Diaz 92 | Cara Delevingne 93 | Carl Reiner 94 | Carl Theodor Dreyer 95 | Carol Reed 96 | Carrie Fisher 97 | Cary Grant 98 | Cate Blanchett 99 | Catherine Zeta-Jones 100 | Cecil B. Demille 101 | Channing Tatum 102 | Charles Bronson 103 | Charles Chaplin 104 | Charles Laughton 105 | Charlie Chaplin 106 | Charlize Theron 107 | Chevy Chase 108 | Chloe Grace Moretz 109 | Chris Columbus 110 | Chris Evans 111 | Chris Farley 112 | Chris Hemsworth 113 | Chris Morgan 114 | Chris Pratt 115 | Chris Rock 116 | Christian Bale 117 | Christophe Gans 118 | Christopher Guest 119 | Christopher McQuarrie 120 | Christopher Nolan 121 | Christopher Walken 122 | Chuck Norris 123 | Chuck Russell 124 | Clint Eastwood 125 | Clive Barker 126 | Coen Brothers 127 | Corin Hardy 128 | Costa-Gavras 129 | Curtis Hanson 130 | D.W. Griffith 131 | Damien Chazelle 132 | Daniel Craig 133 | Daniel Day-Lewis 134 | Daniel Radcliffe 135 | Danny Boyle 136 | Danny Trejo 137 | Dario Argento 138 | Darren Aronofsky 139 | Dave Bautista 140 | Dave Chappelle 141 | David Cronenberg 142 | David Fincher 143 | David Koepp 144 | David Lean 145 | David Lynch 146 | David O. Russell 147 | Demetri Martin 148 | Dennis Hopper 149 | Denzel Washington 150 | Dexter Fletcher 151 | Diane Keaton 152 | Diane Kruger 153 | Don Cheadle 154 | Don Siegel 155 | Don Taylor 156 | Donald Cammell 157 | Doug Liman 158 | Douglas Sirk 159 | Duncan Jones 160 | Dustin Hoffman 161 | Dwayne Johnson 162 | Eddie Murphy 163 | Edgar Wright 164 | Edward D. Wood Jr. 165 | Edward Norton 166 | Eli Roth 167 | Elia Kazan 168 | Elizabeth Banks 169 | Elizabeth Hurley 170 | Elizabeth Olsen 171 | Elizabeth Shue 172 | Elliot Page 173 | Elvis Presley 174 | Emilia Clarke 175 | Emily Blunt 176 | Emily Ratajkowski 177 | Emma Stone 178 | Emma Watson 179 | Emmanuelle Vaugier 180 | Eric Fellner 181 | Ernest Lehman 182 | Evan Handler 183 | Evangeline Lilly 184 | Ewan McGregor 185 | F. Gary Gray 186 | F.W. Murnau 187 | Fede Alvarez 188 | Federico Fellini 189 | Felicity Jones 190 | Frances McDormand 191 | Francis Ford Coppola 192 | Franco Zeffirelli 193 | Frank Capra 194 | Frank Oz 195 | Francois Truffaut 196 | Freddie Francis 197 | Fritz Lang 198 | Gal Gadot 199 | Gareth Edwards 200 | Gareth Evans 201 | Garry Marshall 202 | Garth Jennings 203 | Gaspar Noe 204 | Gene Hackman 205 | Gene Wilder 206 | George A. Romero 207 | George Carlin 208 | George Clooney 209 | George Lucas 210 | George Miller 211 | George Roy Hill 212 | Gerard Butler 213 | Goldie Hawn 214 | Gore Verbinski 215 | Grace Kelly 216 | Greg Mottola 217 | Gregg Araki 218 | Gregory Peck 219 | Greta Gerwig 220 | Griffin Dunne 221 | Gus Van Sant 222 | Gwyneth Paltrow 223 | Hal Ashby 224 | Hal Needham 225 | Halle Berry 226 | Harmony Korine 227 | Harold Ramis 228 | Harrison Ford 229 | Hayao Miyazaki 230 | Helen Mirren 231 | Helena Bonham Carter 232 | Herschell Gordon Lewis 233 | Hideo Nakata 234 | Hiram Garcia 235 | Howard Hawks 236 | Hugh Jackman 237 | Humphrey Bogart 238 | Hutch Parker 239 | Ian Bryce 240 | Ian McKellen 241 | Ice Cube 242 | Idris Elba 243 | Iliza Shlesinger 244 | Ingmar Bergman 245 | Ingrid Bergman 246 | Irwin Winkler 247 | Isabel Lucas 248 | Isabel May 249 | Isao Takahata 250 | Ivan Reitman 251 | J.A. Bayona 252 | J.J. Abrams 253 | Jack Arnold 254 | Jack Black 255 | Jack Clayton 256 | Jack Nicholson 257 | Jackie Chan 258 | James Cameron 259 | James Dean 260 | James Franco 261 | James Stewart 262 | James Wan 263 | James Whale 264 | Jamie Foxx 265 | Jason Bateman 266 | Jason Blum 267 | Jason Statham 268 | Jean Renoir 269 | Jean Rollin 270 | Jean-Luc Godard 271 | Jean-Pierre Jeunet 272 | Jeff Bridges 273 | Jeff Goldblum 274 | Jennifer Aniston 275 | Jennifer Connelly 276 | Jennifer Lawrence 277 | Jeremy Gardner 278 | Jerry Bruckheimer 279 | Jerry Zucker 280 | Jesse Eisenberg 281 | Jessica Alba 282 | Jessica Biel 283 | Jet Li 284 | Jim Carrey 285 | Jim Henson 286 | Jim Jarmusch 287 | Joaquin Phoenix 288 | Joe Dante 289 | Joel Schumacher 290 | Joel Silver 291 | John Badham 292 | John Boorman 293 | John Candy 294 | John Carpenter 295 | John Cassavetes 296 | John Ford 297 | John Frankenheimer 298 | John G. Avildsen 299 | John Huston 300 | John Landis 301 | John Mulaney 302 | John Schlesinger 303 | John Singleton 304 | John Travolta 305 | John Waters 306 | John Wayne 307 | John Woo 308 | Johnny Depp 309 | Jon Favreau 310 | Jon Kilik 311 | Jonathan Demme 312 | Jordan Peele 313 | Judd Apatow 314 | Julia Ducournau 315 | Julia Roberts 316 | Julianne Moore 317 | Kaitlyn Dever 318 | Kate Beckinsale 319 | Kate Winslet 320 | Katheryn Winnick 321 | Kathryn Bigelow 322 | Keanu Reeves 323 | Keira Knightley 324 | Ken Russell 325 | Kevin Bacon 326 | Kevin Costner 327 | Kevin Hart 328 | Kevin Smith 329 | Kristen Bell 330 | Kristen Stewart 331 | Kristen Wiig 332 | Kristin Kreuk 333 | Lars von Trier 334 | Laura Vandervoort 335 | Laurence Fishburne 336 | Leigh Whannell 337 | Leonardo DiCaprio 338 | Lewis Gilbert 339 | Liam Neeson 340 | Lily James 341 | Liv Tyler 342 | Lorenzo Di Bonaventura 343 | Lorne Orleans 344 | Louis C.K 345 | Luc Besson 346 | Lucio Fulci 347 | Lucy Liu 348 | M. Night Shyamalan 349 | Margot Robbie 350 | Marilyn Monroe 351 | Mark Wahlberg 352 | Marlon Brando 353 | Martin Scorsese 354 | Matt Damon 355 | Matthew McConaughey 356 | Meg Ryan 357 | Megan Fox 358 | Mel B 359 | Mel Brooks 360 | Mel Gibson 361 | Melissa Benoist 362 | Melissa McCarthy 363 | Meryl Streep 364 | Michael Bay 365 | Michael Caine 366 | Michael Douglas 367 | Michael Keaton 368 | Michelle Monaghan 369 | Mike Myers 370 | Mila Kunis 371 | Milla Jovovich 372 | Morgan Freeman 373 | Naomi Watts 374 | Natalie Portman 375 | Nazanin Boniadi 376 | Neal H. Moritz 377 | Neil Breen 378 | Neil Marshall 379 | Neill Blomkamp 380 | Nick Frost 381 | Nicolas Cage 382 | Nicolas Winding 383 | Nicole Apelian 384 | Nicole Kidman 385 | Nina Dobrev 386 | Nora Ephron 387 | Norman J. Warren 388 | Odette Annable 389 | Olga Kurylenko 390 | Oliver Stone 391 | Olivia Wilde 392 | Orlando Bloom 393 | Orson Welles 394 | Owen Wilson 395 | Park Chan-wook 396 | Patrick Stewart 397 | Patrick Wilson 398 | Patton Oswalt 399 | Paul Newman 400 | Paul Rudd 401 | Paul Schrader 402 | Paul Thomas Anderson 403 | Paul Verhoeven 404 | Paul W. S. Anderson 405 | Penelope Cruz 406 | Penélope Cruz 407 | Penny Marshall 408 | Peter Bogdanovich 409 | Peyton List 410 | Phil Lord 411 | Philip Seymour Hoffman 412 | Quentin Dupieux 413 | Quentin Tarantino 414 | Rachel McAdams 415 | Rachel Weisz 416 | Rami Malek 417 | Ray Harryhausen 418 | Rebecca Romijn 419 | Reese Witherspoon 420 | Richard Donner 421 | Richard Gere 422 | Richard Linklater 423 | Richard Pryor 424 | Ridley Scott 425 | Rob Schneider 426 | Rob Zombie 427 | Robert Altman 428 | Robert De Niro 429 | Robert Downey Jr. 430 | Robert Duval 431 | Robert Mark Kamen 432 | Robert Redford 433 | Robert Rodriguez 434 | Robert Zemeckis 435 | Robin Williams 436 | Roger Moore 437 | Roman Polanski 438 | Ron Howard 439 | Rooney Mara 440 | Rosamund Pike 441 | Rosario Dawson 442 | Russ Meyer 443 | Russell Crowe 444 | Ryan Gosling 445 | Ryan Reynolds 446 | Sacha Baron Cohen 447 | Safdie Brothers 448 | Sam Neill 449 | Samuel L. Jackson 450 | Sandra Bullock 451 | Saoirse Ronan 452 | Satoshi Kon 453 | Scarlett Johansson 454 | Sean Bean 455 | Sean Connery 456 | Sean Penn 457 | Sergio Leone 458 | Seth MacFarlane 459 | Seth Rogen 460 | Shia LaBeouf 461 | Sidney Lumet 462 | Sigourney Weaver 463 | Sofia Coppola 464 | Sofía Vergara 465 | Spike Jonze 466 | Spike Lee 467 | Stan Lee 468 | Stanley Kubrick 469 | Stephen King 470 | Steve Buscemi 471 | Steve Carell 472 | Steve Irwin 473 | Steve Martin 474 | Steve Zahn 475 | Steve Zaillian 476 | Steven Seagal 477 | Steven Soderbergh 478 | Steven Spielberg 479 | Susan Sarandon 480 | Sydney Pollack 481 | Sylvester Stallone 482 | Taika Waititi 483 | Takashi Miike 484 | Ted Elliott 485 | Terrence Malick 486 | Terry Gilliam 487 | Terry Rossio 488 | The Wachowskis 489 | Thomas Vinterberg 490 | Tilda Swinton 491 | Tim Allen 492 | Tim Bevan 493 | Tim Burton 494 | Timothy Olyphant 495 | Tobe Hooper 496 | Tom Cruise 497 | Tom Hanks 498 | Tom Holland 499 | Tom Pevsner 500 | Tom Rosenberg 501 | Tom Segura 502 | Tommy Lee Jones 503 | Tony Scott 504 | Trey Parker 505 | Uma Thurman 506 | Uwe Boll 507 | Vin Diesel 508 | Vince Vaughn 509 | Walter Hill 510 | Werner Herzog 511 | Wes Anderson 512 | Wes Craven 513 | Wesley Snipes 514 | Will Ferrell 515 | Will Smith 516 | Willem Dafoe 517 | William Castle 518 | William Friedkin 519 | Wong Kar-wai 520 | Woody Allen 521 | Woody Harrelson 522 | Wyck Godfrey 523 | Yorgos Lanthimos 524 | Yvonne Strahovski 525 | Zack Snyder 526 | Zoe Saldana 527 | -------------------------------------------------------------------------------- /TMDB/requirements.txt: -------------------------------------------------------------------------------- 1 | alive-progress 2 | PlexAPI 3 | tmdbsimple 4 | tmdbapis 5 | tmdbv3api 6 | python-dotenv 7 | tvdb_v4_official 8 | ruamel.yaml 9 | requests 10 | pathvalidate 11 | imdbpy 12 | PlexAPI 13 | tmdbapis 14 | tmdbv3api 15 | python-dotenv 16 | tvdb_v4_official 17 | ruamel.yaml 18 | requests 19 | alive-progress 20 | PlexAPI 21 | tmdbsimple 22 | tmdbapis 23 | tmdbv3api 24 | python-dotenv 25 | tvdb_v4_official 26 | ruamel.yaml 27 | requests 28 | pathvalidate 29 | imdbpy 30 | PlexAPI 31 | tmdbapis 32 | tmdbv3api 33 | python-dotenv 34 | tvdb_v4_official 35 | ruamel.yaml 36 | requests 37 | -------------------------------------------------------------------------------- /TMDB/tmdb-people.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from timeit import default_timer as timer 4 | 5 | import requests 6 | from alive_progress import alive_bar 7 | from dotenv import load_dotenv 8 | from tmdbapis import TMDbAPIs 9 | 10 | start = timer() 11 | 12 | load_dotenv() 13 | 14 | TMDB_KEY = os.getenv("TMDB_KEY") 15 | POSTER_DIR = os.getenv("POSTER_DIR") 16 | PERSON_DEPTH = 0 17 | try: 18 | PERSON_DEPTH = int(os.getenv("PERSON_DEPTH")) 19 | except: 20 | PERSON_DEPTH = 0 21 | 22 | 23 | TMDb = TMDbAPIs(TMDB_KEY, language="en") 24 | 25 | image_path = POSTER_DIR 26 | people_name_file = "people_list.txt" 27 | 28 | items = [] 29 | 30 | people_file = Path(people_name_file) 31 | 32 | if people_file.is_file(): 33 | with open(people_name_file, encoding="utf-8") as fp: 34 | for line in fp: 35 | items.append(line.strip()) 36 | 37 | idx = 1 38 | 39 | 40 | def save_image(person, idx, UPPER): 41 | file_root = f"{person.name}-{person.id}" 42 | 43 | if person.profile_url is not None: 44 | r = requests.get(person.profile_url) 45 | 46 | filepath = Path(f"{image_path}/{file_root}.jpg") 47 | filepath.parent.mkdir(parents=True, exist_ok=True) 48 | 49 | with filepath.open("wb") as f: 50 | f.write(r.content) 51 | # else: 52 | # print(f"no profile image for {person.name} #{idx} of {UPPER}") 53 | 54 | 55 | item_total = len(items) 56 | print(f"{item_total} item(s) retrieved...") 57 | item_count = 1 58 | with alive_bar(item_total, dual_line=True, title="TMDB people") as bar: 59 | for item in items: 60 | bar.text = f"-> starting: {item}" 61 | item_count = item_count + 1 62 | 63 | try: 64 | person = TMDb.person(int(item)) 65 | bar.text = f"-> retrieving: {item}" 66 | save_image(person, 0, 1) 67 | 68 | except ValueError: 69 | try: 70 | results = TMDb.people_search(str(item)) 71 | if not results: 72 | bar.text = f"-> NOT FOUND: {item}" 73 | continue 74 | 75 | idx = 0 76 | UPPER = PERSON_DEPTH 77 | 78 | if len(results) < PERSON_DEPTH: 79 | UPPER = len(results) 80 | 81 | for i in range(0, UPPER): 82 | try: 83 | person = results[i] 84 | idx = idx + 1 85 | bar.text = f"-> retrieving: {idx}-{item}" 86 | save_image(person, idx, UPPER) 87 | 88 | except Exception as ex: 89 | print(f"-> exception: {item} - {ex.args[0]}") 90 | except Exception as ex: 91 | print(f"-> exception: {item} - {ex.args[0]}") 92 | 93 | bar() 94 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import requests 4 | from flask import Flask, flash, redirect, render_template, request, session, url_for 5 | from plexapi.server import PlexServer 6 | 7 | app = Flask(__name__) 8 | app.secret_key = os.urandom(24) 9 | 10 | 11 | def get_plex(): 12 | base_url = session.get("base_url") 13 | token = session.get("token") 14 | if not base_url or not token: 15 | return None 16 | return PlexServer(base_url, token) 17 | 18 | 19 | @app.route("/", methods=["GET", "POST"]) 20 | def connect(): 21 | if request.method == "POST": 22 | session["base_url"] = request.form["base_url"] 23 | session["token"] = request.form["token"] 24 | return redirect(url_for("libraries")) 25 | return render_template("connect.html") 26 | 27 | 28 | @app.route("/libraries") 29 | def libraries(): 30 | plex = get_plex() 31 | if not plex: 32 | return redirect(url_for("connect")) 33 | sections = plex.library.sections() 34 | return render_template("libraries.html", sections=sections) 35 | 36 | 37 | @app.route("/gallery/") 38 | def gallery(section_key): 39 | plex = get_plex() 40 | if not plex: 41 | return redirect(url_for("connect")) 42 | # find section by key 43 | section = next( 44 | (s for s in plex.library.sections() if str(s.key) == section_key), None 45 | ) 46 | if not section: 47 | flash("Library not found.") 48 | return redirect(url_for("libraries")) 49 | page = int(request.args.get("page", 1)) 50 | art_type = request.args.get("art_type", "poster") 51 | items = list(section.all()) 52 | total = len(items) 53 | per_page = 10 54 | pages = (total + per_page - 1) // per_page 55 | start = (page - 1) * per_page 56 | end = start + per_page 57 | page_items = items[start:end] 58 | return render_template( 59 | "gallery.html", 60 | section=section, 61 | items=page_items, 62 | page=page, 63 | pages=pages, 64 | art_type=art_type, 65 | ) 66 | 67 | 68 | @app.route("/download", methods=["POST"]) 69 | def download(): 70 | plex = get_plex() 71 | if not plex: 72 | return redirect(url_for("connect")) 73 | rating_key = request.form["rating_key"] 74 | art_type = request.form["art_type"] 75 | section_key = request.form["section_key"] 76 | page = request.form.get("page", 1) 77 | item = plex.fetchItem(rating_key) 78 | # Determine asset name (folder) 79 | try: 80 | media_file = item.media[0].parts[0].file 81 | asset_name = os.path.basename(os.path.dirname(media_file)) 82 | except: 83 | # Fallback for seasons: use show title 84 | asset_name = item.title 85 | # Build directory 86 | base_dir = os.path.join( 87 | os.getcwd(), 88 | "assets", 89 | "movies" if item.type == "movie" else "series", 90 | asset_name, 91 | ) 92 | os.makedirs(base_dir, exist_ok=True) 93 | # Choose URL and filename 94 | url = item.posterUrl if art_type == "poster" else item.artUrl 95 | if item.type == "season": 96 | filename = f"Season {item.index:02d}.jpg" 97 | elif item.type == "episode": 98 | s = item.seasonNumber 99 | e = item.index 100 | filename = f"S{s:02d}E{e:02d}.jpg" 101 | else: 102 | filename = f"{art_type}.jpg" 103 | # Download and save 104 | resp = requests.get( 105 | f"{session['base_url']}{url}", headers={"X-Plex-Token": session["token"]} 106 | ) 107 | with open(os.path.join(base_dir, filename), "wb") as f: 108 | f.write(resp.content) 109 | flash(f"Saved to {os.path.join(base_dir, filename)}") 110 | return redirect( 111 | url_for("gallery", section_key=section_key, page=page, art_type=art_type) 112 | ) 113 | 114 | 115 | if __name__ == "__main__": 116 | app.run(host="0.0.0.0", port=5000) 117 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alive-progress==2.4.1 2 | imdbpy 3 | pathvalidate 4 | PlexAPI 5 | pre-commit 6 | pyopenssl 7 | python-dotenv 8 | requests 9 | ruamel.yaml 10 | setuptools 11 | tmdbapis 12 | tmdbsimple 13 | tmdbv3api 14 | tvdb_v4_official 15 | piexif 16 | filetype 17 | sqlalchemy<2.0 18 | tabulate 19 | validators 20 | pillow 21 | 22 | Flask 23 | GitPython 24 | num2words 25 | retrying 26 | ruff 27 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Exclude a variety of commonly ignored directories. 2 | exclude = [ 3 | ".bzr", 4 | ".direnv", 5 | ".eggs", 6 | ".git", 7 | ".git-rewrite", 8 | ".hg", 9 | ".ipynb_checkpoints", 10 | ".mypy_cache", 11 | ".nox", 12 | ".pants.d", 13 | ".pyenv", 14 | ".pytest_cache", 15 | ".pytype", 16 | ".ruff_cache", 17 | ".svn", 18 | ".tox", 19 | ".venv", 20 | ".vscode", 21 | "__pypackages__", 22 | "_build", 23 | "buck-out", 24 | "build", 25 | "dist", 26 | "node_modules", 27 | "site-packages", 28 | "venv", 29 | ] 30 | 31 | # Same as Black. 32 | line-length = 88 33 | indent-width = 4 34 | 35 | # Assume Python 3.9 36 | target-version = "py39" 37 | 38 | [lint] 39 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 40 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 41 | # McCabe complexity (`C901`) by default. 42 | select = ["E4", "E7", "E9", "F"] 43 | ignore = ["E701", "E722", "F811"] 44 | 45 | # Allow fix for all enabled rules (when `--fix`) is provided. 46 | fixable = ["ALL"] 47 | unfixable = [] 48 | 49 | # Allow unused variables when underscore-prefixed. 50 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 51 | 52 | [format] 53 | # Like Black, use double quotes for strings. 54 | quote-style = "double" 55 | 56 | # Like Black, indent with spaces, rather than tabs. 57 | indent-style = "space" 58 | 59 | # Like Black, respect magic trailing commas. 60 | skip-magic-trailing-comma = false 61 | 62 | # Like Black, automatically detect the appropriate line ending. 63 | line-ending = "auto" 64 | 65 | # Enable auto-formatting of code examples in docstrings. Markdown, 66 | # reStructuredText code/literal blocks and doctests are all supported. 67 | # 68 | # This is currently disabled by default, but it is planned for this 69 | # to be opt-out in the future. 70 | docstring-code-format = false 71 | 72 | # Set the line length limit used when formatting code snippets in 73 | # docstrings. 74 | # 75 | # This only has an effect when the `docstring-code-format` setting is 76 | # enabled. 77 | docstring-code-line-length = "dynamic" --------------------------------------------------------------------------------