From ebb4a45565b8d1245edf6c69a31a8a18698e41e9 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Fri, 22 Aug 2025 14:24:38 -0500 Subject: [PATCH 1/7] config class updates --- .gitignore | 2 + Kometa/.env.example | 23 - Kometa/README.md | 208 +++++++-- Kometa/clean-overlay-backup.py | 322 ++++++-------- Kometa/config.py | 131 ++++++ Kometa/extract-collections.py | 47 +- Kometa/helpers.py | 262 +++++++++-- Kometa/kometa-helpers.py | 8 +- Kometa/metadata-extractor.py | 207 ++++----- Kometa/originals-to-assets.py | 290 +++++------- Kometa/top-n-actor-coll.py | 56 +-- Plex/README.md | 754 +++++++++++++++++--------------- Plex/actor-count.py | 166 +++---- Plex/adjust-added-dates.py | 61 +-- Plex/apply-all-status.py | 18 +- Plex/build-assets-tmdb.py | 85 ++-- Plex/crew-count.py | 59 +-- Plex/delete-collections.py | 32 +- Plex/grab-all-IDs.py | 33 +- Plex/grab-all-info.py | 36 +- Plex/grab-all-posters.py | 224 +++------- Plex/grab-all-status.py | 48 +- Plex/grab-imdb-posters.py | 36 +- Plex/helpers.py | 231 ++++++++-- Plex/import-IDs.py | 14 +- Plex/list-collections.py | 25 +- Plex/list-item-ids.py | 126 +----- Plex/list-libraries.py | 31 +- Plex/list-low-poster-counts.py | 106 +---- Plex/mediascripts.sqlite.HIDDEN | Bin 28672 -> 0 bytes Plex/refresh-metadata.py | 32 +- Plex/rematch-items.py | 29 +- Plex/reset-posters-plex.py | 71 +-- Plex/reset-posters-tmdb.py | 86 ++-- Plex/reverse-genres.py | 26 +- Plex/set-user-rating.py | 14 +- Plex/show-all-playlists.py | 11 +- Plex/user-emails.py | 8 +- README.md | 25 +- config.template.yaml | 143 ++++++ 40 files changed, 2086 insertions(+), 2000 deletions(-) delete mode 100644 Kometa/.env.example create mode 100644 Kometa/config.py delete mode 100644 Plex/mediascripts.sqlite.HIDDEN create mode 100644 config.template.yaml diff --git a/.gitignore b/.gitignore index 3b3739a..7d41d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ **/*.pickle **/*.sqlite +config.yaml + Kometa/metadata-items Plex/movies diff --git a/Kometa/.env.example b/Kometa/.env.example deleted file mode 100644 index 83d0dcd..0000000 --- a/Kometa/.env.example +++ /dev/null @@ -1,23 +0,0 @@ -TMDB_KEY=TMDB_API_KEY -TVDB_KEY=TVDB_V4_API_KEY -PLEX_URL=https://plex.domain.tld -PLEX_TOKEN=PLEX-TOKEN -PLEX_OWNER=yournamehere -LIBRARY_NAMES=Movies,TV Shows,Movies 4K -CAST_DEPTH=20 -TOP_COUNT=10 -TARGET_LABELS=bing, bang, boing -REMOVE_LABELS=1 -DELAY=1 -CURRENT_POSTER_DIR=current_posters -POSTER_DIR=extracted_posters -POSTER_DEPTH=20 -POSTER_DOWNLOAD=0 -POSTER_CONSOLIDATE=0 -KOMETA_CONFIG_DIR=/opt/kometa/config # Path to Kometa config directory - -# ORIGINAL TO ASSETS -USE_ASSET_FOLDERS=1 # should those Kometa-Asset-Directory names use asset folders? -ASSET_DIR=assets # top-level directory for those Kometa-Asset-Directory images -KOMETA_CONFIG_DIR=/kometa/is/here - diff --git a/Kometa/README.md b/Kometa/README.md index d6e99f8..0c88346 100644 --- a/Kometa/README.md +++ b/Kometa/README.md @@ -6,14 +6,151 @@ Misc scripts and tools. Undocumented scripts probably do what I need them to but See the top-level [README](../README.md) for setup instructions. -All these scripts use the same `.env` and requirements. +All these scripts use the same `config.yaml` and requirements. -Any that communicate with Plex require: -``` -PLEXAPI_AUTH_SERVER_BASEURL=https://plex.domain.tld - # Just the base URL, no /web or anything at the end. - # i.e. http://192.168.1.11:32400 or the like -PLEXAPI_AUTH_SERVER_TOKEN=PLEX-TOKEN +### `config.template.yaml` contents + +```yaml +plex_api: + header_identifier: "media-scripts" + timeout: 360 + auth_server: + base_url: 'YOUR_PLEX_URL' + token: 'YOUR_PLEX_TOKEN' + log: + backup_count: 3 + format: "%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s" + level: "INFO" + path: "plexapi.log" + rotate_bytes: 512000 + show_secrets: 0 + skip_verify_ssl: 0 + +general: + tmdb_key: "TMDB_API_KEY" # https://developers.themoviedb.org/3/getting-started/introduction + tvdb_key: "TVDB_V4_API_KEY" # currently not used; https://thetvdb.com/api-information + delay: 1 # optional delay between items + library_names: Movies,TV Shows,Movies 4K # comma-separated list of libraries to act on + superchat: 0 + +kometa: + config_dir: /kometa/is/here # location of Kometa config dir as seen by this script + +image_download: + what_to_grab: + ### collection-related + include_collection_artwork: 1 # should get-all-posters retrieve collection posters? + only_collection_artwork: 0 # should get-all-posters retrieve ONLY collection posters? + only_these_collections: "Bing|Bang|Boing" # only grab artwork for these collections and items in them + + ### tv-related + seasons: 1 # should get-all-posters retrieve season posters? + episodes: 1 # should get-all-posters retrieve episode posters? [requires GRAB_SEASONS] + + ### background-related + backgrounds: 1 # should get-all-posters retrieve backgrounds? + artwork: 1 # current background is downloaded with current poster + + ### quantity-related + only_current: 0 # should get-all-posters retrieve ONLY current artwork? + poster_depth: 20 # grab this many posters [0 grabs all] [ONLY_CURRENT overrides this] + + ### what-to-keep + keep_junk: 0 # keep files that script would normally delete [incorrect filetypes, mainly] + find_overlaid_images: 0 # check all downloaded images for overlays + retain_overlaid_images: 0 # keep images that have an overlay EXIF tag [this will override the following two] + retain_kometa_overlaid_images: 0 # keep images that have the Kometa overlay EXIF tag + retain_tcm_overlaid_images: 0 # keep images that have the TCM overlay EXIF tag + + ## where-to-put-it + where_to_put_it: + use_asset_naming: 1 # should grab-all-posters name images to match Kometa's Asset Directory requirements? + use_asset_folders: 1 # should those Kometa-Asset-Directory names use asset folders? + use_asset_subfolders: 0 # create asset folders in subfolders ["Collections", "Other", or [0-9, A-Z]] ] + assets_by_libraries: 1 # should those Kometa-Asset-Directory images be sorted into library folders? + asset_dir: "assets" # top-level directory for those Kometa-Asset-Directory images + # if asset-directory naming is on, the next three are ignored + poster_dir: "extracted_posters" # put downloaded posters here + current_poster_dir: "current_posters" # put downloaded current posters and artwork here + poster_consolidate: 0 # if false, posters are separated into folders by library + + ## tracking + tracking: + track_urls: 1 # If set to 1, URLS are tracked and won't be downloaded twice + track_completion: 1 # If set to 1, movies/shows are tracked as complete by rating id + track_image_sources: 1 # keep a file containing file names and source URLs + + ## general + general: + poster_download: 1 # if false, generate a script rather than downloading + folders_only: 0 # Just build out the folder hierarchy; no image downloading + default_years_back: 2 # in absence of a "last run date", grab things added this many years back. + # 0 sets the fallback date to the beginning of time + threaded_downloads: 0 # should downloads be done in the background in threads? + reset_libraries: "Bing,Bang,Boing" # reset "last time" count to the fallback date for these libraries + reset_collections: "Bing,Bang,Boing" # CURRENTLY UNUSED + add_source_exif_comment: 1 # CURRENTLY UNUSED + +status: + plex_owner: "yournamehere" # account name of the server owner + target_plex_url: "https://plex.domain2.tld" # As above, the target of apply_all_status + target_plex_token: "PLEX-TOKEN-TWO" # As above, the target of apply_all_status + target_plex_owner: "yournamehere" # As above, the target of apply_all_status + library_map: '{"LIBRARY_ON_PLEX":"LIBRARY_ON_TARGET_PLEX", ...}' + # In apply_all_status, map libraries according to this JSON. + +reset_posters: + track_reset_status: 1 # should reset-posters-* keep track of status and pick up where it left off? + clear_reset_status: 0 + local_reset_archive: 1 # should reset-posters-tmdb keep a local archive of posters? + override_overlay_status: 0 + target_labels: this label, that label # comma-separated list of labels to reset posters on + remove_labels: 0 # attempt to remove the TARGET_LABELs from items after resetting the poster + reset_seasons: 1 # reset-posters-* resets season artwork as well in TV libraries + reset_episodes: 1 # reset-posters-* resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] + retain_reset_status_file: 0 # Don't delete the reset progress file at the end + flush_status_at_start: 0 # Delete the reset progress file at the start instead of reading it + reset_seasons_with_series: 0 # If there isn't a season poster, use the series poster + dry_run: 0 # [currently only works with reset-posters-*]; don't actually do anything, just log + +list_item_ids: + include_collection_members: 0 + only_collection_members: 0 + +delete_collection: + keep_collections: "bing,bang" # List of collections to keep + +refresh_metadata: + refresh_1970_only: 1 # If 1, only refresh things that have an originally-available date of 1970-01-01 + +rematch_items: + unmatched_only: 1 # If 1, only rematch things that are currently unmatched + +reset_added_at: + adjust_date_futures_only: 0 # Only look at items that show up as added in the future + adjust_date_epoch_only: 1 # Only adjust items that have "originally available" dates of `1970-01-01` + +actor: + cast_depth: 20 # how deep to go into the cast for actor collections + top_count: 10 # how many actors to export + job_type: "Actor" + known_for_only: 0 # ignore cast members who are not primarily known as actors + build_collections: 0 # build yaml for Kometa config.yml + num_collections: 20 # this many actors in Kometa yaml + track_gender: 1 # Pay attention to actor gender [as recorded on TMDB] + min_gender_none: 5 # include minimum this many "none" gendered actors in the YAML, if possible + min_gender_female: 5 # include minimum this many "female" gendered actors in the YAML, if possible + min_gender_male: 5 # include minimum this many "male" gendered actors in the YAML, if possible + min_gender_nb: 5 # include minimum this many "non-binary" gendered actors in the YAML, if possible + +low_poster_count: + poster_threshold: 10 # how many posters counts as a "low" count? + +crew: + depth: 20 + count: 100 + target_job: Director + show_jobs: 0 ``` ## Scripts: @@ -34,18 +171,21 @@ You've deleted stuff from Plex and want to clean up the leftover backup art that ### Settings -The script uses these settings from the `.env`: -``` -LIBRARY_NAMES=Movies,TV Shows,Movies 4K # comma-separated list of libraries to act on -DELAY=1 # optional delay between items -KOMETA_CONFIG_DIR=/opt/kometa/config/ # path to Kometa config directory +The script uses these settings from the `config.yaml`: +```yaml +general: + delay: 1 # optional delay between items + library_names: Movies,TV Shows,Movies 4K # comma-separated list of libraries to act on + +kometa: + config_dir: /kometa/is/here # location of Kometa config dir as seen by this script ``` ### Usage 1. setup as above 2. Run with `python clean-overlay-backup.py` -The script will catalog the backup files and current Plex contents for each library listed in the `.env`. +The script will catalog the backup files and current Plex contents for each library listed in the `config.yaml`. It then compares the two lists, and any files in the backup dir that do not correspond to current items in Plkex are deleted. @@ -56,7 +196,7 @@ connecting to https://plex.bing.bang... Loading Movies ... Loading movies from Movies ... Completed loading 6965 of 6965 movie(s) from Movies -Clean Overlay Backup Movies |████████████████████████████████████████| 6965/6965 [100%] in 11.0s (633.23/s) +Clean Overlay Backup Movies |████████████████████████████████████████| 6965/6965 [100%] in 11.0s (633.23/s) Processed 6965 of 6965 0 items to delete 279 items in Plex with no backup art @@ -72,6 +212,7 @@ You're getting started with Kometa and you want to export your existing collecti Here is a quick and dirty [emphasis on "quick" and "dirty"] way to do that. ### Usage + 1. setup as above 2. Run with `python extract-collections.py` @@ -110,7 +251,7 @@ Here is a basic script to do that. The script will clone or update the `Kometa-Images` repo, then iterate through it applying overlays to each image and storing them in a parallel file system rooted at `Kometa-Images-Overlaid`, ready for you to use with the Kometa Asset Directory [after moving them to that directory] or via template variables. -It chooses the overlay by name based on the "group" that each collection is part of: +It chooses the overlay by name based on the "group" that each collection is part of: ``` Kometa-Images ├── aspect @@ -143,7 +284,7 @@ If there isn't a specific image for a "group", then `Kometa/default_collection_o Fetch/Pull on Kometa-Images Using default_collection_overlays/overlay-template.png as global overlay building list of targets -Applying overlays |████████████████████████████▎ | ▇▅▃ 5027/7119 [71%] in 3:53 (21.6/s, eta: 1:37) +Applying overlays |████████████████████████████▎ | ▇▅▃ 5027/7119 [71%] in 3:53 (21.6/s, eta: 1:37) Kometa-Images/genre/Sword & Sandal.jpg ``` @@ -266,18 +407,29 @@ Note, this will only copy images that have received overlays, and for which the If you don't have overlays on any episodes, this script will not put any episode images in the asset directory, and so on. +```yaml +image_download: + where_to_put_it: + use_asset_folders: 1 # should those Kometa-Asset-Directory names use asset folders? + asset_dir: "assets" # top-level directory for those Kometa-Asset-Directory images ``` -# ORIGINAL TO ASSETS -USE_ASSET_FOLDERS=1 # should the asset directory use asset folders? -ASSET_DIR=assets # top-level directory for those assets + +The asset file system will be rooted at the directory in the `asset_dir` setting, and `use_asset_folders` controls whether the images are stored as: + +```yaml + use_asset_folders: 1 ``` -The asset file system will be rooted at the directory in the `ASSET_DIR` setting, and `USE_ASSET_FOLDERS` controls whether the images are stored as: -`USE_ASSET_FOLDERS=1` ``` Media-Scripts/Plex/assets/All That Jazz (1979) {imdb-tt0078754} {tmdb-16858}.jpg ``` -or `USE_ASSET_FOLDERS=0` + +or + +```yaml + use_asset_folders: 0 +``` + ``` Media-Scripts/Plex/assets/All That Jazz (1979) {imdb-tt0078754} {tmdb-16858}/poster.jpg ``` @@ -289,7 +441,7 @@ connecting to https://test-plex.DOMAIN.TLD... Loading Test-Movies ... Loading movies ... Completed loading 35 of 35 movie(s) from Test-Movies -Grab all posters Test-Movies |████████████████████████████████████████| 35/35 [100%] in 0.2s (190.63/s) +Grab all posters Test-Movies |████████████████████████████████████████| 35/35 [100%] in 0.2s (190.63/s) Processed 35 of 35 Complete! ``` @@ -370,10 +522,12 @@ For each movie, gets the cast from TMDB; keeps track across all movies how many At the end, builds a basic Kometa metadata file for the top N actors. -Script-specific variables in .env: -``` -CAST_DEPTH=20 ### HOW DEEP TO GO INTO EACH MOVIE CAST -TOP_COUNT=10 ### PUT THIS MANY INTO THE FILE AT THE END +Script-specific variables in `config.yaml`: + +```yaml +actor: + cast_depth: 20 # how deep to go into the cast for actor collections + top_count: 10 # how many actors to export ``` `CAST_DEPTH` is meant to prevent some journeyman character actor from showing up in the top ten; I'm thinking of someone like Clint Howard who's been in the cast of many movies, but I'm guessing when you think of the top ten actors in your library you're not thinking about Clint. Maybe you are, though, in which case set that higher. diff --git a/Kometa/clean-overlay-backup.py b/Kometa/clean-overlay-backup.py index db44964..7a90b66 100644 --- a/Kometa/clean-overlay-backup.py +++ b/Kometa/clean-overlay-backup.py @@ -1,21 +1,20 @@ #!/usr/bin/env python -import os from datetime import datetime from os import listdir from os.path import isfile, join from pathlib import Path from alive_progress import alive_bar -from helpers import get_all_from_library, get_plex, load_and_upgrade_env +from config import Config +from helpers import (get_all_from_library, get_plex, get_redaction_list, + get_target_libraries) from logs import blogger, logger, plogger, setup_logger SCRIPT_NAME = Path(__file__).stem VERSION = "0.1.0" -env_file_path = Path(".env") - # current dateTime now = datetime.now() @@ -28,71 +27,21 @@ plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() - -PLEX_URL = ( - os.getenv("PLEX_URL") - if os.getenv("PLEX_URL") - else os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") -) -PLEX_TOKEN = ( - os.getenv("PLEX_TOKEN") - if os.getenv("PLEX_TOKEN") - else os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") -) - -if PLEX_URL.endswith("/"): - PLEX_URL = PLEX_URL[:-1] - -if PLEX_URL is None or PLEX_URL == "https://plex.domain.tld": - plogger("You must specify PLEX URL in the .env file.", "info", "a") - exit() - -if PLEX_TOKEN is None or PLEX_TOKEN == "PLEX-TOKEN": - plogger("You must specify PLEX TOKEN in the .env file.", "info", "a") - exit() - -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") +config = Config('../config.yaml') -KOMETA_CONFIG_DIR = os.getenv("KOMETA_CONFIG_DIR") +KOMETA_CONFIG_DIR = config.get("kometa.config_dir") if KOMETA_CONFIG_DIR is None: - plogger("You must specify KOMETA_CONFIG_DIR in the .env file.", "info", "a") + plogger("You must specify kometa.config_dir in the config.yaml.", "info", "a") exit() -DELAY = int(os.getenv("DELAY")) - -if not DELAY: - DELAY = 0 +DELAY = config.get_int('general.delay', 0) -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] - -redaction_list = [] -redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_BASEURL")) -redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_TOKEN")) +redaction_list = get_redaction_list() plex = get_plex() -logger("Plex connection succeeded", "info", "a") - -ALL_LIBS = plex.library.sections() -ALL_LIB_NAMES = [] - -logger(f"{len(ALL_LIBS)} libraries found:", "info", "a") -for lib in ALL_LIBS: - logger(f"{lib.title.strip()}: {lib.type}", "info", "a") - ALL_LIB_NAMES.append(f"{lib.title.strip()}") - -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - for lib in ALL_LIBS: - LIB_ARRAY.append(lib.title.strip()) - +LIB_ARRAY = get_target_libraries(plex) def get_SE_str(item): if item.TYPE == "season": @@ -119,162 +68,155 @@ def get_progress_string(item): for lib in LIB_ARRAY: - if lib in ALL_LIB_NAMES: - try: - highwater = 0 + try: + highwater = 0 - LIBRARY_BACKUP = f"{KOMETA_CONFIG_DIR}overlays/{lib} Original Posters" + LIBRARY_BACKUP = f"{KOMETA_CONFIG_DIR}overlays/{lib} Original Posters" - all_backup_files = [ - f for f in listdir(LIBRARY_BACKUP) if isfile(join(LIBRARY_BACKUP, f)) - ] - backup_dict = {} - missing_dict = {} + all_backup_files = [ + f for f in listdir(LIBRARY_BACKUP) if isfile(join(LIBRARY_BACKUP, f)) + ] + backup_dict = {} + missing_dict = {} - for f in all_backup_files: - rk = f.split(".")[0] - ext = f.split(".")[1] - backup_dict[rk] = f"{LIBRARY_BACKUP}/{rk}.{ext}" + for f in all_backup_files: + rk = f.split(".")[0] + ext = f.split(".")[1] + backup_dict[rk] = f"{LIBRARY_BACKUP}/{rk}.{ext}" - plogger( - f"{len(backup_dict)} images in the {lib} overlay backup directory ...", - "info", - "a", - ) + plogger( + f"{len(backup_dict)} images in the {lib} overlay backup directory ...", + "info", + "a", + ) - plogger(f"Loading {lib} ...", "info", "a") - the_lib = plex.library.section(lib) - the_uuid = the_lib.uuid + plogger(f"Loading {lib} ...", "info", "a") + the_lib = plex.library.section(lib) + the_uuid = the_lib.uuid - ID_ARRAY = [] - the_title = the_lib.title + ID_ARRAY = [] + the_title = the_lib.title - plogger(f"Loading {the_lib.TYPE}s from {lib} ...", "info", "a") - item_count, items = get_all_from_library(the_lib, None, None) + plogger(f"Loading {the_lib.TYPE}s from {lib} ...", "info", "a") + item_count, items = get_all_from_library(the_lib, None, None) + + plogger( + f"Completed loading {len(items)} of {item_count} {the_lib.TYPE}(s) from {the_lib.title}", + "info", + "a", + ) + + if the_lib.TYPE == "show": + plogger(f"Loading seasons from {lib} ...", "info", "a") + season_count, seasons = get_all_from_library(the_lib, "season", None) plogger( - f"Completed loading {len(items)} of {item_count} {the_lib.TYPE}(s) from {the_lib.title}", + f"Completed loading {len(seasons)} of {season_count} season(s) from {the_lib.title}", "info", "a", ) + items.extend(seasons) - if the_lib.TYPE == "show": - plogger(f"Loading seasons from {lib} ...", "info", "a") - season_count, seasons = get_all_from_library(the_lib, "season", None) - - plogger( - f"Completed loading {len(seasons)} of {season_count} season(s) from {the_lib.title}", - "info", - "a", - ) - items.extend(seasons) + plogger(f"Loading episodes from {lib} ...", "info", "a") + episode_count, episodes = get_all_from_library(the_lib, "episode", None) - plogger(f"Loading episodes from {lib} ...", "info", "a") - episode_count, episodes = get_all_from_library(the_lib, "episode", None) - - plogger( - f"Completed loading {len(episodes)} of {episode_count} episode(s) from {the_lib.title}", - "info", - "a", - ) - items.extend(episodes) - - item_total = len(items) - if item_total > 0: - logger(f"looping over {item_total} items...", "info", "a") - item_count = 0 - - with alive_bar( - item_total, - dual_line=True, - title=f"Clean Overlay Backup {the_lib.title}", - ) as bar: - for item in items: - try: - rk = f"{item.ratingKey}" + plogger( + f"Completed loading {len(episodes)} of {episode_count} episode(s) from {the_lib.title}", + "info", + "a", + ) + items.extend(episodes) + + item_total = len(items) + if item_total > 0: + logger(f"looping over {item_total} items...", "info", "a") + item_count = 0 + + with alive_bar( + item_total, + dual_line=True, + title=f"Clean Overlay Backup {the_lib.title}", + ) as bar: + for item in items: + try: + rk = f"{item.ratingKey}" + blogger( + f"Processing {item.title}; rating key {rk}", + "info", + "a", + bar, + ) + if rk in backup_dict.keys(): + blogger(f"Rating key {rk} found", "info", "a", bar) + backup_dict.pop(rk) + else: + missing_dict[rk] = f"{item.title}" blogger( - f"Processing {item.title}; rating key {rk}", + f"{item.title}; rating key {rk} has no backup art", "info", "a", bar, ) - if rk in backup_dict.keys(): - blogger(f"Rating key {rk} found", "info", "a", bar) - backup_dict.pop(rk) - else: - missing_dict[rk] = f"{item.title}" - blogger( - f"{item.title}; rating key {rk} has no backup art", - "info", - "a", - bar, - ) - - item_count += 1 - except Exception as ex: - plogger( - f"Problem processing {item.title}; {ex}", "info", "a" - ) - - bar() - plogger(f"Processed {item_count} of {item_total}", "info", "a") - - plogger(f"{len(backup_dict)} items to delete", "info", "a") + item_count += 1 + except Exception as ex: + plogger( + f"Problem processing {item.title}; {ex}", "info", "a" + ) + + bar() + + plogger(f"Processed {item_count} of {item_total}", "info", "a") + + plogger(f"{len(backup_dict)} items to delete", "info", "a") + + if len(backup_dict) > 0: + delete_list = [] + with alive_bar( + item_total, + dual_line=True, + title=f"Clean Overlay Backup {the_lib.title}", + ) as bar: + for rk in backup_dict: + target_file = backup_dict[rk] + p = Path(target_file) + blogger(f"Deleting {target_file}", "info", "a", bar) + try: + p.unlink() + delete_list.append(rk) + except Exception as ex: + plogger( + f"Problem deleting {target_file}; {ex}", "info", "a" + ) + + for rk in delete_list: + backup_dict.pop(rk) if len(backup_dict) > 0: - delete_list = [] - with alive_bar( - item_total, - dual_line=True, - title=f"Clean Overlay Backup {the_lib.title}", - ) as bar: - for rk in backup_dict: - target_file = backup_dict[rk] - p = Path(target_file) - blogger(f"Deleting {target_file}", "info", "a", bar) - try: - p.unlink() - delete_list.append(rk) - except Exception as ex: - plogger( - f"Problem deleting {target_file}; {ex}", "info", "a" - ) - - for rk in delete_list: - backup_dict.pop(rk) - - if len(backup_dict) > 0: - plogger( - f"{len(backup_dict)} items could not be deleted", "info", "a" - ) - - plogger( - f"{len(missing_dict)} items in Plex with no backup art", "info", "a" - ) - plogger( - "These might be items added to Plex since the last overlay run", - "info", - "a", - ) - plogger( - "They might be items that are not intended to have overlays", - "info", - "a", - ) - - progress_str = "COMPLETE" - logger(progress_str, "info", "a") + plogger( + f"{len(backup_dict)} items could not be deleted", "info", "a" + ) - except Exception as ex: - progress_str = f"Problem processing {lib}; {ex}" - plogger(progress_str, "info", "a") - else: - logger( - f"Library {lib} not found: available libraries on this server are: {ALL_LIB_NAMES}", + plogger( + f"{len(missing_dict)} items in Plex with no backup art", "info", "a" + ) + plogger( + "These might be items added to Plex since the last overlay run", "info", "a", ) + plogger( + "They might be items that are not intended to have overlays", + "info", + "a", + ) + + progress_str = "COMPLETE" + logger(progress_str, "info", "a") + + except Exception as ex: + progress_str = f"Problem processing {lib}; {ex}" + plogger(progress_str, "info", "a") plogger("Complete!", "info", "a") diff --git a/Kometa/config.py b/Kometa/config.py new file mode 100644 index 0000000..03e750e --- /dev/null +++ b/Kometa/config.py @@ -0,0 +1,131 @@ +import os + +import yaml + + +class Config: + """ + A class to handle configuration settings loaded from a YAML file. + """ + _instance = None + + def __new__(cls, config_path="config.yaml"): + if cls._instance is None: + cls._instance = super(Config, cls).__new__(cls) + cls._instance._initialize(config_path) + return cls._instance + + def _initialize(self, config_path): + if not os.path.exists(config_path): + raise FileNotFoundError(f"Configuration file not found at {config_path}") + + try: + with open(config_path, 'r') as file: + self._settings = yaml.safe_load(file) + except yaml.YAMLError as e: + raise ValueError(f"Error parsing YAML file: {e}") + + def __getattr__(self, name): + """ + Allows accessing settings like attributes (e.g., config.database.host). + """ + if name in self._settings: + value = self._settings[name] + if isinstance(value, dict): + # Recursively wrap nested dictionaries + return _DictWrapper(value) + return value + + # Fallback to the default __getattr__ behavior + return super().__getattr__(name) + + def get(self, key, default=None): + """ + Provides a safe way to get a value with an optional default, + similar to dictionary's .get() method. + """ + keys = key.split('.') + current_dict = self._settings + for k in keys: + if isinstance(current_dict, dict) and k in current_dict: + current_dict = current_dict[k] + else: + return default + return current_dict + + def get_int(self, key, default=0): + """ + Provides a safe way to get a value with an optional default, + similar to dictionary's .get() method. + """ + keys = key.split('.') + current_dict = self._settings + for k in keys: + if isinstance(current_dict, dict) and k in current_dict: + current_dict = current_dict[k] + else: + return default + return current_dict + + def get_bool(self, key, default=False): + """ + Provides a safe way to get a value with an optional default, + similar to dictionary's .get() method. + """ + keys = key.split('.') + current_dict = self._settings + for k in keys: + if isinstance(current_dict, dict) and k in current_dict: + current_dict = current_dict[k] + else: + return default + if type(current_dict) is str: + current_dict = eval(current_dict) + return bool(current_dict) + + +class _DictWrapper: + """ + Helper class to enable attribute-style access for nested dictionaries. + """ + def __init__(self, data): + self._data = data + + def __getattr__(self, name): + if name in self._data: + value = self._data[name] + if isinstance(value, dict): + return _DictWrapper(value) + return value + raise AttributeError(f"'{self.__class__.__name__}' has no attribute '{name}'") + +# Example Usage: +if __name__ == "__main__": + # Create a dummy config.yaml file for the example + sample_config_content = """ + tvdb: + apikey: "bed9264b-82e9-486b-af01-1bb201bcb595" # Enter TMDb API Key (REQUIRED) + + omdb: + apikey: "9e62df51" # Enter OMDb API Key (Optional) + """ + with open("config.yaml", "w") as f: + f.write(sample_config_content) + + try: + config = Config() + + print("--- Attribute Access ---") + print(f"tvdb key: {config.tvdb.apikey}") + print(f"omdb key: {config.omdb.apikey}") + + print("\n--- 'get' Method Access ---") + print(f"tvdb key: {config.get('tvdb.apikey')}") + print(f"Default Value Test: {config.get('omdb.sproing', 'default_value')}") + + except (FileNotFoundError, ValueError) as e: + print(f"An error occurred: {e}") + finally: + # Clean up the dummy file + if os.path.exists("config.yaml"): + os.remove("config.yaml") \ No newline at end of file diff --git a/Kometa/extract-collections.py b/Kometa/extract-collections.py index 2982295..eece7a1 100644 --- a/Kometa/extract-collections.py +++ b/Kometa/extract-collections.py @@ -1,11 +1,11 @@ -import os import platform import re from datetime import datetime from pathlib import Path from alive_progress import alive_bar -from helpers import get_plex, load_and_upgrade_env +from config import Config +from helpers import get_plex, get_redaction_list, get_target_libraries from logs import plogger, setup_logger from plexapi.utils import download from ruamel import yaml @@ -18,8 +18,6 @@ VERSION = "0.0.5" -env_file_path = Path(".env") - # current dateTime now = datetime.now() @@ -34,46 +32,21 @@ plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() - -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -# TMDB_KEY = os.getenv("TMDB_KEY") -# TVDB_KEY = os.getenv("TVDB_KEY") -# CAST_DEPTH = int(os.getenv("CAST_DEPTH")) -# TOP_COUNT = int(os.getenv("TOP_COUNT")) -DELAY = int(os.getenv("DELAY")) - -if not DELAY: - DELAY = 0 - -PLEX_URL = ( - os.getenv("PLEX_URL") - if os.getenv("PLEX_URL") - else os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") -) -PLEX_TOKEN = ( - os.getenv("PLEX_TOKEN") - if os.getenv("PLEX_TOKEN") - else os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") -) +config = Config('../config.yaml') -if PLEX_URL.endswith("/"): - PLEX_URL = PLEX_URL[:-1] - - -if LIBRARY_NAMES: - lib_array = LIBRARY_NAMES.split(",") -else: - lib_array = [LIBRARY_NAME] +DELAY = config.get_int('general.delay', 0) artwork_dir = "artwork" background_dir = "background" config_dir = "config" +PLEX_URL = config.get("plex_api.auth_server.base_url") +PLEX_TOKEN = config.get("plex_api.auth_server.token") + plex = get_plex() +LIB_ARRAY = get_target_libraries(plex) + coll_obj = {} coll_obj["collections"] = {} @@ -83,7 +56,7 @@ def get_sort_text(argument): return switcher.get(argument, "invalid-sort") -for lib in lib_array: +for lib in LIB_ARRAY: lib = lib.lstrip() safe_lib = re.sub(r"[/\\?%*:|\"<>\x7F\x00-\x1F]", "-", lib) print(f"Processing: [{lib}] | safe: [{safe_lib}]") diff --git a/Kometa/helpers.py b/Kometa/helpers.py index 582a407..0166a23 100644 --- a/Kometa/helpers.py +++ b/Kometa/helpers.py @@ -1,16 +1,70 @@ +import getpass +import hashlib import itertools +import json import os import shutil from pathlib import Path import plexapi import requests +from config import Config from dotenv import load_dotenv, set_key, unset_key from pathvalidate import is_valid_filename, sanitize_filename from PIL import Image from plexapi.exceptions import Unauthorized +from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer +# Your fixed client identifier +CLIENT_IDENTIFIER = 'MediaScripts-chazlarson' +# File to store token + server URL +AUTH_FILE = '.plex_auth.json' +# Default network timeout (seconds) +DEFAULT_TIMEOUT = 360 + +stock_md5 = { + "plexapi.config.ini": '6209bb0c2ab877e6b74f757a004c84c9', +} + +def file_has_changed(filepath): + """ + Calculates the MD5 checksum of a file. + + Args: + filepath: The path to the file. + + Returns: + The MD5 checksum as a hexadecimal string. + """ + if filepath.name not in stock_md5: + print(f"File {filepath.name} not in stock_md5, returning True") + return True + old_hash = stock_md5.get(filepath.name) + md5_hash = hashlib.md5() + with open(filepath, "rb") as file: + # Read the file in chunks to handle large files efficiently + for chunk in iter(lambda: file.read(4096), b""): + md5_hash.update(chunk) + new_hash = md5_hash.hexdigest() + return new_hash != old_hash + + +def copy_file(source_path, destination_path): + """Copies a file from source to destination using pathlib. + + Args: + source_path (str or Path): Path to the source file. + destination_path (str or Path): Path to the destination file. + """ + source_path = Path(source_path) + destination_path = Path(destination_path) + + if source_path.is_file(): + shutil.copy(source_path, destination_path) + print(f"File copied from {source_path} to {destination_path}") + else: + print(f"Source path {source_path} is not a file.") def has_overlay(image_path): kometa_overlay = False @@ -49,30 +103,161 @@ def redact(the_url, str_list): return ret_val -def get_plex(user_token=None): - print(f"connecting to {os.getenv('PLEXAPI_AUTH_SERVER_BASEURL')}...") +def load_auth(): + """Return saved auth dict or None.""" + try: + with open(AUTH_FILE, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None + + +def save_auth(data): + """Save auth dict and lock down file permissions.""" + with open(AUTH_FILE, 'w') as f: + json.dump(data, f) + try: + os.chmod(AUTH_FILE, 0o600) + except Exception: + pass + +def choose_server(servers): + """Prompt the user to choose one of the available Plex Media Server resources.""" + print("\nAvailable Plex Media Servers:") + for idx, res in enumerate(servers, start=1): + print(f" [{idx}] {res.name} ({res.clientIdentifier})") + while True: + choice = input(f"Select server [1–{len(servers)}]: ").strip() + if choice.isdigit(): + idx = int(choice) + if 1 <= idx <= len(servers): + return servers[idx-1] + print("❌ Invalid selection; please enter a number from the list.") + +def get_timeout(): + """Prompt user for network timeout value, with a safe default.""" + val = input(f"Network timeout in seconds [default {DEFAULT_TIMEOUT}]: ").strip() + if not val: + return DEFAULT_TIMEOUT + try: + t = float(val) + if t <= 0: + raise ValueError() + return t + except ValueError: + print(f"⚠️ Invalid timeout '{val}', using default {DEFAULT_TIMEOUT}.") + return DEFAULT_TIMEOUT + +def get_skip_ssl(): + """Prompt user whether to skip SSL certificate verification.""" + val = input("Skip SSL certificate verification? [y/N]: ").strip().lower() + return val in ('y', 'yes') + +def make_session(skip_ssl): + """Return a requests.Session configured for SSL verification or not.""" + if skip_ssl: + sess = requests.Session() + sess.verify = False + return sess + return None + +def do_login(timeout, session): + """Prompt for user/pass, let user pick server, connect & return PlexServer.""" + username = input('Plex Username: ') + password = getpass.getpass('Plex Password: ') + account = MyPlexAccount(username, password) + print(f"✔ Logged in as {account.username}") + + servers = [r for r in account.resources() if r.product == 'Plex Media Server'] + if not servers: + raise RuntimeError("No Plex Media Server found on your account.") + + resource = choose_server(servers) + print(f"→ Connecting to server: {resource.name} (timeout={timeout}s)") + + # resource.connect accepts a `session` and `timeout` argument + plex = resource.connect(timeout=timeout, session=session) + print(f"✔ Connected to Plex server: {plex.friendlyName}") + + token = getattr(account, 'authenticationToken', None) or getattr(account, '_token') + baseurl = getattr(plex, 'baseurl', None) or getattr(plex, '_baseurl') + save_auth({'token': token, 'baseurl': baseurl}) + print(f"⚑ Saved auth to {AUTH_FILE}") + return plex + + +def get_plex(): plex = None + config = Config('../config.yaml') + os.environ['PLEXAPI_HEADER_IDENTIFIER'] = f"{config.get('plex_api.header_identifier')}" + os.environ['PLEXAPI_PLEXAPI_TIMEOUT'] = f"{config.get('plex_api.timeout')}" + os.environ['PLEXAPI_AUTH_SERVER_BASEURL'] = f"{config.get('plex_api.auth_server.base_url')}" + os.environ['PLEXAPI_AUTH_SERVER_TOKEN'] = f"{config.get('plex_api.auth_server.token')}" + os.environ['PLEXAPI_LOG_BACKUP_COUNT'] = f"{config.get('plex_api.log.backup_count')}" + os.environ['PLEXAPI_LOG_FORMAT'] = f"{config.get('plex_api.log.format')}" + os.environ['PLEXAPI_LOG_LEVEL'] = f"{config.get('plex_api.log.level')}" + os.environ['PLEXAPI_LOG_PATH'] = f"{config.get('plex_api.log.path')}" + os.environ['PLEXAPI_LOG_ROTATE_BYTES'] = f"{config.get('plex_api.log.rotate_bytes')}" + os.environ['PLEXAPI_LOG_SHOW_SECRETS'] = f"{config.get('plex_api.log.show_secrets')}" + os.environ['PLEXAPI_SKIP_VERIFYSSL'] = f"{config.get('plex_api.skip_verify_ssl')}" # ignore self signed certificate errors + try: - if user_token is not None: - plex = PlexServer(token=user_token) - else: - plex = PlexServer() - except Unauthorized: - print("Plex Error: Plex token is invalid") - raise Unauthorized + print("creating plex with plexapi config") + plex = PlexServer() + print(f"connected to {plex.friendlyName}") except Exception as ex: - print(f"Plex Error: {ex.args}") - raise ex + print(f"plexapi config failed: {ex}") + auth = load_auth() + if auth: + try: + print("creating plex with saved auth") + plex = PlexServer(auth['url'], token=auth['token']) + print(f"connected to {plex.friendlyName}") + except Unauthorized: + print("Saved auth is invalid. Please re-authenticate.") + auth = None + else: + print("No saved auth found. Please authenticate.") + timeout = get_timeout() + skip_ssl = get_skip_ssl() + session = make_session(skip_ssl) + plex = do_login(timeout, session) return plex +def get_target_libraries(plex): + if plex: + ALL_LIBS = plex.library.sections() + else: + print(f"Plex connection failed") + return None + + print(f"{len(ALL_LIBS)} libraries found") + + config = Config() + + LIBRARY_NAMES = config.get("general.library_names") + + if LIBRARY_NAMES and len(LIBRARY_NAMES) > 0: + LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] + else: + LIB_ARRAY = None + print(f"No libraries specified in config") + print(f"Processing all {len(ALL_LIBS)} libraries") + + if LIB_ARRAY is None: + LIB_ARRAY = [] + for lib in ALL_LIBS: + LIB_ARRAY.append(f"{lib.title.strip()}") + + return LIB_ARRAY imdb_str = "imdb://" tmdb_str = "tmdb://" tvdb_str = "tvdb://" -def get_ids(theList, TMDB_KEY): +def get_ids(theList): imdbid = None tmid = None tvid = None @@ -87,12 +272,6 @@ def get_ids(theList, TMDB_KEY): return imdbid, tmid, tvid -# def imdb_from_tmdb(tmdb_id, TMDB_KEY): -# tmdb = TMDbAPIs(TMDB_KEY, language="en") - -# https://api.themoviedb.org/3/movie/{movie_id}/external_ids?api_key=<> - - def validate_filename(filename): # return filename if is_valid_filename(filename): @@ -254,7 +433,6 @@ def get_all_from_library(the_lib, tgt_class=None, filter=None): lib_size = get_size(the_lib, tgt_class, filter) - # key = f"/library/sections/{the_lib.key}/all?includeGuids=1&type={utils.searchType(the_lib.type)}" c_start = 0 c_size = 500 results = [] @@ -431,14 +609,14 @@ def load_and_upgrade_env(file_path): status = 0 if os.path.exists(file_path): - load_dotenv(dotenv_path=file_path) + load_dotenv(dotenv_path=file_path, override=True) else: print("No environment [.env] file. Creating base file.") if os.path.exists(".env.example"): src_file = os.path.join(".", ".env.example") tgt_file = os.path.join(".", ".env") shutil.copyfile(src_file, tgt_file) - print("Please edit .env file to suit and rerun script.") + print("Please edit config.yaml to suit and rerun script.") else: print("No example [.env.example] file. Cannot create base file.") status = -1 @@ -514,14 +692,48 @@ def load_and_upgrade_env(file_path): os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") is None or os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") == "https://plex.domain.tld" ): - print("You must specify PLEXAPI_AUTH_SERVER_BASEURL in the .env file.") - status = -1 + print("You must specify PLEXAPI_AUTH_SERVER_BASEURL in the config.yaml.") + # status = -1 if ( os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") is None or os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") == "PLEX-TOKEN" ): - print("You must specify PLEXAPI_AUTH_SERVER_TOKEN in the .env file.") - status = -1 + print("You must specify PLEXAPI_AUTH_SERVER_TOKEN in the config.yaml.") + # status = -1 return status + + +def check_for_images(file_path): + jpg_path = file_path.replace(".dat", ".jpg") + png_path = file_path.replace(".dat", ".png") + + dat_file = Path(file_path) + jpg_file = Path(jpg_path) + png_file = Path(png_path) + + dat_here = dat_file.is_file() + jpg_here = jpg_file.is_file() + png_here = png_file.is_file() + + if dat_here: + os.remove(file_path) + + if jpg_here and png_here: + os.remove(jpg_path) + + os.remove(png_path) + + if jpg_here or png_here: + return True + + return False + +def get_redaction_list(): + config = Config() + redaction_list = [] + redaction_list.append(config.get("plex_api.auth_server.base_url")) + redaction_list.append(config.get("plex_api.auth_server.token")) + + return redaction_list diff --git a/Kometa/kometa-helpers.py b/Kometa/kometa-helpers.py index 1a7281b..e274f60 100644 --- a/Kometa/kometa-helpers.py +++ b/Kometa/kometa-helpers.py @@ -74,7 +74,7 @@ def get_ids(theList, TMDB_KEY): # def imdb_from_tmdb(tmdb_id, TMDB_KEY): -# tmdb = TMDbAPIs(TMDB_KEY, language="en") +# tmdb = TMDbAPIs(str(config.get("general.tmdb_key", "NO_KEY_SPECIFIED")), language="en") # https://api.themoviedb.org/3/movie/{movie_id}/external_ids?api_key=<> @@ -431,7 +431,7 @@ def load_and_upgrade_env(file_path): src_file = os.path.join(".", ".env.example") tgt_file = os.path.join(".", ".env") shutil.copyfile(src_file, tgt_file) - print("Please edit .env file to suit and rerun script.") + print("Please edit config.yaml to suit and rerun script.") else: print("No example [.env.example] file. Cannot create base file.") status = -1 @@ -508,7 +508,7 @@ def load_and_upgrade_env(file_path): or os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") == "https://plex.domain.tld" ): print( - "You must specify PLEXAPI_AUTH_SERVER_BASEURL in the .env file.", + "You must specify PLEXAPI_AUTH_SERVER_BASEURL in the config.yaml.", "info", "a", ) @@ -519,7 +519,7 @@ def load_and_upgrade_env(file_path): or os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") == "PLEX-TOKEN" ): print( - "You must specify PLEXAPI_AUTH_SERVER_TOKEN in the .env file.", "info", "a" + "You must specify PLEXAPI_AUTH_SERVER_TOKEN in the config.yaml.", "info", "a" ) status = -1 diff --git a/Kometa/metadata-extractor.py b/Kometa/metadata-extractor.py index de7b5bd..877c647 100644 --- a/Kometa/metadata-extractor.py +++ b/Kometa/metadata-extractor.py @@ -1,5 +1,6 @@ #!/usr/bin/env python -import logging + + import os import sys import textwrap @@ -9,8 +10,9 @@ import yaml from alive_progress import alive_bar -from helpers import get_ids, get_plex, load_and_upgrade_env -from logs import blogger, logger, plogger, setup_logger +from config import Config +from helpers import get_ids, get_plex, get_redaction_list, get_target_libraries +from logs import blogger, plogger from plexapi.utils import download from tmdbapis import TMDbAPIs @@ -40,77 +42,34 @@ ACTIVITY_LOG = f"{SCRIPT_NAME}.log" -setup_logger("activity_log", ACTIVITY_LOG) - -env_file_path = Path(".env") +# setup_logger("activity_log", ACTIVITY_LOG) -logging.basicConfig( - filename=f"{SCRIPT_NAME}.log", - filemode="w", - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - level=logging.INFO, -) +# logging.basicConfig( +# filename=f"{SCRIPT_NAME}.log", +# filemode="w", +# format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +# level=logging.INFO, +# ) -logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") +# logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") print(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") -if load_and_upgrade_env(env_file_path) < 0: - exit() - - -def lib_type_supported(lib): - return lib.type == "movie" or lib.type == "show" - +config = Config('../config.yaml') plex = get_plex() -LIBRARY_NAME = os.getenv("LIBRARY_NAME") - -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") +LIB_ARRAY = get_target_libraries(plex) CURRENT_LIBRARY = "" -if LIBRARY_NAMES: - LIB_ARRAY = [] - LIB_LIST = LIBRARY_NAMES.split(",") - for s in LIB_LIST: - LIB_ARRAY.append(s.strip()) -else: - LIB_ARRAY = [LIBRARY_NAME] - -ALL_LIBS = plex.library.sections() -ALL_LIB_NAMES = [] - -logger(f"{len(ALL_LIBS)} libraries found:", "info", "a") -for lib in ALL_LIBS: - logger( - f"{lib.title.strip()}: {lib.type} - supported: {lib_type_supported(lib)}", - "info", - "a", - ) - ALL_LIB_NAMES.append(f"{lib.title.strip()}") - -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - for lib in ALL_LIBS: - if lib_type_supported(lib): - LIB_ARRAY.append(lib.title.strip()) - -TMDB_KEY = os.getenv("TMDB_KEY") -TVDB_KEY = os.getenv("TVDB_KEY") -REMOVE_LABELS = os.getenv("REMOVE_LABELS") -PLEXAPI_AUTH_SERVER_TOKEN = os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") - +REMOVE_LABELS = config.get_bool('reset_posters.remove_labels', False) if REMOVE_LABELS: lbl_array = REMOVE_LABELS.split(",") -# Commented out until this doesn't throw a 400 -# tvdb = tvdb_v4_official.TVDB(TVDB_KEY) +PLEXAPI_AUTH_SERVER_TOKEN = config.get("plex_api.auth_server.token") -tmdb = TMDbAPIs(TMDB_KEY, language="en") -tmdb_str = "tmdb://" -tvdb_str = "tvdb://" +tmdb = TMDbAPIs(str(config.get("general.tmdb_key", "NO_KEY_SPECIFIED")), language="en") local_dir = f"{os.getcwd()}/posters" @@ -142,7 +101,7 @@ def progress(count, total, status=""): def get_movie_match(item): - imdb_id, tmdb_id, tvdb_id = get_ids(item.guids, TMDB_KEY) + imdb_id, tmdb_id, tvdb_id = get_ids(item.guids) mapping_id = imdb_id if imdb_id is not None else tmdb_id tmpDict = {"mapping_id": mapping_id} @@ -166,7 +125,7 @@ def get_movie_match(item): def get_show_match(item): - imdb_id, tmdb_id, tvdb_id = get_ids(item.guids, TMDB_KEY) + imdb_id, tmdb_id, tvdb_id = get_ids(item.guids) mapping_id = imdb_id if imdb_id is not None else tvdb_id tmpDict = {"mapping_id": mapping_id} @@ -208,7 +167,7 @@ def doDownload(url, savefile, savepath): def getTheme(item): mp3Path = "TODO" - imdb_id, tmdb_id, tvdb_id = get_ids(item.guids, TMDB_KEY) + imdb_id, tmdb_id, tvdb_id = get_ids(item.guids) base_path = getDownloadBasePath() if imdb_id is not None: @@ -231,7 +190,7 @@ def getTheme(item): def getPoster(item): imgPath = "TODO" - imdb_id, tmdb_id, tvdb_id = get_ids(item.guids, TMDB_KEY) + imdb_id, tmdb_id, tvdb_id = get_ids(item.guids) base_path = getDownloadBasePath() if imdb_id is not None: @@ -245,7 +204,12 @@ def getPoster(item): Path(img_path).mkdir(parents=True, exist_ok=True) - imgPath = doDownload(item.posterUrl, "poster.jpg", img_path) + try: + imgPath = doDownload(item.posterUrl, "poster.jpg", img_path) + except: + print(f"something happened while downloading {img_path + '/poster.jpg'}") + file = Path(f"{img_path}/poster.jpg") + print(f"{file} exists: {file.exists()}") # posterUrl = 'http://192.168.1.11:32400/library/metadata/1262/thumb/1723823327?X-Plex-Token=3rCte1jyCczPrzsAokwR' # thumbUrl = 'http://192.168.1.11:32400/library/metadata/1262/thumb/1723823327?X-Plex-Token=3rCte1jyCczPrzsAokwR' @@ -255,7 +219,7 @@ def getPoster(item): def getBackground(item): imgPath = "TODO" - imdb_id, tmdb_id, tvdb_id = get_ids(item.guids, TMDB_KEY) + imdb_id, tmdb_id, tvdb_id = get_ids(item.guids) base_path = getDownloadBasePath() if imdb_id is not None: @@ -269,7 +233,13 @@ def getBackground(item): Path(img_path).mkdir(parents=True, exist_ok=True) - imgPath = doDownload(item.artUrl, "background.jpg", img_path) + try: + imgPath = doDownload(item.artUrl, "background.jpg", img_path) + except: + print(f"something happened while downloading {img_path + '/background.jpg'}") + file = Path(f"{img_path}/background.jpg") + print(f"{file} exists: {file.exists()}") + return imgPath @@ -513,76 +483,75 @@ def get_episode_info(episode): item_count = 1 for lib in LIB_ARRAY: - if lib in ALL_LIB_NAMES: - CURRENT_LIBRARY = lib - try: - print(f"getting items from [{lib}]...") - items = plex.library.section(lib).all() - item_total = len(items) - print(f"looping over {item_total} items...") - metadataDict = {"metadata": {}} - with alive_bar( - item_total, dual_line=True, title=f"Extracting metadata from {lib}" - ) as bar: - for item in items: - item_count = item_count + 1 - try: - itemKey = f"{item.title} ({item.year})" - blogger(f"Starting {itemKey}", "info", "a", bar) + CURRENT_LIBRARY = lib + try: + print(f"getting items from [{lib}]...") + items = plex.library.section(lib).all() + item_total = len(items) + print(f"looping over {item_total} items...") + metadataDict = {"metadata": {}} + with alive_bar( + item_total, dual_line=True, title=f"Extracting metadata from {lib}" + ) as bar: + for item in items: + item_count = item_count + 1 + try: + itemKey = f"{item.title} ({item.year})" + blogger(f"Starting {itemKey}", "info", "a", bar) + itemDict = get_common_video_info(item) + + itemDict = None + if item.TYPE == "show": itemDict = get_common_video_info(item) + # loop through seasons and then episodes + all_seasons_dict = {} + show_seasons = item.seasons() - itemDict = None - if item.TYPE == "show": - itemDict = get_common_video_info(item) - # loop through seasons and then episodes - all_seasons_dict = {} - show_seasons = item.seasons() + for season in show_seasons: + seasonNumber = season.seasonNumber - for season in show_seasons: - seasonNumber = season.seasonNumber + this_season_dict = get_season_info(season) - this_season_dict = get_season_info(season) + season_episodes = season.episodes() - season_episodes = season.episodes() + all_episodes_dict = {} - all_episodes_dict = {} + for episode in season_episodes: + episodeNumber = episode.episodeNumber - for episode in season_episodes: - episodeNumber = episode.episodeNumber + this_episode_dict = get_episode_info(episode) - this_episode_dict = get_episode_info(episode) + all_episodes_dict[episodeNumber] = this_episode_dict - all_episodes_dict[episodeNumber] = this_episode_dict + this_season_dict["episodes"] = all_episodes_dict - this_season_dict["episodes"] = all_episodes_dict + all_seasons_dict[seasonNumber] = this_season_dict - all_seasons_dict[seasonNumber] = this_season_dict - - itemDict["seasons"] = all_seasons_dict - else: - itemDict = get_common_video_info(item) + itemDict["seasons"] = all_seasons_dict + else: + itemDict = get_common_video_info(item) - # get image data + # get image data - if itemDict is not None: - metadataDict["metadata"][itemKey] = itemDict + if itemDict is not None: + metadataDict["metadata"][itemKey] = itemDict - except Exception as ex: - print(ex) + except Exception as ex: + print(ex) - bar() + bar() - with open(f"metadata-{lib}.yml", "w") as yaml_file: - yaml.dump( - metadataDict, - yaml_file, - default_flow_style=False, - width=float("inf"), - ) + with open(f"metadata-{lib}.yml", "w") as yaml_file: + yaml.dump( + metadataDict, + yaml_file, + default_flow_style=False, + width=float("inf"), + ) - except Exception as ex: - progress_str = f"Problem processing {lib}; {ex}" - plogger(progress_str, "info", "a") + except Exception as ex: + progress_str = f"Problem processing {lib}; {ex}" + plogger(progress_str, "info", "a") end = timer() elapsed = "{:.2f}".format(end - start) diff --git a/Kometa/originals-to-assets.py b/Kometa/originals-to-assets.py index df40040..7fd513b 100644 --- a/Kometa/originals-to-assets.py +++ b/Kometa/originals-to-assets.py @@ -1,20 +1,17 @@ #!/usr/bin/env python -import os import shutil from datetime import datetime from pathlib import Path, PurePath from alive_progress import alive_bar -from helpers import ( - booler, - get_all_from_library, - get_plex, - load_and_upgrade_env, - validate_filename, -) +from config import Config +from helpers import (get_all_from_library, get_plex, get_redaction_list, + get_target_libraries, validate_filename) from logs import logger, plogger, setup_logger +config = Config('../config.yaml') + SCRIPT_NAME = Path(__file__).stem # 0.0.2 added superchatty logging @@ -25,8 +22,6 @@ VERSION = "0.0.6" -env_file_path = Path(".env") - # current dateTime now = datetime.now() @@ -34,102 +29,38 @@ RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") ACTIVITY_LOG = f"{SCRIPT_NAME}.log" -SUPERCHAT = False - +SUPERCHAT = config.get_bool("general.superchat", False) def superchat(msg, level, logfile): if SUPERCHAT: logger(msg, level, logfile) - setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() - ID_FILES = True -PLEX_URL = ( - os.getenv("PLEX_URL") - if os.getenv("PLEX_URL") - else os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") -) -PLEX_TOKEN = ( - os.getenv("PLEX_TOKEN") - if os.getenv("PLEX_TOKEN") - else os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") -) - -if PLEX_URL.endswith("/"): - PLEX_URL = PLEX_URL[:-1] - -if PLEX_URL is None or PLEX_URL == "https://plex.domain.tld": - plogger("You must specify PLEX URL in the .env file.", "info", "a") - exit() - -if PLEX_TOKEN is None or PLEX_TOKEN == "PLEX-TOKEN": - plogger("You must specify PLEX TOKEN in the .env file.", "info", "a") - exit() - -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") - -SUPERCHAT = os.getenv("SUPERCHAT") - ASSET_DIR_LOOKUP = {} -ASSET_DIR = os.getenv("ASSET_DIR") -if ASSET_DIR is None: - ASSET_DIR = "assets" +ASSET_DIR = config.get("image_download.where_to_put_it.asset_dir", "assets") ASSET_PATH = Path(ASSET_DIR) -USE_ASSET_FOLDERS = booler(os.getenv("USE_ASSET_FOLDERS")) - -if ASSET_DIR is None: - ASSET_DIR = "assets" - -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] +USE_ASSET_FOLDERS = config.get_bool("image_download.where_to_put_it.use_asset_folders", False) -KOMETA_CONFIG_DIR = os.getenv("KOMETA_CONFIG_DIR") +KOMETA_CONFIG_DIR = config.get("general.kometa_config_dir", "/opt/kometa/config") -redaction_list = [] -redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_BASEURL")) -redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_TOKEN")) +redaction_list = get_redaction_list() plex = get_plex() -logger("Plex connection succeeded", "info", "a") - +LIB_ARRAY = get_target_libraries(plex) def lib_type_supported(lib): return lib.type == "movie" or lib.type == "show" -ALL_LIBS = plex.library.sections() -ALL_LIB_NAMES = [] - -logger(f"{len(ALL_LIBS)} libraries found:", "info", "a") -for lib in ALL_LIBS: - logger( - f"{lib.title.strip()}: {lib.type} - supported: {lib_type_supported(lib)}", - "info", - "a", - ) - ALL_LIB_NAMES.append(f"{lib.title.strip()}") - -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - for lib in ALL_LIBS: - if lib_type_supported(lib): - LIB_ARRAY.append(lib.title.strip()) - - def get_SE_str(item): superchat(f"entering get_SE_str for {item.TYPE} {item.title}", "info", "a") if item.TYPE == "season": @@ -219,129 +150,122 @@ def target_asset(item): for lib in LIB_ARRAY: - if lib in ALL_LIB_NAMES: - try: - highwater = 0 + try: + highwater = 0 + + plogger(f"Loading {lib} ...", "info", "a") + the_lib = plex.library.section(lib) + the_uuid = the_lib.uuid + superchat(f"{the_lib} uuid {the_uuid}", "info", "a") - plogger(f"Loading {lib} ...", "info", "a") - the_lib = plex.library.section(lib) - the_uuid = the_lib.uuid - superchat(f"{the_lib} uuid {the_uuid}", "info", "a") + the_title = the_lib.title + superchat(f"This library is called {the_title}", "info", "a") + title, msg = validate_filename(the_title) - the_title = the_lib.title - superchat(f"This library is called {the_title}", "info", "a") - title, msg = validate_filename(the_title) + items = [] - items = [] + plogger(f"Loading {the_lib.TYPE}s ...", "info", "a") + item_count, items = get_all_from_library(the_lib, None, None) + plogger( + f"Completed loading {len(items)} of {item_count} {the_lib.TYPE}(s) from {the_lib.title}", + "info", + "a", + ) + + if the_lib.TYPE == "show": + plogger("Loading seasons ...", "info", "a") + season_count, seasons = get_all_from_library(the_lib, "season", None) + plogger( + f"Completed loading {len(seasons)} of {season_count} season(s) from {the_lib.title}", + "info", + "a", + ) + items.extend(seasons) + superchat(f"{len(items)} items to examine", "info", "a") - plogger(f"Loading {the_lib.TYPE}s ...", "info", "a") - item_count, items = get_all_from_library(the_lib, None, None) + plogger("Loading episodes ...", "info", "a") + episode_count, episodes = get_all_from_library(the_lib, "episode", None) plogger( - f"Completed loading {len(items)} of {item_count} {the_lib.TYPE}(s) from {the_lib.title}", + f"Completed loading {len(episodes)} of {episode_count} episode(s) from {the_lib.title}", "info", "a", ) + items.extend(episodes) + superchat(f"{len(items)} items to examine", "info", "a") + + item_total = len(items) + if item_total > 0: + logger(f"looping over {item_total} items...", "info", "a") + item_count = 0 + + plex_links = [] + external_links = [] + + with alive_bar( + item_total, + dual_line=True, + title=f"Grab all posters {the_lib.title}", + ) as bar: + for item in items: + try: + # get rating key + the_key = item.ratingKey + superchat(f"{item.title} key: {the_key}", "info", "a") + + # find image in originals as Path + original_file = find_original(the_lib.title, the_key) + superchat( + f"{item.title} original file: {original_file}", + "info", + "a", + ) + + if original_file.exists(): + superchat( + f"{item.title} original file is here.", "info", "a" + ) - if the_lib.TYPE == "show": - plogger("Loading seasons ...", "info", "a") - season_count, seasons = get_all_from_library(the_lib, "season", None) - plogger( - f"Completed loading {len(seasons)} of {season_count} season(s) from {the_lib.title}", - "info", - "a", - ) - items.extend(seasons) - superchat(f"{len(items)} items to examine", "info", "a") - - plogger("Loading episodes ...", "info", "a") - episode_count, episodes = get_all_from_library(the_lib, "episode", None) - plogger( - f"Completed loading {len(episodes)} of {episode_count} episode(s) from {the_lib.title}", - "info", - "a", - ) - items.extend(episodes) - superchat(f"{len(items)} items to examine", "info", "a") - - item_total = len(items) - if item_total > 0: - logger(f"looping over {item_total} items...", "info", "a") - item_count = 0 - - plex_links = [] - external_links = [] - - with alive_bar( - item_total, - dual_line=True, - title=f"Grab all posters {the_lib.title}", - ) as bar: - for item in items: - try: - # get rating key - the_key = item.ratingKey - superchat(f"{item.title} key: {the_key}", "info", "a") - - # find image in originals as Path - original_file = find_original(the_lib.title, the_key) + # get asset path as Path + target_file = target_asset(item) superchat( - f"{item.title} original file: {original_file}", + f"{item.title} target file: {target_file}", "info", "a", ) - if original_file.exists(): - superchat( - f"{item.title} original file is here.", "info", "a" - ) - - # get asset path as Path - target_file = target_asset(item) - superchat( - f"{item.title} target file: {target_file}", - "info", - "a", - ) - - # create folders on the way to the target - target_file.parent.mkdir(parents=True, exist_ok=True) - superchat( - f"Created folders for: {target_file}", "info", "a" - ) - - # copy original image to asset dir, overwriting whatever's there - shutil.copy(original_file, target_file) - superchat(f"copied {original_file}", "info", "a") - superchat(f" to {target_file}", "info", "a") - else: - plogger( - f"{item.title} ORIGINAL NOT FOUND: {original_file}", - "info", - "a", - ) - - item_count += 1 - except Exception as ex: + # create folders on the way to the target + target_file.parent.mkdir(parents=True, exist_ok=True) + superchat( + f"Created folders for: {target_file}", "info", "a" + ) + + # copy original image to asset dir, overwriting whatever's there + shutil.copy(original_file, target_file) + superchat(f"copied {original_file}", "info", "a") + superchat(f" to {target_file}", "info", "a") + else: plogger( - f"Problem processing {item.title}; {ex}", "info", "a" + f"{item.title} ORIGINAL NOT FOUND: {original_file}", + "info", + "a", ) - bar() + item_count += 1 + except Exception as ex: + plogger( + f"Problem processing {item.title}; {ex}", "info", "a" + ) - plogger(f"Processed {item_count} of {item_total}", "info", "a") + bar() - progress_str = "COMPLETE" - logger(progress_str, "info", "a") + plogger(f"Processed {item_count} of {item_total}", "info", "a") - except Exception as ex: - progress_str = f"Problem processing {lib}; {ex}" - plogger(progress_str, "info", "a") + progress_str = "COMPLETE" + logger(progress_str, "info", "a") + + except Exception as ex: + progress_str = f"Problem processing {lib}; {ex}" + plogger(progress_str, "info", "a") - else: - logger( - f"Library {lib} not found: available libraries on this server are: {ALL_LIB_NAMES}", - "info", - "a", - ) plogger("Complete!", "info", "a") diff --git a/Kometa/top-n-actor-coll.py b/Kometa/top-n-actor-coll.py index 611ec6e..1c8f9a2 100644 --- a/Kometa/top-n-actor-coll.py +++ b/Kometa/top-n-actor-coll.py @@ -1,36 +1,19 @@ -import os import sys import textwrap from collections import Counter -from dotenv import load_dotenv -from plexapi.server import PlexServer +from config import Config +from helpers import get_ids, get_plex, get_target_libraries from tmdbapis import TMDbAPIs -load_dotenv() +config = Config('../config.yaml') -PLEX_URL = os.getenv("PLEX_URL") -PLEX_TOKEN = os.getenv("PLEX_TOKEN") -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -TMDB_KEY = os.getenv("TMDB_KEY") -TVDB_KEY = os.getenv("TVDB_KEY") -CAST_DEPTH = int(os.getenv("CAST_DEPTH")) -TOP_COUNT = int(os.getenv("TOP_COUNT")) -DELAY = int(os.getenv("DELAY")) +CAST_DEPTH = config.get_int("actor.cast_depth") +TOP_COUNT = config.get_int("actor.top_count") -if not DELAY: - DELAY = 0 +DELAY = config.get_int('general.delay', 0) -if LIBRARY_NAMES: - lib_array = LIBRARY_NAMES.split(",") -else: - lib_array = [LIBRARY_NAME] - -tmdb = TMDbAPIs(TMDB_KEY, language="en") - -tmdb_str = "tmdb://" -tvdb_str = "tvdb://" +tmdb = TMDbAPIs(str(config.get("general.tmdb_key", "NO_KEY_SPECIFIED")), language="en") actors = Counter() @@ -44,17 +27,6 @@ COLL_TMPL = tmpl.read() -def getTID(theList): - tmid = None - tvid = None - for guid in theList: - if tmdb_str in guid.id: - tmid = guid.id.replace(tmdb_str, "") - if tvdb_str in guid.id: - tvid = guid.id.replace(tvdb_str, "") - return tmid, tvid - - def progress(count, total, status=""): bar_len = 40 filled_len = int(round(bar_len * count / float(total))) @@ -67,9 +39,11 @@ def progress(count, total, status=""): sys.stdout.flush() -print(f"connecting to {PLEX_URL}...") -plex = PlexServer(PLEX_URL, PLEX_TOKEN) -for lib in lib_array: +plex = get_plex() + +LIB_ARRAY = get_target_libraries(plex) + +for lib in LIB_ARRAY: METADATA_TITLE = f"{lib} Top {TOP_COUNT} Actors.yml" print(f"getting items from [{lib}]...") @@ -79,15 +53,15 @@ def progress(count, total, status=""): item_count = 1 for item in items: tmpDict = {} - tmdb_id, tvdb_id = getTID(item.guids) + imdbid, tmid, tvid = get_ids(item.guids) item_count = item_count + 1 try: progress(item_count, item_total, item.title) cast = "" if item.TYPE == "show": - cast = tmdb.tv_show(tmdb_id).cast + cast = tmdb.tv_show(tmid).cast else: - cast = tmdb.movie(tmdb_id).casts["cast"] + cast = tmdb.movie(tmid).casts["cast"] count = 0 for actor in cast: if count < CAST_DEPTH: diff --git a/Plex/README.md b/Plex/README.md index a0b73d9..41eb69f 100644 --- a/Plex/README.md +++ b/Plex/README.md @@ -6,144 +6,153 @@ Misc scripts and tools. Undocumented scripts probably do what I need them to but See the top-level [README](../README.md) for setup instructions. -All these scripts use the same `.env` and requirements. - -NOTE: on 06-29 these scripts have changed to using ENV vars to set up the Plex API details. This was done primarily to enable the timeout to apply to all Plex interactions. - -If your `.env` file contains the original `PLEX_URL` and `PLEX_TOKEN` entries those will be silently changed for you. - -### `.env` contents - -```shell -# PLEX API ENV VARS -PLEXAPI_HEADER_IDENTIFIER="media-scripts" -PLEXAPI_PLEXAPI_TIMEOUT='360' -PLEXAPI_AUTH_SERVER_BASEURL=https://plex.domain.tld - # Just the base URL, no /web or anything at the end. - # i.e. http://192.168.1.11:32400 or the like -PLEXAPI_AUTH_SERVER_TOKEN=PLEX-TOKEN -PLEXAPI_LOG_BACKUP_COUNT='3' -PLEXAPI_LOG_FORMAT='%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s' # PLEX API ENV VARS -PLEXAPI_LOG_LEVEL='INFO' -PLEXAPI_LOG_PATH='plexapi.log' -PLEXAPI_LOG_ROTATE_BYTES='512000' -PLEXAPI_LOG_SHOW_SECRETS='false' - -# GENERAL ENV VARS -TMDB_KEY=TMDB_API_KEY # https://developers.themoviedb.org/3/getting-started/introduction -TVDB_KEY=TVDB_V4_API_KEY # currently not used; https://thetvdb.com/api-information -DELAY=1 # optional delay between items -LIBRARY_NAMES=Movies,TV Shows,Movies 4K # comma-separated list of libraries to act on - -# IMAGE DOWNLOAD ENV VARS -## what-to-grab -### collection-related -INCLUDE_COLLECTION_ARTWORK=1 # should get-all-posters retrieve collection posters? -ONLY_COLLECTION_ARTWORK=0 # should get-all-posters retrieve ONLY collection posters? -ONLY_THESE_COLLECTIONS=Bing|Bang|Boing # only grab artwork for these collections and items in them - -### tv-related -GRAB_SEASONS=1 # should get-all-posters retrieve season posters? -GRAB_EPISODES=1 # should get-all-posters retrieve episode posters? [requires GRAB_SEASONS] - -### background-related -GRAB_BACKGROUNDS=1 # should get-all-posters retrieve backgrounds? -ARTWORK=1 # current background is downloaded with current poster - -### quantity-related -ONLY_CURRENT=0 # should get-all-posters retrieve ONLY current artwork? -POSTER_DEPTH=20 # grab this many posters [0 grabs all] [ONLY_CURRENT overrides this] - -### what-to-keep -KEEP_JUNK=0 # keep files that script would normally delete [incorrect filetypes, mainly] -FIND_OVERLAID_IMAGES=0 # check all downloaded images for overlays -# RETAIN_OVERLAID_IMAGES=0 # keep images that have an overlay EXIF tag [this will override the following two] -RETAIN_KOMETA_OVERLAID_IMAGES=0 # keep images that have the Kometa overlay EXIF tag -RETAIN_TCM_OVERLAID_IMAGES=0 # keep images that have the TCM overlay EXIF tag - -## where-to-put-it -USE_ASSET_NAMING=1 # should grab-all-posters name images to match Kometa's Asset Directory requirements? -USE_ASSET_FOLDERS=1 # should those Kometa-Asset-Directory names use asset folders? -USE_ASSET_SUBFOLDERS=0 # create asset folders in subfolders ["Collections", "Other", or [0-9, A-Z]] ] -ASSETS_BY_LIBRARIES=1 # should those Kometa-Asset-Directory images be sorted into library folders? -ASSET_DIR=assets # top-level directory for those Kometa-Asset-Directory images - # if asset-directory naming is on, the next three are ignored -POSTER_DIR=extracted_posters # put downloaded posters here -CURRENT_POSTER_DIR=current_posters # put downloaded current posters and artwork here -POSTER_CONSOLIDATE=0 # if false, posters are separated into folders by library - -## tracking -TRACK_URLS=1 # If set to 1, URLS are tracked and won't be downloaded twice -TRACK_COMPLETION=1 # If set to 1, movies/shows are tracked as complete by rating id -TRACK_IMAGE_SOURCES=1 # keep a file containing file names and source URLs - -## general -POSTER_DOWNLOAD=1 # if false, generate a script rather than downloading -FOLDERS_ONLY=0 # Just build out the folder hierarchy; no image downloading -DEFAULT_YEARS_BACK=2 # in absence of a "last run date", grab things added this many years back. - # 0 sets the fallback date to the beginning of time -THREADED_DOWNLOADS=0 # should downloads be done in the background in threads? -RESET_LIBRARIES=Bing,Bang,Boing # reset "last time" count to the fallback date for these libraries -RESET_COLLECTIONS=Bing,Bang,Boing # CURRENTLY UNUSED -ADD_SOURCE_EXIF_COMMENT=1 # CURRENTLY UNUSED - -# STATUS ENV VARS -PLEX_OWNER=yournamehere # account name of the server owner -TARGET_PLEX_URL=https://plex.domain2.tld # As above, the target of apply_all_status -TARGET_PLEX_TOKEN=PLEX-TOKEN-TWO # As above, the target of apply_all_status -TARGET_PLEX_OWNER=yournamehere # As above, the target of apply_all_status -LIBRARY_MAP={"LIBRARY_ON_PLEX":"LIBRARY_ON_TARGET_PLEX", ...} - # In apply_all_status, map libraries according to this JSON. - -# RESET-POSTERS ENV VARS -TRACK_RESET_STATUS=1 # should reset-posters-* keep track of status and pick up where it left off? -LOCAL_RESET_ARCHIVE=1 # should reset-posters-tmdb keep a local archive of posters? -TARGET_LABELS=this label, that label # comma-separated list of labels to reset posters on -REMOVE_LABELS=0 # attempt to remove the TARGET_LABELs from items after resetting the poster -RESET_SEASONS=1 # reset-posters-* resets season artwork as well in TV libraries -RESET_EPISODES=1 # reset-posters-* resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] -RETAIN_RESET_STATUS_FILE=0 # Don't delete the reset progress file at the end -FLUSH_STATUS_AT_START=0 # Delete the reset progress file at the start instead of reading it -RESET_SEASONS_WITH_SERIES=0 # If there isn't a season poster, use the series poster -DRY_RUN=0 # [currently only works with reset-posters-*]; don't actually do anything, just log - -# LIST ITEM IDS ENV VARS -INCLUDE_COLLECTION_MEMBERS=0 -ONLY_COLLECTION_MEMBERS=0 - -# DELETE_COLLECTION ENV VARS -KEEP_COLLECTIONS=bing,bang # List of collections to keep - -# REMATCH-ITEMS ENV VARS -UNMATCHED_ONLY=1 # If 1, only rematch things that are currently unmatched - -# RESET_ADDED_AT -ADJUST_DATE_FUTURES_ONLY=0 # Only look at items that show up as added in the future -ADJUST_DATE_EPOCH_ONLY=1 # Only adjust items that have "originally available" dates of `1970-01-01` - -# REFRESH_METADATA -REFRESH_1970_ONLY=1 # If 1, only refresh things that have an originally-available date of 1970-01-01 - -# ACTOR ENV VARS -CAST_DEPTH=20 # how deep to go into the cast for actor collections -TOP_COUNT=10 # how many actors to export -KNOWN_FOR_ONLY=0 # ignore cast members who are not primarily known as actors -TRACK_GENDER=1 # Pay attention to actor gender [as recorded on TMDB] -BUILD_COLLECTIONS=0 # build yaml for Kometa config.yml -NUM_COLLECTIONS=20 # this many actors in Kometa yaml -MIN_GENDER_NONE = 5 # include minimum this many "none" gendered actors in the YAML, if possible -MIN_GENDER_FEMALE = 5 # include minimum this many "female" gendered actors in the YAML, if possible -MIN_GENDER_MALE = 5 # include minimum this many "male" gendered actors in the YAML, if possible -MIN_GENDER_NB = 5 # include minimum this many "non-binary" gendered actors in the YAML, if possible - -# LOW POSTER COUNT -POSTER_THRESHOLD=10 - -# CREW COUNT -CREW_DEPTH=20 -CREW_COUNT=100 -TARGET_JOB=Director -SHOW_JOBS=0 +All these scripts use the same `config.yaml` and requirements. + +NOTE: on 08-22-2025 these scripts have changed to using a yaml config rather than an env file. TYOu will need to transfer your settings manually from one to the other. + +### `config.template.yaml` contents + +```yaml +plex_api: + header_identifier: "media-scripts" + timeout: 360 + auth_server: + base_url: 'YOUR_PLEX_URL' + token: 'YOUR_PLEX_TOKEN' + log: + backup_count: 3 + format: "%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s" + level: "INFO" + path: "plexapi.log" + rotate_bytes: 512000 + show_secrets: 0 + skip_verify_ssl: 0 + +general: + tmdb_key: "TMDB_API_KEY" # https://developers.themoviedb.org/3/getting-started/introduction + tvdb_key: "TVDB_V4_API_KEY" # currently not used; https://thetvdb.com/api-information + delay: 1 # optional delay between items + library_names: Movies,TV Shows,Movies 4K # comma-separated list of libraries to act on + superchat: 0 + +kometa: + config_dir: /kometa/is/here + +image_download: + what_to_grab: + ### collection-related + include_collection_artwork: 1 # should get-all-posters retrieve collection posters? + only_collection_artwork: 0 # should get-all-posters retrieve ONLY collection posters? + only_these_collections: "Bing|Bang|Boing" # only grab artwork for these collections and items in them + + ### tv-related + seasons: 1 # should get-all-posters retrieve season posters? + episodes: 1 # should get-all-posters retrieve episode posters? [requires GRAB_SEASONS] + + ### background-related + backgrounds: 1 # should get-all-posters retrieve backgrounds? + artwork: 1 # current background is downloaded with current poster + + ### quantity-related + only_current: 0 # should get-all-posters retrieve ONLY current artwork? + poster_depth: 20 # grab this many posters [0 grabs all] [ONLY_CURRENT overrides this] + + ### what-to-keep + keep_junk: 0 # keep files that script would normally delete [incorrect filetypes, mainly] + find_overlaid_images: 0 # check all downloaded images for overlays + retain_overlaid_images: 0 # keep images that have an overlay EXIF tag [this will override the following two] + retain_kometa_overlaid_images: 0 # keep images that have the Kometa overlay EXIF tag + retain_tcm_overlaid_images: 0 # keep images that have the TCM overlay EXIF tag + + ## where-to-put-it + where_to_put_it: + use_asset_naming: 1 # should grab-all-posters name images to match Kometa's Asset Directory requirements? + use_asset_folders: 1 # should those Kometa-Asset-Directory names use asset folders? + use_asset_subfolders: 0 # create asset folders in subfolders ["Collections", "Other", or [0-9, A-Z]] ] + assets_by_libraries: 1 # should those Kometa-Asset-Directory images be sorted into library folders? + asset_dir: "assets" # top-level directory for those Kometa-Asset-Directory images + # if asset-directory naming is on, the next three are ignored + poster_dir: "extracted_posters" # put downloaded posters here + current_poster_dir: "current_posters" # put downloaded current posters and artwork here + poster_consolidate: 0 # if false, posters are separated into folders by library + + ## tracking + tracking: + track_urls: 1 # If set to 1, URLS are tracked and won't be downloaded twice + track_completion: 1 # If set to 1, movies/shows are tracked as complete by rating id + track_image_sources: 1 # keep a file containing file names and source URLs + + ## general + general: + poster_download: 1 # if false, generate a script rather than downloading + folders_only: 0 # Just build out the folder hierarchy; no image downloading + default_years_back: 2 # in absence of a "last run date", grab things added this many years back. + # 0 sets the fallback date to the beginning of time + threaded_downloads: 0 # should downloads be done in the background in threads? + reset_libraries: "Bing,Bang,Boing" # reset "last time" count to the fallback date for these libraries + reset_collections: "Bing,Bang,Boing" # CURRENTLY UNUSED + add_source_exif_comment: 1 # CURRENTLY UNUSED + +status: + plex_owner: "yournamehere" # account name of the server owner + target_plex_url: "https://plex.domain2.tld" # As above, the target of apply_all_status + target_plex_token: "PLEX-TOKEN-TWO" # As above, the target of apply_all_status + target_plex_owner: "yournamehere" # As above, the target of apply_all_status + library_map: '{"LIBRARY_ON_PLEX":"LIBRARY_ON_TARGET_PLEX", ...}' + # In apply_all_status, map libraries according to this JSON. + +reset_posters: + track_reset_status: 1 # should reset-posters-* keep track of status and pick up where it left off? + clear_reset_status: 0 + local_reset_archive: 1 # should reset-posters-tmdb keep a local archive of posters? + override_overlay_status: 0 + target_labels: this label, that label # comma-separated list of labels to reset posters on + remove_labels: 0 # attempt to remove the TARGET_LABELs from items after resetting the poster + reset_seasons: 1 # reset-posters-* resets season artwork as well in TV libraries + reset_episodes: 1 # reset-posters-* resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] + retain_reset_status_file: 0 # Don't delete the reset progress file at the end + flush_status_at_start: 0 # Delete the reset progress file at the start instead of reading it + reset_seasons_with_series: 0 # If there isn't a season poster, use the series poster + dry_run: 0 # [currently only works with reset-posters-*]; don't actually do anything, just log + +list_item_ids: + include_collection_members: 0 + only_collection_members: 0 + +delete_collection: + keep_collections: "bing,bang" # List of collections to keep + +refresh_metadata: + refresh_1970_only: 1 # If 1, only refresh things that have an originally-available date of 1970-01-01 + +rematch_items: + unmatched_only: 1 # If 1, only rematch things that are currently unmatched + +reset_added_at: + adjust_date_futures_only: 0 # Only look at items that show up as added in the future + adjust_date_epoch_only: 1 # Only adjust items that have "originally available" dates of `1970-01-01` + +actor: + cast_depth: 20 # how deep to go into the cast for actor collections + top_count: 10 # how many actors to export + job_type: "Actor" + known_for_only: 0 # ignore cast members who are not primarily known as actors + build_collections: 0 # build yaml for Kometa config.yml + num_collections: 20 # this many actors in Kometa yaml + track_gender: 1 # Pay attention to actor gender [as recorded on TMDB] + min_gender_none: 5 # include minimum this many "none" gendered actors in the YAML, if possible + min_gender_female: 5 # include minimum this many "female" gendered actors in the YAML, if possible + min_gender_male: 5 # include minimum this many "male" gendered actors in the YAML, if possible + min_gender_nb: 5 # include minimum this many "non-binary" gendered actors in the YAML, if possible + +low_poster_count: + poster_threshold: 10 # how many posters counts as a "low" count? + +crew: + depth: 20 + count: 100 + target_job: Director + show_jobs: 0 ``` ## Scripts: @@ -171,10 +180,12 @@ You have things in your library that show up added in the future, or way in the This script will set the "added at" date and "originally available" date to match the thing's release date as found on TMDB, if the values set in Plex are more than a day or so off the TMDB release date. -Script-specific variables in .env: -``` -ADJUST_DATE_FUTURES_ONLY=0 # Only look at items that show up as added in the future -ADJUST_DATE_EPOCH_ONLY=1 # Only adjust items that have "originally available" dates of `1970-01-01` +Script-specific variables in `config.yaml`: +```yaml +reset_added_at: + adjust_date_futures_only: 0 # Only look at items that show up as added in the future + adjust_date_epoch_only: 1 # Only adjust items that have "originally available" dates of `1970-01-01` + ``` ### Usage @@ -212,23 +223,26 @@ This script will set the poster for every series or movie to the default poster If there is a file already located at `./posters/[movies|shows]/.ext`, the script will use *that image* instead of retrieving a new one, so if you replace that local one with a poster of your choice, the script will use the custom one rather than the TMDB/TVDB default. -Script-specific variables in .env: -```shell -TRACK_RESET_STATUS=1 # pick up where the script left off -LOCAL_RESET_ARCHIVE=1 # keep a local archive of posters -TARGET_LABELS = Bing, Bang, Boing # reset artwork on items with these labels -REMOVE_LABELS=1 # remove labels when done [NOT RECOMMENDED] -RESET_SEASONS=1 # reset-posters-plex resets season artwork as well in TV libraries -RESET_EPISODES=1 # reset-posters-plex resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] -RETAIN_RESET_STATUS_FILE=0 # Don't delete the reset progress file at the end -DRY_RUN=0 # [currently only works with reset-posters-*]; don't actually do anything, just log -FLUSH_STATUS_AT_START=0 # Delete the reset progress file at the start instead of reading them -RESET_SEASONS_WITH_SERIES=0 # If there isn't a season poster, use the series poster +Script-specific variables in `config.yaml`: +```yaml +reset_posters: + track_reset_status: 1 # should reset-posters-* keep track of status and pick up where it left off? + clear_reset_status: 0 + local_reset_archive: 1 # should reset-posters-tmdb keep a local archive of posters? + override_overlay_status: 0 + target_labels: this label, that label # comma-separated list of labels to reset posters on + remove_labels: 0 # attempt to remove the TARGET_LABELs from items after resetting the poster + reset_seasons: 1 # reset-posters-* resets season artwork as well in TV libraries + reset_episodes: 1 # reset-posters-* resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] + retain_reset_status_file: 0 # Don't delete the reset progress file at the end + flush_status_at_start: 0 # Delete the reset progress file at the start instead of reading it + reset_seasons_with_series: 0 # If there isn't a season poster, use the series poster + dry_run: 0 # [currently only works with reset-posters-*]; don't actually do anything, just log ``` If you set: -``` -TRACK_RESET_STATUS=1 +```yaml + track_reset_status: 1 ``` The script will keep track of where it is and will pick up at that point on subsequent runs. This is useful in the event of a lost connection to Plex. @@ -237,22 +251,22 @@ Once it gets to the end of the library successfully, the tracking file is delete If you want to reset any existing progress tracking and start from the beginning for some reason, set `FLUSH_STATUS_AT_START` to 1. If you specify a comma-separated list of labels in the env file: -``` -TARGET_LABELS = This label, That label, Another label +```yaml + target_labels: this label, that label ``` The script will reset posters only on movies with those labels assigned. If you also set: -``` -REMOVE_LABELS=1 +```yaml + remove_labels: 1 ``` The script will *attempt* to remove those labels after resetting the poster. I say "attempt" since in testing I have experienced an odd situation where no error occurs but the label is not removed. My test library of 230 4K-Dolby Movies contains 47 that fail in this way; every run it goes through the 47 movies "removing labels" without error yet they still have the labels on the next run. -Besides that Heisenbug, I don't recommend using this [`REMOVE_LABELS`] since the label removal takes a long time [dozens of seconds per item]. Doing this through the Plex UI is much faster. +Besides that Heisenbug, I don't recommend using this [`remove_labels`] since the label removal takes a long time [dozens of seconds per item]. Doing this through the Plex UI is much faster. If you set: -``` -LOCAL_RESET_ARCHIVE=0 +```yaml + local_reset_archive: 1 ``` The script will set the artwork by sending the TMDB URL to Plex, without downloading the file locally first. This means a faster run compared to the initial run when downloading. @@ -288,29 +302,32 @@ At this time, there is no configuration aside from library name; it replaces all ## reset-posters-plex.py -Script-specific variables in .env: -``` -TRACK_RESET_STATUS=1 # pick up where the script left off -LOCAL_RESET_ARCHIVE=1 # keep a local archive of posters -TARGET_LABELS = Bing, Bang, Boing # reset artwork on items with these labels -REMOVE_LABELS=1 # remove labels when done [NOT RECOMMENDED] -RESET_SEASONS=1 # reset-posters-plex resets season artwork as well in TV libraries -RESET_EPISODES=1 # reset-posters-plex resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] -RETAIN_RESET_STATUS_FILE=0 # Don't delete the reset progress file at the end -DRY_RUN=0 # [currently only works with reset-posters-*]; don't actually do anything, just log -FLUSH_STATUS_AT_START=0 # Delete the reset progress file at the start instead of reading them -RESET_SEASONS_WITH_SERIES=0 # If there isn't a season poster, use the series poster +Script-specific variables in `config.yaml`: +```yaml +reset_posters: + track_reset_status: 1 # should reset-posters-* keep track of status and pick up where it left off? + clear_reset_status: 0 + local_reset_archive: 1 # should reset-posters-tmdb keep a local archive of posters? + override_overlay_status: 0 + target_labels: this label, that label # comma-separated list of labels to reset posters on + remove_labels: 0 # attempt to remove the TARGET_LABELs from items after resetting the poster + reset_seasons: 1 # reset-posters-* resets season artwork as well in TV libraries + reset_episodes: 1 # reset-posters-* resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] + retain_reset_status_file: 0 # Don't delete the reset progress file at the end + flush_status_at_start: 0 # Delete the reset progress file at the start instead of reading it + reset_seasons_with_series: 0 # If there isn't a season poster, use the series poster + dry_run: 0 # [currently only works with reset-posters-*]; don't actually do anything, just log ``` Same as `reset-posters-tmdb.py`, but it resets the artwork to the first item in Plex's own list of artwork, rather than downloading a new image from TMDB. -With `RESET_SEASONS_WITH_SERIES=1`, if the season doesn't have artwork the series artwork will be used instead. +With `reset_seasons_with_series: 1`, if the season doesn't have artwork the series artwork will be used instead. ## grab-all-IDs.py Perhaps you want to gather all the IDs for everything in a library. -This script will go through a library and grab PLex RatingKey [which may be unique], IMDB ID, TMDB ID, and TVDB ID for everything in the list of libraries specified in the `.env`. It stores the data in a sqlite database called `ids.sqlite`; the repo copy of this file contains that data for 105871 movies and 26699 TV Shows. +This script will go through a library and grab PLex RatingKey [which may be unique], IMDB ID, TMDB ID, and TVDB ID for everything in the list of libraries specified in the `config.yaml`. It stores the data in a sqlite database called `ids.sqlite`; the repo copy of this file contains that data for 105871 movies and 26699 TV Shows. ## grab-all-posters.py @@ -327,118 +344,130 @@ The script can name these files so that they are ready for use with [Kometa's As If you have downloaded more than one image for each thing, see [image_picker.py](#image_pickerpy) for a simpler way to choose which one you want to make active. -If `THREADED_DOWNLOADS=1`, the script queues downloads so they happen in the background in multiple threads. Once it's gone through the libraries listed in the config, it will then wait until the queue is drained before exiting. If you want to drop out of the library-scanning loop early, create a file `stop.dat` next to the script, and the library loop will exit at the end of the current show or movie, then go to the "waiting for the downloads" section. This allows you to get out early without flushing the queue [as control-C would do]. +If threaded downloads are enabled, the script queues downloads so they happen in the background in multiple threads. Once it's gone through the libraries listed in the config, it will then wait until the queue is drained before exiting. If you want to drop out of the library-scanning loop early, create a file `stop.dat` next to the script, and the library loop will exit at the end of the current show or movie, then go to the "waiting for the downloads" section. This allows you to get out early without flushing the queue [as control-C would do]. You can also skip the current library by creating `skip.dat`. -Script-specific variables in .env: -```shell -# IMAGE DOWNLOAD ENV VARS -## what-to-grab -GRAB_SEASONS=1 # should get-all-posters retrieve season posters? -GRAB_EPISODES=1 # should get-all-posters retrieve episode posters? [requires GRAB_SEASONS] -GRAB_BACKGROUNDS=1 # should get-all-posters retrieve backgrounds? -ONLY_CURRENT=0 # should get-all-posters retrieve ONLY current artwork? -ARTWORK=1 # current background is downloaded with current poster -INCLUDE_COLLECTION_ARTWORK=1 # should get-all-posters retrieve collection posters? -ONLY_COLLECTION_ARTWORK=0 # should get-all-posters retrieve ONLY collection posters? -ONLY_THESE_COLLECTIONS=Bing|Bang|Boing # only grab artwork for these collections and items in them -POSTER_DEPTH=20 # grab this many posters [0 grabs all] -KEEP_JUNK=0 # keep files that script would normally delete [incorrect filetypes, mainly] -FIND_OVERLAID_IMAGES=0 # check all downloaded images for overlays -# RETAIN_OVERLAID_IMAGES=0 # keep images that have an overlay EXIF tag [this will override the following two] -RETAIN_KOMETA_OVERLAID_IMAGES=0 # keep images that have the Kometa overlay EXIF tag -RETAIN_TCM_OVERLAID_IMAGES=0 # keep images that have the TCM overlay EXIF tag - -## where-to-put-it -USE_ASSET_NAMING=1 # should grab-all-posters name images to match Kometa's Asset Directory requirements? -USE_ASSET_FOLDERS=1 # should those Kometa-Asset-Directory names use asset folders? -USE_ASSET_SUBFOLDERS=0 # create asset folders in subfolders ["Collections", "Other", or [0-9, A-Z]] ] -ASSETS_BY_LIBRARIES=1 # should those Kometa-Asset-Directory images be sorted into library folders? -ASSET_DIR=assets # top-level directory for those Kometa-Asset-Directory images - # if asset-directory naming is on, the next three are ignored -POSTER_DIR=extracted_posters # put downloaded posters here -CURRENT_POSTER_DIR=current_posters # put downloaded current posters and artwork here -POSTER_CONSOLIDATE=0 # if false, posters are separated into folders by library - -## tracking -TRACK_URLS=1 # If set to 1, URLS are tracked and won't be downloaded twice -TRACK_COMPLETION=1 # If set to 1, movies/shows are tracked as complete by rating id -TRACK_IMAGE_SOURCES=1 # keep a file containing file names and source URLs - -## general -POSTER_DOWNLOAD=1 # if false, generate a script rather than downloading -FOLDERS_ONLY=0 # Just build out the folder hierarchy; no image downloading -DEFAULT_YEARS_BACK=2 # in absence of a "last run date", grab things added this many years back. - # 0 sets the fallback date to the beginning of time -THREADED_DOWNLOADS=0 # should downloads be done in the background in threads? -RESET_LIBRARIES=Bing,Bang,Boing # reset "last time" count to the fallback date for these libraries -RESET_COLLECTIONS=Bing,Bang,Boing # CURRENTLY UNUSED -ADD_SOURCE_EXIF_COMMENT=1 # CURRENTLY UNUSED +Script-specific variables in `config.yaml`: +```yaml +image_download: + what_to_grab: + ### collection-related + include_collection_artwork: 1 # should get-all-posters retrieve collection posters? + only_collection_artwork: 0 # should get-all-posters retrieve ONLY collection posters? + only_these_collections: "Bing|Bang|Boing" # only grab artwork for these collections and items in them + + ### tv-related + seasons: 1 # should get-all-posters retrieve season posters? + episodes: 1 # should get-all-posters retrieve episode posters? [requires GRAB_SEASONS] + + ### background-related + backgrounds: 1 # should get-all-posters retrieve backgrounds? + artwork: 1 # current background is downloaded with current poster + + ### quantity-related + only_current: 0 # should get-all-posters retrieve ONLY current artwork? + poster_depth: 20 # grab this many posters [0 grabs all] [ONLY_CURRENT overrides this] + + ### what-to-keep + keep_junk: 0 # keep files that script would normally delete [incorrect filetypes, mainly] + find_overlaid_images: 0 # check all downloaded images for overlays + retain_overlaid_images: 0 # keep images that have an overlay EXIF tag [this will override the following two] + retain_kometa_overlaid_images: 0 # keep images that have the Kometa overlay EXIF tag + retain_tcm_overlaid_images: 0 # keep images that have the TCM overlay EXIF tag + + ## where-to-put-it + where_to_put_it: + use_asset_naming: 1 # should grab-all-posters name images to match Kometa's Asset Directory requirements? + use_asset_folders: 1 # should those Kometa-Asset-Directory names use asset folders? + use_asset_subfolders: 0 # create asset folders in subfolders ["Collections", "Other", or [0-9, A-Z]] ] + assets_by_libraries: 1 # should those Kometa-Asset-Directory images be sorted into library folders? + asset_dir: "assets" # top-level directory for those Kometa-Asset-Directory images + # if asset-directory naming is on, the next three are ignored + poster_dir: "extracted_posters" # put downloaded posters here + current_poster_dir: "current_posters" # put downloaded current posters and artwork here + poster_consolidate: 0 # if false, posters are separated into folders by library + + ## tracking + tracking: + track_urls: 1 # If set to 1, URLS are tracked and won't be downloaded twice + track_completion: 1 # If set to 1, movies/shows are tracked as complete by rating id + track_image_sources: 1 # keep a file containing file names and source URLs + + ## general + general: + poster_download: 1 # if false, generate a script rather than downloading + folders_only: 0 # Just build out the folder hierarchy; no image downloading + default_years_back: 2 # in absence of a "last run date", grab things added this many years back. + # 0 sets the fallback date to the beginning of time + threaded_downloads: 0 # should downloads be done in the background in threads? + reset_libraries: "Bing,Bang,Boing" # reset "last time" count to the fallback date for these libraries + reset_collections: "Bing,Bang,Boing" # CURRENTLY UNUSED + add_source_exif_comment: 1 # CURRENTLY UNUSED ``` -The point of "POSTER_DEPTH" is that sometimes movies have an insane number of posters, and maybe you don't want all 257 Endgame posters or whatever. Or maybe you want to download them in batches. +The point of `poster_depth` is that sometimes movies have an insane number of posters, and maybe you don't want all 257 Endgame posters or whatever. Or maybe you want to download them in batches. -If "POSTER_DOWNLOAD" is `0`, the script will build a shell/batch script for each library to download the images at your convenience instead of downloading them as it runs, so you can run the downloads overnight or on a different machine with ALL THE DISK SPACE or something. +If `poster_download` is `0`, the script will build a shell/batch script for each library to download the images at your convenience instead of downloading them as it runs, so you can run the downloads overnight or on a different machine with ALL THE DISK SPACE or something. -If "POSTER_CONSOLIDATE" is `1`, the script will store all the images in one directory rather than separating them by library name. The idea is that Plex shows the same set of posters for "Star Wars" whether it's in your "Movies" or "Movies - 4K" or whatever other libraries, so there's no reason to pull the same set of posters multiple times. There is an example below. +If `poster_consolidate` is `1`, the script will store all the images in one directory rather than separating them by library name. The idea is that Plex shows the same set of posters for "Star Wars" whether it's in your "Movies" or "Movies - 4K" or whatever other libraries, so there's no reason to pull the same set of posters multiple times. There is an example below. -If "INCLUDE_COLLECTION_ARTWORK" is `1`, the script will grab artwork for all the collections in the target library. +If `include_collection_artwork` is `1`, the script will grab artwork for all the collections in the target library. -If "ONLY_COLLECTION_ARTWORK" is `1`, the script will grab artwork for ONLY the collections in the target library; artwork for individual items [movies, shows] will not be grabbed. +If `only_collection_artwork` is `1`, the script will grab artwork for ONLY the collections in the target library; artwork for individual items [movies, shows] will not be grabbed. -If "ONLY_THESE_COLLECTIONS" is not empty, the script will grab artwork for ONLY the collections listed and items contained in those collections. This doesn't affect the sorting or naming, just the filter applied when asking Plex for the items. IF YOU DON'T CHANGE THIS SETTING, NOTHING WILL BE DOWNLOADED. +If `only_these_collections` is not empty, the script will grab artwork for ONLY the collections listed and items contained in those collections. This doesn't affect the sorting or naming, just the filter applied when asking Plex for the items. IF YOU DON'T CHANGE THIS SETTING, NOTHING WILL BE DOWNLOADED. -If "TRACK_URLS" is `1`, the script will track every URL it downloads in a sqlite database. On future runs, if a given URL is found in that database it won't be downloaded a second time. This may save time if the same URL appears multiple times in the list of posters from Plex. +If `track_urls` is `1`, the script will track every URL it downloads in a sqlite database. On future runs, if a given URL is found in that database it won't be downloaded a second time. This may save time if the same URL appears multiple times in the list of posters from Plex. -If "TRACK_COMPLETION" is `1`, the script records collections/movies/shows/seasons/episodes by rating key in a sqlite database. On future runs, if a given rating key is found in that database the thing is considered complete and it will be skipped. This will save time in subsequent runs as the script will not look through all the images for a thing only to determine that it's already downloaded all of them. HOWEVER, this also means that if you increase `POSTER_DEPTH`, those additional images won't be picked up when you run the script again, since the item will be marked as complete. +If `track_completion` is `1`, the script records collections/movies/shows/seasons/episodes by rating key in a sqlite database. On future runs, if a given rating key is found in that database the thing is considered complete and it will be skipped. This will save time in subsequent runs as the script will not look through all the images for a thing only to determine that it's already downloaded all of them. HOWEVER, this also means that if you increase `poster_depth`, those additional images won't be picked up when you run the script again, since the item will be marked as complete. -The script keeps track of the last date it retrieved items from a library [for show libraries it also tracks seasons and episodes separately], and on each run will only retrieve items added since that date. If there is no "last run date" for a given thing, the script uses a fallback "last run" date of today - `DEFAULT_YEARS_BACK`. +The script keeps track of the last date it retrieved items from a library [for show libraries it also tracks seasons and episodes separately], and on each run will only retrieve items added since that date. If there is no "last run date" for a given thing, the script uses a fallback "last run" date of today - `default_years_back`. -If `DEFAULT_YEARS_BACK` = 0, the fallback date is "the beginning of time". There is one other circumstance that will result in a fallback date of "the beginning of time". +If `default_years_back` = 0, the fallback date is "the beginning of time". There is one other circumstance that will result in a fallback date of "the beginning of time". If: 1. You are running Windows -2. `DEFAULT_YEARS_BACK` is > 0 +2. `default_years_back` is > 0 3. the calculated fallback date is before 01/01/1970 Then the "beginning of time" fallback date will be used. This is a Windows issue. -Normally, this fallback date is used *only* if there is no last-run date stored, for example the first time you run the script. This means that if you run the script once with `DEFAULT_YEARS_BACK=2` then change that to `DEFAULT_YEARS_BACK=0`, nothing new will be downloaded. The script will read the last run date from its database and will never look at the new fallback date. +Normally, this fallback date is used *only* if there is no last-run date stored, for example the first time you run the script. This means that if you run the script once with `default_years_back: 2` then change that to `default_years_back: 0`, nothing new will be downloaded. The script will read the last run date from its database and will never look at the new fallback date. -You can use `RESET_LIBRARIES` to force the "last run date" to that fallback date for one or more libraries. +You can use `reset_libraries` to force the "last run date" to that fallback date for one or more libraries. If you want to reset all libraries and clear all history, delete `mediascripts.sqlite`. For example: -You run `grab-all-posters` with `DEFAULT_YEARS_BACK=2`; it downloads posters for half your "Movies" library. Now you want to grab all the rest. You change that to `DEFAULT_YEARS_BACK=0` and run `grab-all-posters` again. Nothing new will be downloaded since the last run date is now the time of that first run, and nothing has been added to Plex since then. If you want to grab all posters from the beginning of time for that library, set: -``` -DEFAULT_YEARS_BACK=0 -RESET_LIBRARIES=Movies +You run `grab-all-posters` with `default_years_back: 2`; it downloads posters for half your "Movies" library. Now you want to grab all the rest. You change that to `default_years_back: 0` and run `grab-all-posters` again. Nothing new will be downloaded since the last run date is now the time of that first run, and nothing has been added to Plex since then. If you want to grab all posters from the beginning of time for that library, set: +```yaml + default_years_back: 0 + reset_libraries: Movies ``` That will set the fallback date to "the beginning of time" and apply that new fallback date to the "Movies" library only. -If "FIND_OVERLAID_IMAGES" is `1`, the script checks every imnage it downloads for the EXIF tag that indicates Kometa created it. If found, the image is deleted. You can override the deleting with `RETAIN_KOMETA_OVERLAID_IMAGES` and/or `RETAIN_TCM_OVERLAID_IMAGES`. +If `find_overlaid_images` is `1`, the script checks every imnage it downloads for the EXIF tag that indicates Kometa created it. If found, the image is deleted. You can override the deleting with `retain_kometa_overlaid_images` and/or `retain_tcm_overlaid_images`. -If "RETAIN_KOMETA_OVERLAID_IMAGES" is `1`, those images with the Kometa EXIF tag are **not** deleted. +If `retain_kometa_overlaid_images` is `1`, those images with the Kometa EXIF tag are **not** deleted. -If "RETAIN_TCM_OVERLAID_IMAGES" is `1`, those images with the Kometa EXIF tag are **not** deleted. +If `retain_tcm_overlaid_images`` is `1`, those images with the Kometa EXIF tag are **not** deleted. -If "RETAIN_OVERLAID_IMAGES" is `1`, the previous two settings will be forced to `0` and all overlaid images will be retained. This is a older deprecated setting. +If `retain_overlaid_images` is `1`, the previous two settings will be forced to `0` and all overlaid images will be retained. This is a older deprecated setting. -NOTE: `ONLY_CURRENT` and `POSTER_DEPTH` do not take these images into account, meaning that if you have: -``` -ONLY_CURRENT=1 -RETAIN_KOMETA_OVERLAID_IMAGES=0 +NOTE: `only_current` and `poster_depth` do not take these images into account, meaning that if you have: +```yaml + only_current: 1 + retain_kometa_overlaid_images: 0 ``` Then nothing will be retained for items with overlaid posters. `grab-all-posters` will download the current art, find that it has an overlay, delete it, then go to the next movie/show. Similarly: -``` -ONLY_CURRENT=0 -POSTER_DEPTH=20 -RETAIN_KOMETA_OVERLAID_IMAGES=0 +```yaml + only_current: 0 + poster_depth: 20 + retain_kometa_overlaid_images: 0 ``` This won't grab images until you have 20 downloaded. It will grab 20 images, and if ten are found to have overlays, those ten will be deleted and you will end up with 10. @@ -457,9 +486,10 @@ The image names are: `title-source-location-INCREMENT.ext` The folder structure in which the images are saved is controlled by a combination of settings; please review the examples below to find the format you want and the settings that you need to generate it. All movies and TV shows in a single folder: +```yaml + poster_consolidate: 1 +``` ```shell -POSTER_CONSOLIDATE=1: - extracted_posters/ └── all_libraries ├── 3 12 Hours-847208 @@ -492,9 +522,10 @@ extracted_posters/ ``` Split by Plex library name ['Movies' and 'TV Shows' are Plex library names]: +```yaml + poster_consolidate: 0 +``` ```shell -POSTER_CONSOLIDATE=0: - extracted_posters/ ├── Movies │   ├── 3 12 Hours-847208 @@ -528,12 +559,16 @@ extracted_posters/ ``` Use Kometa Asset-directory naming, flat: +```yaml +image_download: + what_to_grab: + only_current: 1 + where_to_put_it: + use_asset_naming: 1 + use_asset_folders: 0 + assets_by_libraries: 0 +``` ```shell -USE_ASSET_NAMING=1 -USE_ASSET_FOLDERS=0 -ASSETS_BY_LIBRARIES=0 -ONLY_CURRENT=1 - assets ├── Adam-12 (1968) {tvdb-78686}.jpg ├── Adam-12 (1968) {tvdb-78686}_S01E01.jpg @@ -547,12 +582,16 @@ assets ``` Use Kometa Asset-directory naming, movies and TV in a single directory, split by item name: +```yaml +image_download: + what_to_grab: + only_current: 1 # OR poster_depth: 1 + where_to_put_it: + use_asset_naming: 1 + use_asset_folders: 1 + assets_by_libraries: 0 +``` ```shell -USE_ASSET_NAMING=1 -USE_ASSET_FOLDERS=1 -ASSETS_BY_LIBRARIES=0 -ONLY_CURRENT=1 OR POSTER_DEPTH=1 - assets ├── Adam-12 (1968) {tvdb-78686} │   ├── S01E01.jpg @@ -569,12 +608,16 @@ assets ``` Use Kometa Asset-directory naming, split by Plex library name, flat folder: +```yaml +image_download: + what_to_grab: + only_current: 1 + where_to_put_it: + use_asset_naming: 1 + use_asset_folders: 0 + assets_by_libraries: 1 +``` ```shell -USE_ASSET_NAMING=1 -USE_ASSET_FOLDERS=0 -ASSETS_BY_LIBRARIES=1 -ONLY_CURRENT=1 - assets ├── One Movie │   ├── Star Wars (1977) {imdb-tt0076759} {tmdb-11}.jpg @@ -590,12 +633,16 @@ assets ``` Use Kometa Asset-directory naming, split by Plex library name, split by item name: +```yaml +image_download: + what_to_grab: + only_current: 1 # OR poster_depth: 1 + where_to_put_it: + use_asset_naming: 1 + use_asset_folders: 1 + assets_by_libraries: 1 +``` ```shell -USE_ASSET_NAMING=1 -USE_ASSET_FOLDERS=1 -ASSETS_BY_LIBRARIES=1 -ONLY_CURRENT=1 OR POSTER_DEPTH=1 - assets ├── One Movie │   └── Star Wars (1977) {imdb-tt0076759} {tmdb-11} @@ -614,13 +661,17 @@ assets ``` Use Kometa Asset-directory naming, split by Plex library name, split by first letter, split by item name: +```yaml +image_download: + what_to_grab: + only_current: 1 # OR poster_depth: 1 + where_to_put_it: + use_asset_naming: 1 + use_asset_folders: 1 + assets_by_libraries: 1 + use_asset_subfolders: 1 +``` ```shell -USE_ASSET_NAMING=1 -USE_ASSET_FOLDERS=1 -ASSETS_BY_LIBRARIES=1 -ONLY_CURRENT=1 OR POSTER_DEPTH=1 -USE_ASSET_SUBFOLDERS=1 - assets ├── One Movie │   └── S @@ -651,9 +702,10 @@ Perhaps you want to move or restore watch status from one server to another [or This script will retrieve all watched items for all libraries on a given plex server. It stores them in a tab-delimited file. -Script-specific variables in .env: -```shell -PLEX_OWNER=yournamehere # account name of the server owner +Script-specific variables in `config.yaml`: +```yaml +status: + plex_owner: "yournamehere" # account name of the server owner ``` ### Usage @@ -692,22 +744,24 @@ chazlarson movie Movies - 4K DV Mad Max: Fury Road 2015 R This script reads the file produces by the previous script and applies the watched status for each user/library/item -Script-specific variables in .env: -```shell -TARGET_PLEX_URL=https://plex.domain2.tld -TARGET_PLEX_TOKEN=PLEX-TOKEN-TWO -TARGET_PLEX_OWNER=yournamehere -LIBRARY_MAP={"LIBRARY_ON_PLEX":"LIBRARY_ON_TARGET_PLEX", ...} +Script-specific variables in `config.yaml`: +```yaml +status: + target_plex_url: "https://plex.domain2.tld" # As above, the target of apply_all_status + target_plex_token: "PLEX-TOKEN-TWO" # As above, the target of apply_all_status + target_plex_owner: "yournamehere" # As above, the target of apply_all_status + library_map: '{"LIBRARY_ON_PLEX":"LIBRARY_ON_TARGET_PLEX", ...}' + # In apply_all_status, map libraries according to this JSON. ``` These values are for the TARGET of this script; this is easier than requiring you to edit the PLEX_URL, etc, when running the script. -If the target Plex has different library names, you can map one to the other in LIBRARY_MAP. +If the target Plex has different library names, you can map one to the other in `library_map`. For example, if the TV library on the source Plex is called "TV - 1080p" and on the target Plex it's "TV Shows on SpoonFlix", you'd map that with: -``` -LIBRARY_MAP={"TV - 1080p":"TV Shows on SpoonFlix"} +```yaml + library_map: '{"TV - 1080p":"TV Shows on SpoonFlix"}' ``` And any records from the status.txt file that came from the "TV - 1080p" library on the source Plex would get applied to the "TV Shows on SpoonFlix" library on the target. @@ -732,7 +786,7 @@ Perhaps you want to creep on your users and see what they have on their playlist This script will list the contents of all playlists users have created [except the owner's, since you already have access to those]. -Script-specific variables in .env: +Script-specific variables in `config.yaml`: ``` NONE ``` @@ -776,9 +830,10 @@ Perhaps you want to delete all the collections in one or more libraries This script will simply delete all collections from the libraries specified in the config, except those listed. -Script-specific variables in .env: -```shell -KEEP_COLLECTIONS=bing,bang # comma-separated list of collections to keep +Script-specific variables in `config.yaml`: +```yaml +delete_collection: + keep_collections: "bing,bang" # List of collections to keep ``` **** ### Usage @@ -797,9 +852,10 @@ Perhaps you want to refresh metadata in one or more libraries; there are situati This script will simply loop through the libraries specified in the config, refreshing each item in the library. It waits for the specified DELAY between each. -Script-specific variables in .env: -``` -NONE +Script-specific variables in `config.yaml`: +```yaml +refresh_metadata: + refresh_1970_only: 1 # If 1, only refresh things that have an originally-available date of 1970-01-01 ``` **** ### Usage @@ -855,36 +911,39 @@ This script connects to a plex library, and grabs all the items. For each item, At the end, it produces a list of a configurable size in descending order of number of appearances. -Script-specific variables in .env: +Script-specific variables in `config.yaml`: ``` -CAST_DEPTH=20 ### HOW DEEP TO GO INTO EACH MOVIE CAST -TOP_COUNT=10 ### PUT THIS MANY INTO THE FILE AT THE END -KNOWN_FOR_ONLY=0 ### ONLY CONSIDER CAST MEMBERS "KNOWN FOR" ACTING -BUILD_COLLECTIONS=0 # build yaml for Kometa config.yml -NUM_COLLECTIONS=20 # this many actors in Kometa yaml -MIN_GENDER_NONE = 5 # include minimum this many "none" gendered actors in the YAML, if possible -MIN_GENDER_FEMALE = 5 # include minimum this many "female" gendered actors in the YAML, if possible -MIN_GENDER_MALE = 5 # include minimum this many "male" gendered actors in the YAML, if possible -MIN_GENDER_NB = 5 # include minimum this many "non-binary" gendered actors in the YAML, if possible +actor: + cast_depth: 20 # how deep to go into the cast for actor collections + top_count: 10 # how many actors to export + job_type: "Actor" + known_for_only: 0 # ignore cast members who are not primarily known as actors + build_collections: 0 # build yaml for Kometa config.yml + num_collections: 20 # this many actors in Kometa yaml + track_gender: 1 # Pay attention to actor gender [as recorded on TMDB] + min_gender_none: 5 # include minimum this many "none" gendered actors in the YAML, if possible + min_gender_female: 5 # include minimum this many "female" gendered actors in the YAML, if possible + min_gender_male: 5 # include minimum this many "male" gendered actors in the YAML, if possible + min_gender_nb: 5 # include minimum this many "non-binary" gendered actors in the YAML, if possible ``` -`CAST_DEPTH` is meant to prevent some journeyman character actor from showing up in the top ten; I'm thinking of someone like Clint Howard who's been in the cast of many movies, but I'm guessing when you think of the top ten actors in your library you're not thinking about Clint. Maybe you are, though, in which case set that higher. +`cast_depth` is meant to prevent some journeyman character actor from showing up in the top ten; I'm thinking of someone like Clint Howard who's been in the cast of many movies, but I'm guessing when you think of the top ten actors in your library you're not thinking about Clint. Maybe you are, though, in which case set that higher. -`TOP_COUNT` is the number of actors to show in the list at the end. +`top_count` is the number of actors to show in the list at the end. -Every person in the cast list has a "known_for_department" attribute on TMDB. If you set `KNOWN_FOR_ONLY=True`, then people who don't have "Acting" in that field will be excluded. Turning this on may slightly distort results. For example, Harold Ramis is the second lead in "Stripes" and "Ghostbusters", but he is primarily known for "Directing" according to TMDB, so if you turn this flag on he doesn't get counted at all. +Every person in the cast list has a "known_for_department" attribute on TMDB. If you set `known_for_only: 1`, then people who don't have "Acting" in that field will be excluded. Turning this on may slightly distort results. For example, Harold Ramis is the second lead in "Stripes" and "Ghostbusters", but he is primarily known for "Directing" according to TMDB, so if you turn this flag on he doesn't get counted at all. -`BUILD_COLLECTIONS` will make the script build some YAML to paste into your Kometa config file to generate collections. +`build_collections` will make the script build some YAML to paste into your Kometa config file to generate collections. -`NUM_COLLECTIONS` controls the number of collections in that YAML +`num_collections` controls the number of collections in that YAML -`TRACK_GENDER` controls whether the script pays attention to actor gender +`track_gender` controls whether the script pays attention to actor gender -`MIN_GENDER_*` control the minimum number of that gender [as recorded by TMDB] actor to include in the list [provided `TRACK_GENDER=1`] +`min_gender_*` control the minimum number of that gender [as recorded by TMDB] actor to include in the list [provided `track_gender: 1`] -Actors are sorted into lists by the four genders recorded at TMDB. The top `MIN_GENDER_*` for each are added to the final list, then if there is space left over the remainder is filled from the master actor list. +Actors are sorted into lists by the four genders recorded at TMDB. The top `min_gender_*` for each are added to the final list, then if there is space left over the remainder is filled from the master actor list. -If the four `MIN_GENDER_*` sum to more than `NUM_COLLECTIONS`, the script exists with an error. +If the four `min_gender_*` sum to more than `num_collections`, the script exits with an error. ### Usage 1. setup as above 1. Run with `python actor-count.py` @@ -901,8 +960,10 @@ It will go through all your movies, and then at the end print out however many a Sample results for the library above: -CAST_DEPTH=20 -TOP_COUNT = 10 +```yaml + cast_depth: 20 + top_count: 10 +``` ```shell 30 Samuel L. Jackson - 2231 22 Idris Elba - 17605 @@ -916,8 +977,10 @@ TOP_COUNT = 10 20 Laurence Fishburne - 2975 ``` -CAST_DEPTH=40 -TOP_COUNT=10 +```yaml + cast_depth: 40 + top_count: 10 +``` ```shell 33 Samuel L. Jackson - 2231 24 John Ratzenberger - 7907 @@ -940,19 +1003,20 @@ Perhaps you want a list of crew members with a count of how many movies from you This script connects to a plex library, and grabs all the items. For each item, it then gets the crew from TMDB and keeps track across all items how many times it sees each individual with the configured `TARGET_JOB` within the list, looking down to a configurable depth. At the end, it produces a list of a configurable size in descending order of number of appearances. -Script-specific variables in .env: -``` -CREW_DEPTH=20 ### HOW DEEP TO GO INTO EACH MOVIE CREW -CREW_COUNT=100 ### HOW MANY INDIVIDUALS TO REPORT AT THE END -TARGET_JOB=Director ### WHAT JOB TO TRACK -SHOW_JOBS=0 ### Display a list of all the jobs the script saw +Script-specific variables in `config.yaml`: +```yaml +crew: + depth: 20 + count: 100 + target_job: Director + show_jobs: 0 ``` -`CREW_DEPTH` is meant to allow the script to look deeper into the crew to find all the individuals working as TARGET_JOB. +`depth` is meant to allow the script to look deeper into the crew to find all the individuals working as TARGET_JOB. -`CREW_COUNT` is the number of individuals to show in the list at the end. +`count` is the number of individuals to show in the list at the end. -If `SHOW_JOBS` is set to 1, the script will also output a list of all the jobs it saw, if you want a reference to what may be available. +If `show_jobs` is set to 1, the script will also output a list of all the jobs it saw, if you want a reference to what may be available. ### Usage 1. setup as above @@ -969,9 +1033,13 @@ It will go through all your movies, and then at the end print out however many a Sample results for the library above: -CREW_DEPTH=20 -CREW_COUNT=100 -TARGET_JOB=Director +```yaml +crew: + depth: 20 + count: 100 + target_job: Director + show_jobs: 0 +``` ```shell Top 27 Director in [Test-Movies]: 3 Jules Bass - 16410 @@ -1003,9 +1071,12 @@ Top 27 Director in [Test-Movies]: 1 Russ Meyer - 4590 ``` -CREW_DEPTH=5 -CREW_COUNT=100 -TARGET_JOB=Director +```yaml +crew: + depth: 5 + count: 100 + target_job: Director +``` ```shell Top 22 Director in [Test-Movies]: 3 Jules Bass - 16410 @@ -1038,9 +1109,10 @@ Note that the list changed due to the different depth; apparently Robert Wise's Perhaps you want to know which movies have fewer than 4 posters avaiable in Plex. -Script-specific variables in .env: -``` -POSTER_THRESHOLD=10 # report items with fewer posters than this +Script-specific variables in `config.yaml`: +```yaml +low_poster_count: + poster_threshold: 10 # how many posters counts as a "low" count? ``` ### Usage diff --git a/Plex/actor-count.py b/Plex/actor-count.py index 7b1ef49..0c59d1d 100644 --- a/Plex/actor-count.py +++ b/Plex/actor-count.py @@ -1,5 +1,6 @@ #!/usr/bin/env python -import os +# -*- coding: utf-8 -*- + import platform import time from collections import Counter @@ -8,7 +9,9 @@ from timeit import default_timer as timer from alive_progress import alive_bar -from helpers import booler, get_all_from_library, get_plex, load_and_upgrade_env +from config import Config +from helpers import (get_all_from_library, get_ids, get_plex, + get_target_libraries) from logs import plogger, setup_logger from tmdbapis import TMDbAPIs @@ -21,6 +24,7 @@ JOB_DIRECTOR = "Director" # DONE 0.1.0: refactoring, added version +# DONE 0.2.0: config class start = timer() @@ -32,63 +36,41 @@ setup_logger("activity_log", ACTIVITY_LOG) -env_file_path = Path(".env") - # current dateTime now = datetime.now() -IS_WINDOWS = platform.system() == "Windows" - # convert to string RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") -plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") - -if load_and_upgrade_env(env_file_path) < 0: - exit() +IS_WINDOWS = platform.system() == "Windows" -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") +plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] +config = Config('../config.yaml') -TMDB_KEY = os.getenv("TMDB_KEY") -TVDB_KEY = os.getenv("TVDB_KEY") -CAST_DEPTH = int(os.getenv("CAST_DEPTH")) -TOP_COUNT = int(os.getenv("TOP_COUNT")) -KNOWN_FOR_ONLY = booler(os.getenv("KNOWN_FOR_ONLY")) -TRACK_GENDER = booler(os.getenv("TRACK_GENDER")) -JOB_TYPE = os.getenv("JOB_TYPE") +CAST_DEPTH = config.get_int("actor.cast_depth") +TOP_COUNT = config.get_int("actor.top_count") +TRACK_GENDER = config.get_bool("actor.track_gender") -GENERATE_KOMETA_YAML = booler(os.getenv("GENERATE_KOMETA_YAML")) -NUM_COLLECTIONS = int(os.getenv("NUM_COLLECTIONS")) -MIN_GENDER_NONE = int(os.getenv("MIN_GENDER_NONE")) -MIN_GENDER_FEMALE = int(os.getenv("MIN_GENDER_FEMALE")) -MIN_GENDER_MALE = int(os.getenv("MIN_GENDER_MALE")) -MIN_GENDER_NB = int(os.getenv("MIN_GENDER_NB")) +NUM_COLLECTIONS = config.get_int("general.num_collections") +MIN_GENDER_NO_GENDER = config.get_int("general.min_gender_no_gender", 0) +MIN_GENDER_FEMALE = config.get_int("general.min_gender_female", 0) +MIN_GENDER_MALE = config.get_int("general.min_gender_male", 0) +MIN_GENDER_NB = config.get_int("general.min_gender_nb", 0) if ( - MIN_GENDER_NONE + MIN_GENDER_FEMALE + MIN_GENDER_MALE + MIN_GENDER_NB + MIN_GENDER_NO_GENDER + MIN_GENDER_FEMALE + MIN_GENDER_MALE + MIN_GENDER_NB ) > NUM_COLLECTIONS: print("minimum gender requirements exceed number of collections") exit(1) -DELAY = int(os.getenv("DELAY")) - -if not DELAY: - DELAY = 0 +DELAY = config.get_int("general.delay", 0) -tmdb = TMDbAPIs(TMDB_KEY, language="en") - -tmdb_str = "tmdb://" -tvdb_str = "tvdb://" +tmdb = TMDbAPIs(str(config.get("general.tmdb_key", "NO_KEY_SPECIFIED")), language="en") people = Counter() lists = Counter() -gender_none = Counter() +gender_no_gender = Counter() gender_female = Counter() gender_male = Counter() gender_nonbinary = Counter() @@ -96,7 +78,7 @@ def track_gender(the_key, gender): if gender == TMDB_GENDER_NOT_SET: - gender_none[the_key] += 1 + gender_no_gender[the_key] += 1 if gender == TMDB_GENDER_FEMALE: gender_female[the_key] += 1 @@ -136,30 +118,10 @@ def reverse_gender(gender_str): return TMDB_GENDER_NONBINARY -def getTID(theList): - tmid = None - tvid = None - for guid in theList: - if tmdb_str in guid.id: - tmid = guid.id.replace(tmdb_str, "") - if tvdb_str in guid.id: - tvid = guid.id.replace(tvdb_str, "") - return tmid, tvid - - plex = get_plex() -ALL_LIBS = plex.library.sections() -ALL_LIB_NAMES = [] -plogger(f"{len(ALL_LIBS)} libraries found:", "info", "a") -for lib in ALL_LIBS: - ALL_LIB_NAMES.append(f"{lib.title.strip()}") - -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - for lib in ALL_LIBS: - LIB_ARRAY.append(lib.title.strip()) +LIB_ARRAY = get_target_libraries(plex) def ascii_histogram(data) -> None: @@ -185,21 +147,25 @@ def ascii_histogram(data) -> None: with alive_bar(item_total, dual_line=True, title=f"Actor Count: {lib}") as bar: for item in items: tmpDict = {} - tmdb_id, tvdb_id = getTID(item.guids) + imdbid, tmid, tvid = get_ids(item.guids) item_count = item_count + 1 try: list = "" if item.TYPE == "show": - media_item = tmdb.tv_show(tmdb_id) + media_item = tmdb.tv_show(tmid) if tmid else None + else: + media_item = tmdb.movie(tmid) if tmid else None + if config.get("actor.job_type") == JOB_DIRECTOR: + person_data = media_item.crew if media_item else [] else: - media_item = tmdb.movie(tmdb_id) - if JOB_TYPE == JOB_DIRECTOR: - list = media_item.crew + person_data = media_item.cast if media_item else [] + if person_data is None: + person_list = [] else: - list = media_item.cast + person_list = person_data if person_data else [] count = 0 - list_size = len(list) + list_size = len(person_list) if person_list else 0 if list_size < 2: print(f"small list - {item.title}: {list_size}") @@ -213,35 +179,35 @@ def ascii_histogram(data) -> None: ) bar.text( - f"Processing {CAST_DEPTH if CAST_DEPTH < list_size else list_size} of {list_size} from {item.title} - average list {average_list} counts: {len(people)} - N{len(gender_none)} - F{len(gender_female)} - M{len(gender_male)} - NB{len(gender_nonbinary)}" + f"Processing {CAST_DEPTH if CAST_DEPTH < list_size else list_size} of {list_size} from {item.title} - average list {average_list} counts: {len(people)} - N{len(gender_no_gender)} - F{len(gender_female)} - M{len(gender_male)} - NB{len(gender_nonbinary)}" ) - for person in list: + person_list = person_list[:CAST_DEPTH] if CAST_DEPTH < list_size else person_list + + for person in person_list: # person points to person - if count < CAST_DEPTH: - count = count + 1 - list_count += 1 - the_key = f"{person.name} - {person.person_id} - {translate_gender(person.gender)}" - count_them = False - if KNOWN_FOR_ONLY: - if person.known_for_department == "Acting": - count_them = True - else: - skip_count += 1 - print( - f"Skipping {person.name}: {person.known_for_department}" - ) - else: + list_count += 1 + the_key = f"{person.name} - {person.person_id} - {translate_gender(person.gender)}" + count_them = False + if config.get_bool("actor.known_for_only"): + if person.known_for_department == "Acting": count_them = True - - if count_them: - people[the_key] += 1 - if TRACK_GENDER: - track_gender(the_key, person.gender) - credit_count += 1 - bar.text( - f"Processing {CAST_DEPTH if CAST_DEPTH < list_size else list_size} of {list_size} from {item.title} - average list {average_list} counts: {len(people)} - N{len(gender_none)} - F{len(gender_female)} - M{len(gender_male)} - NB{len(gender_nonbinary)}" + else: + skip_count += 1 + print( + f"Skipping {person.name}: {person.known_for_department}" ) + else: + count_them = True + + if count_them: + people[the_key] += 1 + if TRACK_GENDER: + track_gender(the_key, person.gender) + credit_count += 1 + bar.text( + f"Processing {CAST_DEPTH if CAST_DEPTH < list_size else list_size} of {list_size} from {item.title} - average list {average_list} counts: {len(people)} - N{len(gender_no_gender)} - F{len(gender_female)} - M{len(gender_male)} - NB{len(gender_nonbinary)}" + ) except Exception: print(f"{item_count}, {item_total}, EX: {item.title}") @@ -259,13 +225,13 @@ def ascii_histogram(data) -> None: ) print(f"Unique people: {len(people)}") if TRACK_GENDER: - print(f"'None' gender': {len(gender_none)}") + print(f"'None' gender': {len(gender_no_gender)}") print(f"'Female' gender': {len(gender_female)}") print(f"'Male' gender': {len(gender_male)}") print(f"'Nonbinary' gender': {len(gender_nonbinary)}") print(f"Unique list counts: {len(lists)}") - print(f"Longest list list: {highwater_list}") - print(f"Average list list: {average_list}") + print(f"Longest list count: {highwater_list}") + print(f"Average list count: {average_list}") print(f"Skipped {skip_count} non-primary") print(f"Total {credit_count} credits recorded") print(f"Top {TOP_COUNT} listed below") @@ -282,13 +248,13 @@ def ascii_histogram(data) -> None: ascii_histogram(lists) print("--------------------------------\n") - if GENERATE_KOMETA_YAML: + if config.get_bool("general.generate_kometa_yaml"): top_people = Counter() count = 0 - if MIN_GENDER_NONE > 0: - for person in sorted(gender_none.items(), key=lambda x: x[1], reverse=True): - if count < MIN_GENDER_NONE: + if MIN_GENDER_NO_GENDER > 0: + for person in sorted(gender_no_gender.items(), key=lambda x: x[1], reverse=True): + if count < MIN_GENDER_NO_GENDER: top_people[person[0]] = person[1] count = count + 1 @@ -340,7 +306,7 @@ def ascii_histogram(data) -> None: print(f"Minimum {MIN_GENDER_FEMALE} female people if possible") print(f"Minimum {MIN_GENDER_MALE} male people if possible") print(f"Minimum {MIN_GENDER_NB} non-binary people if possible") - print(f"Minimum {MIN_GENDER_NONE} no-gender-available people if possible") + print(f"Minimum {MIN_GENDER_NO_GENDER} no-gender-available people if possible") print("--- YAML FOR Kometa config.yml ----") diff --git a/Plex/adjust-added-dates.py b/Plex/adjust-added-dates.py index a7ccf48..24a9b89 100644 --- a/Plex/adjust-added-dates.py +++ b/Plex/adjust-added-dates.py @@ -1,27 +1,21 @@ #!/usr/bin/env python -import os from datetime import datetime from pathlib import Path from alive_progress import alive_bar -from helpers import ( - booler, - get_all_from_library, - get_ids, - get_plex, - load_and_upgrade_env, -) +from config import Config +from helpers import (get_all_from_library, get_ids, get_plex, + get_target_libraries) from logs import blogger, logger, plogger, setup_logger from tmdbapis import TMDbAPIs SCRIPT_NAME = Path(__file__).stem -env_file_path = Path(".env") - # 0.1.1 Log config details # 0.1.2 incorporate helper changes, remove testing code +# 0.2.0 config class -VERSION = "0.1.2" +VERSION = "0.2.0" # current dateTime now = datetime.now() @@ -35,42 +29,21 @@ plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') plex = get_plex() +LIB_ARRAY = get_target_libraries(plex) + logger("connection success", "info", "a") -ADJUST_DATE_FUTURES_ONLY = booler(os.getenv("ADJUST_DATE_FUTURES_ONLY")) -plogger(f"ADJUST_DATE_FUTURES_ONLY: {ADJUST_DATE_FUTURES_ONLY}", "info", "a") +plogger(f"Adjusting future dates only: {config.get_bool("adjust_date.futures_only", False)}", "info", "a") -ADJUST_DATE_EPOCH_ONLY = booler(os.getenv("ADJUST_DATE_EPOCH_ONLY")) -plogger(f"ADJUST_DATE_EPOCH_ONLY: {ADJUST_DATE_EPOCH_ONLY}", "info", "a") +plogger(f"Adjusting epoch dates only: {config.get_bool("adjust_date.epoch_only", False)}", "info", "a") EPOCH_DATE = datetime(1970, 1, 1, 0, 0, 0) -TMDB_KEY = os.getenv("TMDB_KEY") - -tmdb = TMDbAPIs(TMDB_KEY, language="en") - -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") - -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] - -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - all_libs = plex.library.sections() - for lib in all_libs: - if lib.type == "movie" or lib.type == "show": - LIB_ARRAY.append(lib.title.strip()) - -plogger(f"Acting on libraries: {LIB_ARRAY}", "info", "a") - +tmdb = TMDbAPIs(str(config.get("general.tmdb_key", "NO_KEY_SPECIFIED")), language="en") def is_epoch(the_date): ret_val = False @@ -94,7 +67,7 @@ def is_epoch(the_date): lib_size = the_lib.totalViewSize() - if ADJUST_DATE_FUTURES_ONLY: + if config.get_bool("adjust_date.futures_only", False): TODAY_STR = now.strftime("%Y-%m-%d") item_count, items = get_all_from_library( the_lib, None, {"addedAt>>": TODAY_STR} @@ -125,7 +98,7 @@ def is_epoch(the_date): for sub_item in sub_items: try: - imdbid, tmid, tvid = get_ids(sub_item.guids, None) + imdbid, tmid, tvid = get_ids(sub_item.guids) if is_movie: tmdb_item = tmdb.movie(tmid) @@ -137,7 +110,7 @@ def is_epoch(the_date): else: parent_show = sub_item.show() imdbid, tmid, tvid = get_ids( - parent_show.guids, None + parent_show.guids ) season_num = sub_item.seasonNumber episode_num = sub_item.episodeNumber @@ -150,8 +123,8 @@ def is_epoch(the_date): added_date = item.addedAt orig_date = item.originallyAvailableAt - if not ADJUST_DATE_EPOCH_ONLY or ( - ADJUST_DATE_EPOCH_ONLY and is_epoch(orig_date) + if not config.get_bool("adjust_date.epoch_only", False) or ( + config.get_bool("adjust_date.epoch_only", False) and is_epoch(orig_date) ): try: delta = added_date - release_date @@ -205,7 +178,7 @@ def is_epoch(the_date): else: blogger( - f"skipping {item.title}: EPOCH_ONLY {ADJUST_DATE_EPOCH_ONLY}, originally available date {orig_date}", + f"skipping {item.title}: EPOCH_ONLY {config.get_bool("adjust_date.epoch_only", False)}, originally available date {orig_date}", "info", "a", bar, diff --git a/Plex/apply-all-status.py b/Plex/apply-all-status.py index a475c3b..b1c478c 100644 --- a/Plex/apply-all-status.py +++ b/Plex/apply-all-status.py @@ -7,14 +7,16 @@ from datetime import datetime from pathlib import Path -from helpers import get_plex, load_and_upgrade_env +from config import Config +from helpers import get_plex from logs import plogger, setup_logger SCRIPT_NAME = Path(__file__).stem -VERSION = "0.1.1" +VERSION = "0.2.0" # DONE 0.1.1: guard against empty library map +# DONE 0.2.0: config class # current dateTime now = datetime.now() @@ -22,26 +24,23 @@ # convert to string RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") -env_file_path = Path(".env") - ACTIVITY_LOG = f"{SCRIPT_NAME}.log" setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') -PLEX_OWNER = os.getenv("TARGET_PLEX_OWNER") +PLEX_OWNER = config.get("target.plex_owner") -LIBRARY_MAP = os.getenv("LIBRARY_MAP", "{}") +LIBRARY_MAP = config.get("target.library_map", "{}") try: lib_map = json.loads(LIBRARY_MAP) except: plogger( - "LIBRARY_MAP in the .env file appears to be broken. Defaulting to an empty list.", + "LIBRARY_MAP in the config.yaml appears to be broken. Defaulting to an empty list.", "info", "a", ) @@ -74,6 +73,7 @@ def get_user_acct(acct_list, title): last_library = None plex = get_plex() + PMI = plex.machineIdentifier account = plex.myPlexAccount() diff --git a/Plex/build-assets-tmdb.py b/Plex/build-assets-tmdb.py index ca8f810..72878ad 100644 --- a/Plex/build-assets-tmdb.py +++ b/Plex/build-assets-tmdb.py @@ -11,19 +11,12 @@ import requests import validators from alive_progress import alive_bar -from helpers import ( - booler, - get_all_from_library, - get_ids, - get_overlay_status, - get_plex, - load_and_upgrade_env, -) +from config import Config +from helpers import (get_all_from_library, get_ids, get_overlay_status, + get_plex, get_redaction_list, get_target_libraries) from logs import blogger, logger, plogger, setup_logger from tmdbapis import TMDbAPIs -# import tvdb_v4_official - start = timer() # current dateTime @@ -38,50 +31,37 @@ VERSION = "0.1.2" -env_file_path = Path(".env") - ACTIVITY_LOG = f"{SCRIPT_NAME}.log" setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -TMDB_KEY = os.getenv("TMDB_KEY") -TVDB_KEY = os.getenv("TVDB_KEY") -TARGET_LABELS = os.getenv("TARGET_LABELS") +TARGET_LABELS = config.get('reset_posters.target_labels') if TARGET_LABELS == "this label, that label": print( - "TARGET_LABELS in the .env file must be empty or have a meaningful value.", - "info", + "reset_posters.target_labels in the config.yaml must be empty or have a meaningful value.", "a", ) exit() -TRACK_RESET_STATUS = booler(os.getenv("TRACK_RESET_STATUS")) -CLEAR_RESET_STATUS = booler( - os.getenv( - "CLEAR_RESET_STATUS", - ) -) +TRACK_RESET_STATUS = config.get_bool("reset_posters.track_reset_status") +CLEAR_RESET_STATUS = config.get_bool("reset_posters.clear_reset_status") -RETAIN_RESET_STATUS_FILE = os.getenv("RETAIN_RESET_STATUS_FILE") -REMOVE_LABELS = booler(os.getenv("REMOVE_LABELS")) -RESET_SEASONS = booler(os.getenv("RESET_SEASONS")) -RESET_EPISODES = booler(os.getenv("RESET_EPISODES")) -RESET_SEASONS_WITH_SERIES = booler(os.getenv("RESET_SEASONS_WITH_SERIES")) -LOCAL_RESET_ARCHIVE = booler(os.getenv("LOCAL_RESET_ARCHIVE")) -DRY_RUN = booler(os.getenv("DRY_RUN")) -FLUSH_STATUS_AT_START = booler(os.getenv("FLUSH_STATUS_AT_START")) -OVERRIDE_OVERLAY_STATUS = booler(os.getenv("OVERRIDE_OVERLAY_STATUS")) +REMOVE_LABELS = config.get_bool("reset_posters.remove_labels") +RESET_SEASONS = config.get_bool("reset_posters.reset_seasons") +RESET_EPISODES = config.get_bool("reset_posters.reset_episodes") +RESET_SEASONS_WITH_SERIES = config.get_bool("reset_posters.reset_seasons_with_series") +LOCAL_RESET_ARCHIVE = config.get_bool("reset_posters.local_reset_archive") +DRY_RUN = config.get_bool("reset_posters.dry_run") +OVERRIDE_OVERLAY_STATUS = config.get_bool("reset_posters.override_overlay_status") +FLUSH_STATUS_AT_START = config.get_bool("reset_posters.flush_status_at_start") DELAY = 0 try: - DELAY = int(os.getenv("DELAY")) + DELAY = config.get_int('general.delay') except: DELAY = 0 @@ -90,17 +70,9 @@ else: LBL_ARRAY = ["xy22y1973"] -if LIBRARY_NAMES: - LIB_ARRAY = LIBRARY_NAMES.split(",") -else: - LIB_ARRAY = [LIBRARY_NAME] - IS_WINDOWS = platform.system() == "Windows" -# Commented out until this doesn't throw a 400 -# tvdb = tvdb_v4_official.TVDB(TVDB_KEY) - -tmdb = TMDbAPIs(TMDB_KEY, language="en") +tmdb = TMDbAPIs(str(config.get("general.tmdb_key", "NO_KEY_SPECIFIED")), language="en") local_dir = os.path.join(os.getcwd(), "posters") @@ -128,14 +100,8 @@ def localFilePath(tgt_dir, rating_key): plex = get_plex() -logger(("connection success"), "info", "a") -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - all_libs = plex.library.sections() - for lib in all_libs: - if lib.type == "movie" or lib.type == "show": - LIB_ARRAY.append(lib.title.strip()) +LIB_ARRAY = get_target_libraries(plex) def sleep_for_a_while(): @@ -318,7 +284,7 @@ def track_completion(id_array, status_file, item_id): status_file_name = the_lib.uuid + ".txt" status_file = Path(status_file_name) - if get_overlay_status(plex, the_lib) and not OVERRIDE_OVERLAY_STATUS: + if get_overlay_status(the_lib) and not OVERRIDE_OVERLAY_STATUS: print("==================== ATTENTION ====================") print(f"Library: {lib}") print("This library appears to have Kometa overlays applied.") @@ -344,13 +310,14 @@ def track_completion(id_array, status_file, item_id): for lbl in LBL_ARRAY: if lbl == "xy22y1973": print(f"{os.linesep}getting all items from the library [{lib}]...") - library_items = get_all_from_library(plex, the_lib) + library_search_result = get_all_from_library(the_lib) REMOVE_LABELS = False else: print( f"{os.linesep}getting items from the library [{lib}] with the label [{lbl}]..." ) - library_items = the_lib.search(label=lbl) + library_search_result = the_lib.search(label=lbl) + library_items = library_search_result[1] item_total = len(library_items) plogger(f"{item_total} item(s) retrieved...", "info", "a") item_count = 1 @@ -359,7 +326,7 @@ def track_completion(id_array, status_file, item_id): item_count = item_count + 1 item_key = library_item.ratingKey item_title = library_item.title - imdbid, tmdb_id, tvdb_id = get_ids(library_item.guids, TMDB_KEY) + imdbid, tmdb_id, tvdb_id = get_ids(library_item.guids) logger( ( f"{item_title}: ratingKey: {item_key} imdbid: {imdbid} tmdb_id: {tmdb_id} tvdb_id: {tvdb_id}" @@ -570,7 +537,7 @@ def track_completion(id_array, status_file, item_id): ) plex_season.removeLabel(lbl, True) - if RESET_EPISODES: + if config.get_bool("reset.episodes"): # get episodes blogger( f"getting TMDB episodes for season: {tmdb_season.season_number}", @@ -779,7 +746,7 @@ def track_completion(id_array, status_file, item_id): the_lib.saveMultiEdits() # delete the status file - if not RETAIN_RESET_STATUS_FILE and not DRY_RUN: + if not config.get_bool("retain.reset_status_file") and not DRY_RUN: if status_file.is_file(): os.remove(status_file) diff --git a/Plex/crew-count.py b/Plex/crew-count.py index d6867d6..f965557 100644 --- a/Plex/crew-count.py +++ b/Plex/crew-count.py @@ -1,39 +1,23 @@ -import os import sys import textwrap from collections import Counter -from dotenv import load_dotenv -from helpers import booler -from plexapi.server import PlexServer +from config import Config +from helpers import get_ids, get_plex, get_redaction_list, get_target_libraries from tmdbapis import TMDbAPIs -load_dotenv() +config = Config('../config.yaml') -PLEX_URL = os.getenv("PLEX_URL") -PLEX_TOKEN = os.getenv("PLEX_TOKEN") -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -TMDB_KEY = os.getenv("TMDB_KEY") -TVDB_KEY = os.getenv("TVDB_KEY") -CREW_DEPTH = int(os.getenv("CREW_DEPTH")) -CREW_COUNT = int(os.getenv("CREW_COUNT")) -TARGET_JOB = os.getenv("TARGET_JOB") -DELAY = int(os.getenv("DELAY")) -SHOW_JOBS = booler(os.getenv("SHOW_JOBS")) +CREW_COUNT = config.get_int('crew.count') +TARGET_JOB = config.get('crew.target_job') +DELAY = config.get_int('general.delay') +SHOW_JOBS = config.get_bool('crew.show_jobs') if not DELAY: DELAY = 0 -if LIBRARY_NAMES: - lib_array = LIBRARY_NAMES.split(",") -else: - lib_array = [LIBRARY_NAME] -tmdb = TMDbAPIs(TMDB_KEY, language="en") - -tmdb_str = "tmdb://" -tvdb_str = "tvdb://" +tmdb = TMDbAPIs(str(config.get("general.tmdb_key", "NO_KEY_SPECIFIED")), language="en") individuals = Counter() jobs = Counter() @@ -42,17 +26,6 @@ COLL_TMPL = "" -def getTID(theList): - tmid = None - tvid = None - for guid in theList: - if tmdb_str in guid.id: - tmid = guid.id.replace(tmdb_str, "") - if tvdb_str in guid.id: - tvid = guid.id.replace(tvdb_str, "") - return tmid, tvid - - def progress(count, total, status=""): bar_len = 40 filled_len = int(round(bar_len * count / float(total))) @@ -65,8 +38,10 @@ def progress(count, total, status=""): sys.stdout.flush() -print("connecting to Plex...") -plex = PlexServer(PLEX_URL, PLEX_TOKEN) +plex = get_plex() + +lib_array = get_target_libraries(plex) + for lib in lib_array: print(f"getting items from [{lib}]...") items = plex.library.section(lib).all() @@ -76,18 +51,18 @@ def progress(count, total, status=""): for item in items: jobDict = {} tmpDict = {} - tmdb_id, tvdb_id = getTID(item.guids) + imdbid, tmid, tvid = get_ids(item.guids) item_count = item_count + 1 try: progress(item_count, item_total, item.title) crew = None if item.TYPE == "show": - crew = tmdb.tv_show(tmdb_id).crew + crew = tmdb.tv_show(tmid).crew else: - crew = tmdb.movie(tmdb_id).crew + crew = tmdb.movie(tmid).crew count = 0 for individual in crew: - if count < CREW_DEPTH: + if count < config.get_int('crew.depth'): count = count + 1 if individual.job == TARGET_JOB: tmpDict[f"{individual.name} - {individual.person_id}"] = 1 @@ -113,6 +88,6 @@ def progress(count, total, status=""): JOB_COUNT = len(jobs.items()) count = 0 - print(f"{JOB_COUNT} defined [{lib}]:") + print(f"{JOB_COUNT} jobs defined [{lib}]:") for job in sorted(jobs.items(), key=lambda x: x[1], reverse=True): print("{}\t{}".format(job[1], job[0])) diff --git a/Plex/delete-collections.py b/Plex/delete-collections.py index 7b93e45..83d9c7d 100644 --- a/Plex/delete-collections.py +++ b/Plex/delete-collections.py @@ -1,16 +1,16 @@ #!/usr/bin/env python -import os import time from datetime import datetime from pathlib import Path from alive_progress import alive_bar -from helpers import get_plex, load_and_upgrade_env +from config import Config +from helpers import get_plex, get_redaction_list, get_target_libraries from logs import plogger, setup_logger SCRIPT_NAME = Path(__file__).stem -VERSION = "0.1.0" +VERSION = "0.2.0" # current dateTime now = datetime.now() @@ -18,31 +18,20 @@ # convert to string RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") -env_file_path = Path(".env") - ACTIVITY_LOG = f"{SCRIPT_NAME}.log" setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -DELAY = int(os.getenv("DELAY")) -KEEP_COLLECTIONS = os.getenv("KEEP_COLLECTIONS") +DELAY = config.get_int('general.delay') +KEEP_COLLECTIONS = config.get('delete_collection.keep_collections') if not DELAY: DELAY = 0 -if LIBRARY_NAMES: - LIB_ARRAY = LIBRARY_NAMES.split(",") -else: - LIB_ARRAY = [LIBRARY_NAME] - -plogger(f"Acting on libraries: {LIB_ARRAY}", "info", "a") if KEEP_COLLECTIONS: keeper_array = KEEP_COLLECTIONS.split(",") @@ -51,12 +40,9 @@ plex = get_plex() -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - all_libs = plex.library.sections() - for lib in all_libs: - if lib.type == "movie" or lib.type == "show": - LIB_ARRAY.append(lib.title.strip()) +LIB_ARRAY = get_target_libraries(plex) + +plogger(f"Acting on libraries: {LIB_ARRAY}", "info", "a") coll_obj = {} coll_obj["collections"] = {} diff --git a/Plex/grab-all-IDs.py b/Plex/grab-all-IDs.py index 07f6f86..bdae944 100644 --- a/Plex/grab-all-IDs.py +++ b/Plex/grab-all-IDs.py @@ -5,8 +5,11 @@ from pathlib import Path from alive_progress import alive_bar -from database import get_completed, get_count, get_diffs, insert_record, update_record -from helpers import get_all_from_library, get_ids, get_plex, load_and_upgrade_env +from config import Config +from database import (get_completed, get_count, get_diffs, insert_record, + update_record) +from helpers import (get_all_from_library, get_ids, get_plex, + get_target_libraries) # current dateTime now = datetime.now() @@ -16,12 +19,11 @@ SCRIPT_NAME = Path(__file__).stem -VERSION = "0.1.1" +VERSION = "0.3.0" # 0.1.1 refactoring changes # 0.2.0 get rid of sqlalchemy, use the same database module as the others - -env_file_path = Path(".env") +# 0.3.0 use config module for configuration logging.basicConfig( filename=f"{SCRIPT_NAME}.log", @@ -33,20 +35,12 @@ logging.info(f"Starting {SCRIPT_NAME}") print(f"Starting {SCRIPT_NAME}") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") TMDB_KEY = os.getenv("TMDB_KEY") NEW = [] UPDATED = [] -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] - CHANGE_FILE_NAME = "changes.txt" change_file = Path(CHANGE_FILE_NAME) # Delete any existing change file @@ -55,6 +49,8 @@ plex = get_plex() +LIB_ARRAY = get_target_libraries(plex) + logging.info("connection success") @@ -74,7 +70,7 @@ def get_IDs(type, item): try: if item.type != "collection": logging.info("Getting IDs") - imdbid, tmid, tvid = get_ids(item.guids, TMDB_KEY) + imdbid, tmid, tvid = get_ids(item.guids) complete = ( imdbid is not None and tmid is not None and tvid is not None ) @@ -118,13 +114,6 @@ def get_IDs(type, item): COMPLETE_ARRAY = [] -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - all_libs = plex.library.sections() - for lib in all_libs: - if lib.type == "movie" or lib.type == "show": - LIB_ARRAY.append(lib.title.strip()) - with open(change_file, "a", encoding="utf-8") as cf: cf.write(f"start: {get_count()} records{os.linesep}") diff --git a/Plex/grab-all-info.py b/Plex/grab-all-info.py index 0828c25..51ec6a3 100644 --- a/Plex/grab-all-info.py +++ b/Plex/grab-all-info.py @@ -6,7 +6,9 @@ import sqlalchemy as db from alive_progress import alive_bar -from helpers import get_all_from_library, get_ids, get_plex, load_and_upgrade_env +from config import Config +from helpers import (get_all_from_library, get_ids, get_plex, + get_target_libraries) from sqlalchemy.dialects.sqlite import insert # current dateTime @@ -17,10 +19,9 @@ SCRIPT_NAME = Path(__file__).stem -VERSION = "0.1.0" +VERSION = "0.2.0" - -env_file_path = Path(".env") +# 0.2.0 use config module for configuration logging.basicConfig( filename=f"{SCRIPT_NAME}.log", @@ -32,20 +33,11 @@ logging.info(f"Starting {SCRIPT_NAME}") print(f"Starting {SCRIPT_NAME}") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -TMDB_KEY = os.getenv("TMDB_KEY") NEW = [] UPDATED = [] -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] - CHANGE_FILE_NAME = "changes.txt" change_file = Path(CHANGE_FILE_NAME) # Delete any existing change file @@ -170,8 +162,7 @@ def get_diffs(payload): plex = get_plex() -logging.info("connection success") - +LIB_ARRAY = get_target_libraries(plex) def get_IDs(type, item): imdbid = None @@ -189,7 +180,7 @@ def get_IDs(type, item): try: if item.type != "collection": logging.info("Getting IDs") - imdbid, tmid, tvid = get_ids(item.guids, TMDB_KEY) + imdbid, tmid, tvid = get_ids(item.guids) complete = ( imdbid is not None and tmid is not None and tvid is not None ) @@ -232,13 +223,6 @@ def get_IDs(type, item): COMPLETE_ARRAY = [] -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - all_libs = plex.library.sections() - for lib in all_libs: - if lib.type == "movie" or lib.type == "show": - LIB_ARRAY.append(lib.title.strip()) - with open(change_file, "a", encoding="utf-8") as cf: cf.write(f"start: {get_count()} records{os.linesep}") @@ -254,8 +238,8 @@ def get_IDs(type, item): count = plex.library.section(lib).totalSize print(f"getting {count} {the_lib.type}s from [{lib}]...") logging.info(f"getting {count} {the_lib.type}s from [{lib}]...") - items = get_all_from_library(the_lib) - # items = the_lib.all() + search_results = get_all_from_library(the_lib) + items = search_results[1] item_total = len(items) logging.info(f"looping over {item_total} items...") item_count = 1 diff --git a/Plex/grab-all-posters.py b/Plex/grab-all-posters.py index 3a87148..f1b4144 100644 --- a/Plex/grab-all-posters.py +++ b/Plex/grab-all-posters.py @@ -10,19 +10,13 @@ import filetype from alive_progress import alive_bar -from database import add_key, add_last_run, add_url, check_key, check_url, get_last_run -from helpers import ( - booler, - check_for_images, - get_all_from_library, - get_ids, - get_letter_dir, - get_plex, - has_overlay, - load_and_upgrade_env, - redact, - validate_filename, -) +from config import Config +from database import (add_key, add_last_run, add_url, check_key, check_url, + get_last_run) +from helpers import (check_for_images, get_all_from_library, get_ids, + get_letter_dir, get_plex, get_redaction_list, + get_target_libraries, has_overlay, redact, + validate_filename) from logs import blogger, logger, plogger, setup_logger from pathvalidate import sanitize_filename from plexapi.utils import download @@ -84,9 +78,8 @@ VERSION = "0.8.9c" -env_file_path = Path(".env") +config = Config('../config.yaml') -print(f"{env_file_path.read_text()}") # current dateTime now = datetime.now() @@ -110,9 +103,6 @@ def superchat(msg, level, logfile): plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() - ID_FILES = True URL_ARRAY = [] @@ -122,15 +112,7 @@ def superchat(msg, level, logfile): STOP_FILE_NAME = "stop.dat" SKIP_FILE_NAME = "skip.dat" -try: - DEFAULT_YEARS_BACK = abs(int(os.getenv("DEFAULT_YEARS_BACK"))) -except: - plogger( - f"DEFAULT_YEARS_BACK: {os.getenv('DEFAULT_YEARS_BACK')} not an integer. Defaulting to 1", - "info", - "a", - ) - DEFAULT_YEARS_BACK = 1 +DEFAULT_YEARS_BACK = abs(config.get_int('image_download.general.default_years_back', 1)) WEEKS_BACK = 52 * DEFAULT_YEARS_BACK @@ -143,43 +125,17 @@ def superchat(msg, level, logfile): if IS_WINDOWS and fallback_date is not None and fallback_date < epoch: fallback_date = None -PLEX_URL = ( - os.getenv("PLEX_URL") - if os.getenv("PLEX_URL") - else os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") -) -PLEX_TOKEN = ( - os.getenv("PLEX_TOKEN") - if os.getenv("PLEX_TOKEN") - else os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") -) - -if PLEX_URL.endswith("/"): - PLEX_URL = PLEX_URL[:-1] - -if PLEX_URL is None or PLEX_URL == "https://plex.domain.tld": - plogger("You must specify a PLEX URL in the .env file.", "info", "a") - exit() - -if PLEX_TOKEN is None or PLEX_TOKEN == "PLEX-TOKEN": - plogger("You must specify A PLEX TOKEN in the .env file.", "info", "a") - exit() -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -POSTER_DIR = os.getenv("POSTER_DIR") +POSTER_DIR = config.get('image_download.general.poster_dir') -SUPERCHAT = os.getenv("SUPERCHAT") +SUPERCHAT = config.get('general.superchat') if POSTER_DIR is None: POSTER_DIR = "extracted_posters" -try: - POSTER_DEPTH = int(os.getenv("POSTER_DEPTH")) -except: - POSTER_DEPTH = 0 +POSTER_DEPTH = config.get_int('image_download.general.poster_depth', 0) -POSTER_DOWNLOAD = booler(os.getenv("POSTER_DOWNLOAD")) +POSTER_DOWNLOAD = config.get_bool('image_download.general.poster_download', True) if not POSTER_DOWNLOAD: print("================== ATTENTION ==================") print("Downloading disabled; file identification not possible") @@ -187,44 +143,43 @@ def superchat(msg, level, logfile): print("================== ATTENTION ==================") ID_FILES = False -POSTER_CONSOLIDATE = booler(os.getenv("POSTER_CONSOLIDATE")) -INCLUDE_COLLECTION_ARTWORK = booler(os.getenv("INCLUDE_COLLECTION_ARTWORK")) -ONLY_COLLECTION_ARTWORK = booler(os.getenv("ONLY_COLLECTION_ARTWORK")) -DELAY = int(os.getenv("DELAY")) +POSTER_CONSOLIDATE = config.get_bool('image_download.general.poster_consolidate', True) +INCLUDE_COLLECTION_ARTWORK = config.get_bool('image_download.image.include_collection_artwork', True) +ONLY_COLLECTION_ARTWORK = config.get_bool('image_download.image.only_collection_artwork', False) -GRAB_BACKGROUNDS = booler(os.getenv("GRAB_BACKGROUNDS")) -GRAB_SEASONS = booler(os.getenv("GRAB_SEASONS")) -ONLY_SEASONS = booler(os.getenv("ONLY_SEASONS")) +DELAY = config.get_int('general.delay', 1) -GRAB_EPISODES = booler(os.getenv("GRAB_EPISODES")) -ONLY_EPISODES = booler(os.getenv("ONLY_EPISODES")) +GRAB_BACKGROUNDS = config.get_bool('image_download.general.grab_backgrounds', True) +GRAB_SEASONS = config.get_bool('image_download.general.grab_seasons', True) +ONLY_SEASONS = config.get_bool('image_download.general.only_seasons', False) -ONLY_CURRENT = booler(os.getenv("ONLY_CURRENT")) +GRAB_EPISODES = config.get_bool('image_download.general.grab_episodes', True) +ONLY_EPISODES = config.get_bool('image_download.general.only_episodes', False) + +ONLY_CURRENT = config.get_bool('image_download.general.only_current', False) if ONLY_CURRENT: - POSTER_DIR = os.getenv("CURRENT_POSTER_DIR") + POSTER_DIR = config.get('image_download.general.current_poster_dir', 'current_posters') -TRACK_URLS = booler(os.getenv("TRACK_URLS")) -TRACK_COMPLETION = booler(os.getenv("TRACK_COMPLETION")) +TRACK_URLS = config.get_bool('image_download.general.track_urls', True) +TRACK_COMPLETION = config.get_bool('image_download.general.track_completion', False) -ASSET_DIR = os.getenv("ASSET_DIR") -if ASSET_DIR is None: - ASSET_DIR = "assets" +ASSET_DIR = config.get('image_download.general.asset_dir', 'assets') ASSET_PATH = Path(ASSET_DIR) -USE_ASSET_NAMING = booler(os.getenv("USE_ASSET_NAMING")) -USE_ASSET_FOLDERS = booler(os.getenv("USE_ASSET_FOLDERS")) -ASSETS_BY_LIBRARIES = booler(os.getenv("ASSETS_BY_LIBRARIES")) -NO_FS_WARNING = booler(os.getenv("NO_FS_WARNING")) -ADD_SOURCE_EXIF_COMMENT = booler(os.getenv("ADD_SOURCE_EXIF_COMMENT")) +USE_ASSET_NAMING = config.get_bool('image_download.general.use_asset_naming', False) +USE_ASSET_FOLDERS = config.get_bool('image_download.general.use_asset_folders', False) +ASSETS_BY_LIBRARIES = config.get_bool('image_download.general.assets_by_libraries', False) +NO_FS_WARNING = config.get_bool('image_download.general.no_fs_warning', False) +ADD_SOURCE_EXIF_COMMENT = config.get_bool('image_download.general.add_source_exif_comment', False) SRC_ARRAY = [] -TRACK_IMAGE_SOURCES = booler(os.getenv("TRACK_IMAGE_SOURCES")) -IGNORE_SHRINKING_LIBRARIES = booler(os.getenv("IGNORE_SHRINKING_LIBRARIES")) -RETAIN_OVERLAID_IMAGES = booler(os.getenv("RETAIN_OVERLAID_IMAGES")) -FIND_OVERLAID_IMAGES = booler(os.getenv("FIND_OVERLAID_IMAGES")) -RETAIN_KOMETA_OVERLAID_IMAGES = booler(os.getenv("RETAIN_TCM_IMAGES")) -RETAIN_TCM_OVERLAID_IMAGES = booler(os.getenv("RETAIN_TCM_IMAGES")) +TRACK_IMAGE_SOURCES = config.get_bool('image_download.general.track_image_sources', False) +IGNORE_SHRINKING_LIBRARIES = config.get_bool('image_download.general.ignore_shrinking_libraries', False) +RETAIN_OVERLAID_IMAGES = config.get_bool('image_download.general.retain_overlaid_images', False) +FIND_OVERLAID_IMAGES = config.get_bool('image_download.general.find_overlaid_images', False) +RETAIN_KOMETA_OVERLAID_IMAGES = config.get_bool('image_download.general.retain_kometa_overlaid_images', False) +RETAIN_TCM_OVERLAID_IMAGES = config.get_bool('image_download.general.retain_tcm_overlaid_images', False) if RETAIN_OVERLAID_IMAGES: RETAIN_KOMETA_OVERLAID_IMAGES = RETAIN_OVERLAID_IMAGES @@ -237,8 +192,8 @@ def superchat(msg, level, logfile): USE_ASSET_SUBFOLDERS = False FOLDERS_ONLY = False else: - USE_ASSET_SUBFOLDERS = booler(os.getenv("USE_ASSET_SUBFOLDERS")) - FOLDERS_ONLY = booler(os.getenv("FOLDERS_ONLY")) + USE_ASSET_SUBFOLDERS = config.get_bool('image_download.general.use_asset_subfolders', False) + FOLDERS_ONLY = config.get_bool('image_download.general.folders_only', False) if FOLDERS_ONLY: ONLY_CURRENT = FOLDERS_ONLY if ASSET_DIR is None: @@ -266,7 +221,7 @@ def superchat(msg, level, logfile): if not DELAY: DELAY = 0 -KEEP_JUNK = booler(os.getenv("KEEP_JUNK")) +KEEP_JUNK = config.get_bool('image_download.general.keep_junk', False) SCRIPT_FILE = "get_images.sh" SCRIPT_SEED = f"#!/bin/bash{os.linesep}{os.linesep}# SCRIPT TO GRAB IMAGES{os.linesep}{os.linesep}" @@ -280,14 +235,14 @@ def superchat(msg, level, logfile): if POSTER_DOWNLOAD: SCRIPT_STRING = SCRIPT_SEED -RESET_LIBRARIES = os.getenv("RESET_LIBRARIES") +RESET_LIBRARIES = config.get('image_download.general.reset_libraries', False) if RESET_LIBRARIES: RESET_ARRAY = [s.strip() for s in RESET_LIBRARIES.split(",")] else: RESET_ARRAY = ["PLACEHOLDER_VALUE_XYZZY"] -RESET_COLLECTIONS = os.getenv("RESET_COLLECTIONS") +RESET_COLLECTIONS = config.get('image_download.general.reset_collections', False) if RESET_COLLECTIONS: RESET_COLL_ARRAY = [s.strip() for s in RESET_COLLECTIONS.split(",")] @@ -295,57 +250,25 @@ def superchat(msg, level, logfile): RESET_COLL_ARRAY = ["PLACEHOLDER_VALUE_XYZZY"] -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] - -ONLY_THESE_COLLECTIONS = os.getenv("ONLY_THESE_COLLECTIONS") +ONLY_THESE_COLLECTIONS = config.get('image_download.what_to_grab.only_these_collections', '') if ONLY_THESE_COLLECTIONS: COLLECTION_ARRAY = [s.strip() for s in ONLY_THESE_COLLECTIONS.split("|")] else: COLLECTION_ARRAY = [] -THREADED_DOWNLOADS = booler(os.getenv("THREADED_DOWNLOADS")) +THREADED_DOWNLOADS = config.get_bool('image_download.general.threaded_downloads', False) plogger(f"Threaded downloads: {THREADED_DOWNLOADS}", "info", "a") -imdb_str = "imdb://" -tmdb_str = "tmdb://" -tvdb_str = "tvdb://" - -redaction_list = [] -redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_BASEURL")) -redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_TOKEN")) +redaction_list = get_redaction_list() plex = get_plex() -logger("Plex connection succeeded", "info", "a") - +LIB_ARRAY = get_target_libraries(plex) def lib_type_supported(lib): return lib.type == "movie" or lib.type == "show" - -ALL_LIBS = plex.library.sections() -ALL_LIB_NAMES = [] - -logger(f"{len(ALL_LIBS)} libraries found:", "info", "a") -for lib in ALL_LIBS: - logger( - f"{lib.title.strip()}: {lib.type} - supported: {lib_type_supported(lib)}", - "info", - "a", - ) - ALL_LIB_NAMES.append(f"{lib.title.strip()}") - -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - for lib in ALL_LIBS: - if lib_type_supported(lib): - LIB_ARRAY.append(lib.title.strip()) - - def get_asset_names(item): ret_val = {} item_file = None @@ -1230,16 +1153,17 @@ def add_script_line(artwork_path, poster_file_path, src_URL_with_token): for lib in LIB_ARRAY: - if lib in ALL_LIB_NAMES: - try: - highwater = 0 - start_queue_length = len(my_futures) - if len(my_futures) > 0: - plogger(f"queue length: {len(my_futures)}", "info", "a") + try: + highwater = 0 + start_queue_length = len(my_futures) + + if len(my_futures) > 0: + plogger(f"queue length: {len(my_futures)}", "info", "a") - plogger(f"Loading {lib} ...", "info", "a") - the_lib = plex.library.section(lib) + plogger(f"Loading {lib} ...", "info", "a") + the_lib = plex.library.section(lib) + if lib_type_supported(the_lib): the_uuid = the_lib.uuid superchat(f"{the_lib} uuid {the_uuid}", "info", "a") @@ -1555,29 +1479,23 @@ def add_script_line(artwork_path, poster_file_path, src_URL_with_token): with open(SCRIPT_FILE, "w", encoding="utf-8") as myfile: myfile.write(f"{SCRIPT_STRING}{os.linesep}") - except StopIteration: - if stop_file.is_file(): - progress_str = "stop file found, leaving loop" - if skip_file.is_file(): - progress_str = "skip file found, skipping library" + except StopIteration: + if stop_file.is_file(): + progress_str = "stop file found, leaving loop" + if skip_file.is_file(): + progress_str = "skip file found, skipping library" - plogger(progress_str, "info", "a") + plogger(progress_str, "info", "a") - if stop_file.is_file(): - stop_file.unlink() - break - if skip_file.is_file(): - skip_file.unlink() + if stop_file.is_file(): + stop_file.unlink() + break + if skip_file.is_file(): + skip_file.unlink() - except Exception as ex: - progress_str = f"Problem processing {lib}; {ex}" - plogger(progress_str, "info", "a") - else: - logger( - f"Library {lib} not found: available libraries on this server are: {ALL_LIB_NAMES}", - "info", - "a", - ) + except Exception as ex: + progress_str = f"Problem processing {lib}; {ex}" + plogger(progress_str, "info", "a") idx = 1 max = len(my_futures) diff --git a/Plex/grab-all-status.py b/Plex/grab-all-status.py index 7bf0dc6..7b18c92 100644 --- a/Plex/grab-all-status.py +++ b/Plex/grab-all-status.py @@ -1,13 +1,14 @@ #!/usr/bin/env python + import json import os -import sys -import textwrap from datetime import datetime from pathlib import Path from alive_progress import alive_bar -from helpers import get_plex, get_xml_libraries, get_xml_watched, load_and_upgrade_env +from config import Config +from helpers import (get_plex, get_redaction_list, get_target_libraries, + get_xml_libraries, get_xml_watched) from logs import plogger, setup_logger # current dateTime @@ -18,49 +19,26 @@ SCRIPT_NAME = Path(__file__).stem -VERSION = "0.1.1" +VERSION = "0.2.0" # DONE 0.1.1: guard against empty library map - -env_file_path = Path(".env") +# DONE 0.2.0: config class ACTIVITY_LOG = f"{SCRIPT_NAME}.log" setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() - -target_url_var = "PLEX_URL" -PLEX_URL = os.getenv(target_url_var) -if PLEX_URL is None: - target_url_var = "PLEXAPI_AUTH_SERVER_BASEURL" - PLEX_URL = os.getenv(target_url_var) - -target_token_var = "PLEX_TOKEN" -PLEX_TOKEN = os.getenv(target_token_var) -if PLEX_TOKEN is None: - target_token_var = "PLEXAPI_AUTH_SERVER_TOKEN" - PLEX_TOKEN = os.getenv(target_token_var) - -if PLEX_URL is None or PLEX_URL == "https://plex.domain.tld": - plogger(f"You must specify {target_url_var} in the .env file.", "info", "a") - exit() +config = Config('../config.yaml') -if PLEX_TOKEN is None or PLEX_TOKEN == "PLEX-TOKEN": - plogger(f"You must specify {target_token_var} in the .env file.", "info", "a") - exit() - -PLEX_OWNER = os.getenv("PLEX_OWNER") - -LIBRARY_MAP = os.getenv("LIBRARY_MAP", "{}") +PLEX_URL = config.get('plex_api.auth_server.base_url') +PLEX_TOKEN = config.get('plex_api.auth_server.token') try: - lib_map = json.loads(LIBRARY_MAP) + lib_map = json.loads(config.get("status.library_map", "{}")) except: plogger( - "LIBRARY_MAP in the .env file appears to be broken. Defaulting to an empty list.", + "LIBRARY_MAP in the config.yaml appears to be broken. Defaulting to an empty list.", "info", "a", ) @@ -122,10 +100,11 @@ def process_section(username, section): padwidth = 95 count = 0 -connected_plex_user = PLEX_OWNER +connected_plex_user = config.get("status.plex_owner") connected_plex_library = "" plex = get_plex() + PMI = plex.machineIdentifier account = plex.myPlexAccount() @@ -134,6 +113,7 @@ def process_section(username, section): file_string = "" DO_NOTHING = False + print(f"------------ {account.username} ------------") try: # plex_sections = plex.library.sections() diff --git a/Plex/grab-imdb-posters.py b/Plex/grab-imdb-posters.py index 9a86d8b..b599c1b 100644 --- a/Plex/grab-imdb-posters.py +++ b/Plex/grab-imdb-posters.py @@ -7,7 +7,8 @@ from pathlib import Path import imdb -from helpers import booler, get_ids, get_plex, load_and_upgrade_env +from config import Config +from helpers import get_ids, get_plex, get_redaction_list, get_target_libraries # current dateTime now = datetime.now() @@ -19,8 +20,6 @@ VERSION = "0.1.0" -env_file_path = Path(".env") - logging.basicConfig( filename=f"{SCRIPT_NAME}.log", filemode="w", @@ -31,34 +30,15 @@ logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") print(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() - -TMDB_KEY = os.getenv("TMDB_KEY") -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -POSTER_DIR = os.getenv("POSTER_DIR") -POSTER_DEPTH = int(os.getenv("POSTER_DEPTH")) -POSTER_DOWNLOAD = booler(os.getenv("POSTER_DOWNLOAD")) -POSTER_CONSOLIDATE = booler(os.getenv("POSTER_CONSOLIDATE")) +config = Config('../config.yaml') -if POSTER_DEPTH is None: - POSTER_DEPTH = 0 +POSTER_CONSOLIDATE = config.get_bool("image_download.general.poster_consolidate") -if POSTER_DOWNLOAD: - script_string = f'#!/bin/bash{os.linesep}{os.linesep}# SCRIPT TO DO STUFF{os.linesep}{os.linesep}cd "{POSTER_DIR}"{os.linesep}{os.linesep}' +if config.get_bool("image_download.general.poster_download"): + script_string = f'#!/bin/bash{os.linesep}{os.linesep}# SCRIPT TO DO STUFF{os.linesep}{os.linesep}cd "{config.get("image_download.where_to_put_it.poster_dir")}"{os.linesep}{os.linesep}' else: script_string = "" -if LIBRARY_NAMES: - lib_array = LIBRARY_NAMES.split(",") -else: - lib_array = [LIBRARY_NAME] - -imdb_str = "imdb://" -tmdb_str = "tmdb://" -tvdb_str = "tvdb://" - def progress(count, total, status=""): bar_len = 40 @@ -76,9 +56,11 @@ def progress(count, total, status=""): plex = get_plex() +LIB_ARRAY = get_target_libraries(plex) + logging.info("connection success") -for lib in lib_array: +for lib in LIB_ARRAY: print(f"getting items from [{lib}]...") items = plex.library.section(lib).all() item_total = len(items) diff --git a/Plex/helpers.py b/Plex/helpers.py index 8b46f4c..76c35ae 100644 --- a/Plex/helpers.py +++ b/Plex/helpers.py @@ -1,16 +1,70 @@ +import getpass +import hashlib import itertools +import json import os import shutil from pathlib import Path import plexapi import requests +from config import Config from dotenv import load_dotenv, set_key, unset_key from pathvalidate import is_valid_filename, sanitize_filename from PIL import Image from plexapi.exceptions import Unauthorized +from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer +# Your fixed client identifier +CLIENT_IDENTIFIER = 'MediaScripts-chazlarson' +# File to store token + server URL +AUTH_FILE = '.plex_auth.json' +# Default network timeout (seconds) +DEFAULT_TIMEOUT = 360 + +stock_md5 = { + "plexapi.config.ini": '6209bb0c2ab877e6b74f757a004c84c9', +} + +def file_has_changed(filepath): + """ + Calculates the MD5 checksum of a file. + + Args: + filepath: The path to the file. + + Returns: + The MD5 checksum as a hexadecimal string. + """ + if filepath.name not in stock_md5: + print(f"File {filepath.name} not in stock_md5, returning True") + return True + old_hash = stock_md5.get(filepath.name) + md5_hash = hashlib.md5() + with open(filepath, "rb") as file: + # Read the file in chunks to handle large files efficiently + for chunk in iter(lambda: file.read(4096), b""): + md5_hash.update(chunk) + new_hash = md5_hash.hexdigest() + return new_hash != old_hash + + +def copy_file(source_path, destination_path): + """Copies a file from source to destination using pathlib. + + Args: + source_path (str or Path): Path to the source file. + destination_path (str or Path): Path to the destination file. + """ + source_path = Path(source_path) + destination_path = Path(destination_path) + + if source_path.is_file(): + shutil.copy(source_path, destination_path) + print(f"File copied from {source_path} to {destination_path}") + else: + print(f"Source path {source_path} is not a file.") def has_overlay(image_path): kometa_overlay = False @@ -49,38 +103,161 @@ def redact(the_url, str_list): return ret_val -def get_plex(user_token=None): - print(f"connecting to {os.getenv('PLEXAPI_AUTH_SERVER_BASEURL')}...") - plex = None +def load_auth(): + """Return saved auth dict or None.""" try: - session = None - if booler(os.getenv("PLEXAPI_SKIP_VERIFYSSL", False)): - session = requests.Session() - session.verify = False - import urllib3 + with open(AUTH_FILE, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None - urllib3.disable_warnings() - if user_token is not None: - plex = PlexServer(token=user_token, session=session) - else: - plex = PlexServer(session=session) - except Unauthorized: - print("Plex Error: Plex token is invalid") - raise Unauthorized +def save_auth(data): + """Save auth dict and lock down file permissions.""" + with open(AUTH_FILE, 'w') as f: + json.dump(data, f) + try: + os.chmod(AUTH_FILE, 0o600) + except Exception: + pass + +def choose_server(servers): + """Prompt the user to choose one of the available Plex Media Server resources.""" + print("\nAvailable Plex Media Servers:") + for idx, res in enumerate(servers, start=1): + print(f" [{idx}] {res.name} ({res.clientIdentifier})") + while True: + choice = input(f"Select server [1–{len(servers)}]: ").strip() + if choice.isdigit(): + idx = int(choice) + if 1 <= idx <= len(servers): + return servers[idx-1] + print("❌ Invalid selection; please enter a number from the list.") + +def get_timeout(): + """Prompt user for network timeout value, with a safe default.""" + val = input(f"Network timeout in seconds [default {DEFAULT_TIMEOUT}]: ").strip() + if not val: + return DEFAULT_TIMEOUT + try: + t = float(val) + if t <= 0: + raise ValueError() + return t + except ValueError: + print(f"⚠️ Invalid timeout '{val}', using default {DEFAULT_TIMEOUT}.") + return DEFAULT_TIMEOUT + +def get_skip_ssl(): + """Prompt user whether to skip SSL certificate verification.""" + val = input("Skip SSL certificate verification? [y/N]: ").strip().lower() + return val in ('y', 'yes') + +def make_session(skip_ssl): + """Return a requests.Session configured for SSL verification or not.""" + if skip_ssl: + sess = requests.Session() + sess.verify = False + return sess + return None + +def do_login(timeout, session): + """Prompt for user/pass, let user pick server, connect & return PlexServer.""" + username = input('Plex Username: ') + password = getpass.getpass('Plex Password: ') + account = MyPlexAccount(username, password) + print(f"✔ Logged in as {account.username}") + + servers = [r for r in account.resources() if r.product == 'Plex Media Server'] + if not servers: + raise RuntimeError("No Plex Media Server found on your account.") + + resource = choose_server(servers) + print(f"→ Connecting to server: {resource.name} (timeout={timeout}s)") + + # resource.connect accepts a `session` and `timeout` argument + plex = resource.connect(timeout=timeout, session=session) + print(f"✔ Connected to Plex server: {plex.friendlyName}") + + token = getattr(account, 'authenticationToken', None) or getattr(account, '_token') + baseurl = getattr(plex, 'baseurl', None) or getattr(plex, '_baseurl') + save_auth({'token': token, 'baseurl': baseurl}) + print(f"⚑ Saved auth to {AUTH_FILE}") + return plex + + +def get_plex(): + plex = None + config = Config('../config.yaml') + os.environ['PLEXAPI_HEADER_IDENTIFIER'] = f"{config.get('plex_api.header_identifier')}" + os.environ['PLEXAPI_PLEXAPI_TIMEOUT'] = f"{config.get('plex_api.timeout')}" + os.environ['PLEXAPI_AUTH_SERVER_BASEURL'] = f"{config.get('plex_api.auth_server.base_url')}" + os.environ['PLEXAPI_AUTH_SERVER_TOKEN'] = f"{config.get('plex_api.auth_server.token')}" + os.environ['PLEXAPI_LOG_BACKUP_COUNT'] = f"{config.get('plex_api.log.backup_count')}" + os.environ['PLEXAPI_LOG_FORMAT'] = f"{config.get('plex_api.log.format')}" + os.environ['PLEXAPI_LOG_LEVEL'] = f"{config.get('plex_api.log.level')}" + os.environ['PLEXAPI_LOG_PATH'] = f"{config.get('plex_api.log.path')}" + os.environ['PLEXAPI_LOG_ROTATE_BYTES'] = f"{config.get('plex_api.log.rotate_bytes')}" + os.environ['PLEXAPI_LOG_SHOW_SECRETS'] = f"{config.get('plex_api.log.show_secrets')}" + os.environ['PLEXAPI_SKIP_VERIFYSSL'] = f"{config.get('plex_api.skip_verify_ssl')}" # ignore self signed certificate errors + + try: + print("creating plex with plexapi config") + plex = PlexServer() + print(f"connected to {plex.friendlyName}") except Exception as ex: - print(f"Plex Error: {ex.args}") - raise ex + print(f"plexapi config failed: {ex}") + auth = load_auth() + if auth: + try: + print("creating plex with saved auth") + plex = PlexServer(auth['url'], token=auth['token']) + print(f"connected to {plex.friendlyName}") + except Unauthorized: + print("Saved auth is invalid. Please re-authenticate.") + auth = None + else: + print("No saved auth found. Please authenticate.") + timeout = get_timeout() + skip_ssl = get_skip_ssl() + session = make_session(skip_ssl) + plex = do_login(timeout, session) return plex +def get_target_libraries(plex): + if plex: + ALL_LIBS = plex.library.sections() + else: + print(f"Plex connection failed") + return None + + print(f"{len(ALL_LIBS)} libraries found") + + config = Config() + + LIBRARY_NAMES = config.get("general.library_names") + + if LIBRARY_NAMES and len(LIBRARY_NAMES) > 0: + LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] + else: + LIB_ARRAY = None + print(f"No libraries specified in config") + print(f"Processing all {len(ALL_LIBS)} libraries") + + if LIB_ARRAY is None: + LIB_ARRAY = [] + for lib in ALL_LIBS: + LIB_ARRAY.append(f"{lib.title.strip()}") + + return LIB_ARRAY imdb_str = "imdb://" tmdb_str = "tmdb://" tvdb_str = "tvdb://" -def get_ids(theList, TMDB_KEY): +def get_ids(theList): imdbid = None tmid = None tvid = None @@ -95,12 +272,6 @@ def get_ids(theList, TMDB_KEY): return imdbid, tmid, tvid -# def imdb_from_tmdb(tmdb_id, TMDB_KEY): -# tmdb = TMDbAPIs(TMDB_KEY, language="en") - -# # https://api.themoviedb.org/3/movie/{movie_id}/external_ids?api_key=<> - - def validate_filename(filename): # return filename if is_valid_filename(filename): @@ -445,7 +616,7 @@ def load_and_upgrade_env(file_path): src_file = os.path.join(".", ".env.example") tgt_file = os.path.join(".", ".env") shutil.copyfile(src_file, tgt_file) - print("Please edit .env file to suit and rerun script.") + print("Please edit config.yaml to suit and rerun script.") else: print("No example [.env.example] file. Cannot create base file.") status = -1 @@ -521,15 +692,15 @@ def load_and_upgrade_env(file_path): os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") is None or os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") == "https://plex.domain.tld" ): - print("You must specify PLEXAPI_AUTH_SERVER_BASEURL in the .env file.") - status = -1 + print("You must specify PLEXAPI_AUTH_SERVER_BASEURL in the config.yaml.") + # status = -1 if ( os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") is None or os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") == "PLEX-TOKEN" ): - print("You must specify PLEXAPI_AUTH_SERVER_TOKEN in the .env file.") - status = -1 + print("You must specify PLEXAPI_AUTH_SERVER_TOKEN in the config.yaml.") + # status = -1 return status diff --git a/Plex/import-IDs.py b/Plex/import-IDs.py index 74dc87b..1167fa6 100644 --- a/Plex/import-IDs.py +++ b/Plex/import-IDs.py @@ -1,13 +1,12 @@ #!/usr/bin/env python import ast import logging -import os from datetime import datetime from pathlib import Path import sqlalchemy as db from alive_progress import alive_bar -from dotenv import load_dotenv +from config import Config from sqlalchemy.dialects.sqlite import insert # current dateTime @@ -20,9 +19,6 @@ VERSION = "0.1.0" - -env_file_path = Path(".env") - logging.basicConfig( filename=f"{SCRIPT_NAME}.log", filemode="w", @@ -175,12 +171,8 @@ def get_diffs(payload): level=logging.INFO, ) -if os.path.exists(".env"): - load_dotenv() -else: - logging.info("No environment [.env] file. Exiting.") - print("No environment [.env] file. Exiting.") - exit() + +config = Config('../config.yaml') change_records = None diff --git a/Plex/list-collections.py b/Plex/list-collections.py index 9f48286..28a396e 100644 --- a/Plex/list-collections.py +++ b/Plex/list-collections.py @@ -1,12 +1,12 @@ #!/usr/bin/env python import logging -import os import time from datetime import datetime from pathlib import Path from alive_progress import alive_bar -from helpers import get_plex, load_and_upgrade_env +from config import Config +from helpers import get_plex, get_redaction_list, get_target_libraries SCRIPT_NAME = Path(__file__).stem @@ -18,8 +18,6 @@ # convert to string RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") -env_file_path = Path(".env") - logging.basicConfig( filename=f"{SCRIPT_NAME}.log", filemode="w", @@ -30,23 +28,14 @@ logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") print(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") -if load_and_upgrade_env(env_file_path) < 0: - exit() - -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -DELAY = int(os.getenv("DELAY")) +config = Config('../config.yaml') -if not DELAY: - DELAY = 0 - -if LIBRARY_NAMES: - lib_array = LIBRARY_NAMES.split(",") -else: - lib_array = [LIBRARY_NAME] +DELAY = config.get_int("settings.delay", 0) plex = get_plex() +LIB_ARRAY = get_target_libraries(plex) + coll_obj = {} coll_obj["collections"] = {} @@ -56,7 +45,7 @@ def get_sort_text(argument): return switcher.get(argument, "invalid-sort") -for lib in lib_array: +for lib in LIB_ARRAY: print(f"{lib} collection(s):") movies = plex.library.section(lib) items = movies.collections() diff --git a/Plex/list-item-ids.py b/Plex/list-item-ids.py index e82079a..dfdd6ec 100644 --- a/Plex/list-item-ids.py +++ b/Plex/list-item-ids.py @@ -5,21 +5,15 @@ from pathlib import Path from alive_progress import alive_bar -from helpers import ( - booler, - get_all_from_library, - get_ids, - get_plex, - load_and_upgrade_env, -) +from config import Config +from helpers import (get_all_from_library, get_ids, get_plex, + get_target_libraries) from logs import logger, plogger, setup_logger SCRIPT_NAME = Path(__file__).stem VERSION = "0.1.0" -env_file_path = Path(".env") - # current dateTime now = datetime.now() @@ -43,8 +37,7 @@ def superchat(msg, level, logfile): plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') ID_FILES = True @@ -52,104 +45,39 @@ def superchat(msg, level, logfile): # no one using this yet # QUEUED_DOWNLOADS = {} -target_url_var = "PLEX_URL" -PLEX_URL = os.getenv(target_url_var) -if PLEX_URL is None: - target_url_var = "PLEXAPI_AUTH_SERVER_BASEURL" - PLEX_URL = os.getenv(target_url_var) - -target_token_var = "PLEX_TOKEN" -PLEX_TOKEN = os.getenv(target_token_var) -if PLEX_TOKEN is None: - target_token_var = "PLEXAPI_AUTH_SERVER_TOKEN" - PLEX_TOKEN = os.getenv(target_token_var) - -if PLEX_URL is None or PLEX_URL == "https://plex.domain.tld": - plogger(f"You must specify {target_url_var} in the .env file.", "info", "a") - exit() - -if PLEX_TOKEN is None or PLEX_TOKEN == "PLEX-TOKEN": - plogger(f"You must specify {target_token_var} in the .env file.", "info", "a") - exit() +POSTER_DIR = config.get("image_download.where_to_put_it.poster_dir", "extracted_posters") -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -POSTER_DIR = os.getenv("POSTER_DIR") -SUPERCHAT = os.getenv("SUPERCHAT") +SUPERCHAT = config.get("general.superchat", False) -INCLUDE_COLLECTION_MEMBERS = booler(os.getenv("INCLUDE_COLLECTION_MEMBERS")) -ONLY_COLLECTION_MEMBERS = booler(os.getenv("ONLY_COLLECTION_MEMBERS")) -DELAY = int(os.getenv("DELAY")) +INCLUDE_COLLECTION_MEMBERS = config.get_bool("list_item_ids.include_collection_members", False) +ONLY_COLLECTION_MEMBERS = config.get_bool("list_item_ids.only_collection_members", False) +DELAY = config.get_int("general.delay", 0) -if not DELAY: - DELAY = 0 - -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] - -ONLY_THESE_COLLECTIONS = os.getenv("ONLY_THESE_COLLECTIONS") +ONLY_THESE_COLLECTIONS = config.get("list_item_ids.only_these_collections", "").strip() if ONLY_THESE_COLLECTIONS: COLLECTION_ARRAY = [s.strip() for s in ONLY_THESE_COLLECTIONS.split("|")] else: COLLECTION_ARRAY = [] -imdb_str = "imdb://" -tmdb_str = "tmdb://" -tvdb_str = "tvdb://" - -redaction_list = [] -redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_BASEURL")) -redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_TOKEN")) - +redaction_list = get_redaction_list() plex = get_plex() -logger("Plex connection succeeded", "info", "a") +LIB_ARRAY = get_target_libraries(plex) def lib_type_supported(lib): return lib.type == "movie" or lib.type == "show" -ALL_LIBS = plex.library.sections() -ALL_LIB_NAMES = [] - -logger(f"{len(ALL_LIBS)} libraries found:", "info", "a") -for lib in ALL_LIBS: - logger( - f"{lib.title.strip()}: {lib.type} - supported: {lib_type_supported(lib)}", - "info", - "a", - ) - ALL_LIB_NAMES.append(f"{lib.title.strip()}") - -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - for lib in ALL_LIBS: - if lib_type_supported(lib): - LIB_ARRAY.append(lib.title.strip()) - -TOPLEVEL_TMID = "" -TOPLEVEL_TVID = "" - - -def get_lib_setting(the_lib, the_setting): - settings = the_lib.settings() - for setting in settings: - if setting.id == the_setting: - return setting.value - - for lib in LIB_ARRAY: - if lib in ALL_LIB_NAMES: - try: - highwater = 0 + try: + highwater = 0 - plogger(f"Loading {lib} ...", "info", "a") - the_lib = plex.library.section(lib) + plogger(f"Loading {lib} ...", "info", "a") + the_lib = plex.library.section(lib) + if lib_type_supported(the_lib): the_uuid = the_lib.uuid superchat(f"{the_lib} uuid {the_uuid}", "info", "a") ID_ARRAY = [] @@ -178,9 +106,7 @@ def get_lib_setting(the_lib, the_setting): coll_item_total = len(collection_items) coll_idx = 1 for collection_item in collection_items: - imdbid, tmid, tvid = get_ids( - collection_item.guids, None - ) + imdbid, tmid, tvid = get_ids(collection_item.guids) if the_lib.TYPE == "movie": plogger( f"Collection: {item.title} item {coll_idx: >5}/{coll_item_total: >5} | TMDb ID: {tmid: >7} | IMDb ID: {imdbid: >10} | {collection_item.title}", @@ -244,7 +170,7 @@ def get_lib_setting(the_lib, the_setting): ) as bar: for item in items: try: - imdbid, tmid, tvid = get_ids(item.guids, None) + imdbid, tmid, tvid = get_ids(item.guids) imdbid_format = ( f"{imdbid: >10}" if imdbid else " N/A" ) @@ -278,15 +204,11 @@ def get_lib_setting(the_lib, the_setting): progress_str = "COMPLETE" logger(progress_str, "info", "a") + else: + logger(f"Library type '{the_lib.type}' not supported", "info", "a") - except Exception as ex: - progress_str = f"Problem processing {lib}; {ex}" - plogger(progress_str, "info", "a") - else: - logger( - f"Library {lib} not found: available libraries on this server are: {ALL_LIB_NAMES}", - "info", - "a", - ) + except Exception as ex: + progress_str = f"Problem processing {lib}; {ex}" + plogger(progress_str, "info", "a") plogger("Complete!", "info", "a") diff --git a/Plex/list-libraries.py b/Plex/list-libraries.py index 8c7f961..26b3e96 100644 --- a/Plex/list-libraries.py +++ b/Plex/list-libraries.py @@ -1,28 +1,21 @@ #!/usr/bin/env python -import os import time from datetime import datetime -from pathlib import Path from alive_progress import alive_bar -from helpers import get_plex, load_and_upgrade_env +from config import Config +from helpers import get_plex from tabulate import tabulate +config = Config('../config.yaml') + # current dateTime now = datetime.now() # convert to string RUNTIME_STR = now.strftime("%Y-%m-%d %H:%M:%S") -env_file_path = Path(".env") - -if load_and_upgrade_env(env_file_path) < 0: - exit() - -DELAY = int(os.getenv("DELAY")) - -if not DELAY: - DELAY = 0 +DELAY = config.get_int("general.delay", 0) plex = get_plex() @@ -30,21 +23,23 @@ coll_obj["libraries"] = {} -def get_sort_text(argument): - switcher = {0: "release", 1: "alpha", 2: "custom"} - return switcher.get(argument, "invalid-sort") - - sections = plex.library.sections() item_total = len(sections) -table = [["Name", "Type", "Size"]] + +table = [["Key", "Name", "Type", "Agent", "Scanner", "Created At", "Updated At", "Total Size", "UUID"]] with alive_bar(item_total, dual_line=True, title="Library list - Plex") as bar: for section in sections: info = [] + info.append(section.key) info.append(section.title) info.append(section.type) + info.append(section.agent) + info.append(section.scanner) + info.append(section.createdAt.strftime("%Y-%m-%d %H:%M:%S")) + info.append(section.updatedAt.strftime("%Y-%m-%d %H:%M:%S")) info.append(section.totalSize) + info.append(section.uuid) table.append(info) diff --git a/Plex/list-low-poster-counts.py b/Plex/list-low-poster-counts.py index cd0755b..ad351d6 100644 --- a/Plex/list-low-poster-counts.py +++ b/Plex/list-low-poster-counts.py @@ -1,19 +1,20 @@ #!/usr/bin/env python -import os import platform from datetime import datetime from pathlib import Path from alive_progress import alive_bar -from helpers import get_all_from_library, get_plex, load_and_upgrade_env +from config import Config +from helpers import (get_all_from_library, get_plex, get_redaction_list, + get_target_libraries) from logs import logger, plogger, setup_logger +config = Config('../config.yaml') + SCRIPT_NAME = Path(__file__).stem VERSION = "0.1.0" -env_file_path = Path(".env") - # current dateTime now = datetime.now() @@ -29,91 +30,28 @@ plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() - ID_FILES = True URL_ARRAY = [] # no one using this yet # QUEUED_DOWNLOADS = {} -target_url_var = "PLEX_URL" -PLEX_URL = os.getenv(target_url_var) -if PLEX_URL is None: - target_url_var = "PLEXAPI_AUTH_SERVER_BASEURL" - PLEX_URL = os.getenv(target_url_var) - -target_token_var = "PLEX_TOKEN" -PLEX_TOKEN = os.getenv(target_token_var) -if PLEX_TOKEN is None: - target_token_var = "PLEXAPI_AUTH_SERVER_TOKEN" - PLEX_TOKEN = os.getenv(target_token_var) - -if PLEX_URL is None or PLEX_URL == "https://plex.domain.tld": - plogger(f"You must specify {target_url_var} in the .env file.", "info", "a") - exit() - -if PLEX_TOKEN is None or PLEX_TOKEN == "PLEX-TOKEN": - plogger(f"You must specify {target_token_var} in the .env file.", "info", "a") - exit() - -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") - -SUPERCHAT = os.getenv("SUPERCHAT") - -DELAY = int(os.getenv("DELAY")) +SUPERCHAT = config.get("general.superchat", False) -if not DELAY: - DELAY = 0 +DELAY = config.get_int("general.delay", 0) -POSTER_THRESHOLD = int(os.getenv("POSTER_THRESHOLD")) +POSTER_THRESHOLD = config.get_int("low_poster_count.poster_threshold", 5) -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] - -imdb_str = "imdb://" -tmdb_str = "tmdb://" -tvdb_str = "tvdb://" - -redaction_list = [] -redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_BASEURL")) -redaction_list.append(os.getenv("PLEXAPI_AUTH_SERVER_TOKEN")) +redaction_list = get_redaction_list() plex = get_plex() -logger("Plex connection succeeded", "info", "a") - +LIB_ARRAY = get_target_libraries(plex) def lib_type_supported(lib): return lib.type == "movie" or lib.type == "show" -ALL_LIBS = plex.library.sections() -ALL_LIB_NAMES = [] - -logger(f"{len(ALL_LIBS)} libraries found:", "info", "a") -for lib in ALL_LIBS: - logger( - f"{lib.title.strip()}: {lib.type} - supported: {lib_type_supported(lib)}", - "info", - "a", - ) - ALL_LIB_NAMES.append(f"{lib.title.strip()}") - -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - for lib in ALL_LIBS: - if lib_type_supported(lib): - LIB_ARRAY.append(lib.title.strip()) - -TOPLEVEL_TMID = "" -TOPLEVEL_TVID = "" - - def get_lib_setting(the_lib, the_setting): settings = the_lib.settings() for setting in settings: @@ -122,12 +60,12 @@ def get_lib_setting(the_lib, the_setting): for lib in LIB_ARRAY: - if lib in ALL_LIB_NAMES: - try: - highwater = 0 + try: + highwater = 0 - plogger(f"Loading {lib} ...", "info", "a") - the_lib = plex.library.section(lib) + plogger(f"Loading {lib} ...", "info", "a") + the_lib = plex.library.section(lib) + if lib_type_supported(the_lib): the_uuid = the_lib.uuid ID_ARRAY = [] the_title = the_lib.title @@ -173,15 +111,11 @@ def get_lib_setting(the_lib, the_setting): progress_str = "COMPLETE" logger(progress_str, "info", "a") + else: + print(f"Library type '{the_lib.type}' not supported") - except Exception as ex: - progress_str = f"Problem processing {lib}; {ex}" - plogger(progress_str, "info", "a") - else: - logger( - f"Library {lib} not found: available libraries on this server are: {ALL_LIB_NAMES}", - "info", - "a", - ) + except Exception as ex: + progress_str = f"Problem processing {lib}; {ex}" + plogger(progress_str, "info", "a") plogger("Complete!", "info", "a") diff --git a/Plex/mediascripts.sqlite.HIDDEN b/Plex/mediascripts.sqlite.HIDDEN deleted file mode 100644 index 90c84d8feeee7c6a6193fd9d4359a0a37258ddbf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28672 zcmeI(O;6iM7zc2hB+i1!%c_T5T~$a$!o+so(@T@opg;m?aA-i4EGIF+#C8(Lp@Cb3 zw%0xEci8(%EA6e)euX{mevv(`c0#uyNU(CMv=!~&iiO8!m}h>^jF4yG;AKg7H1?!x zo2tXcs4zv-)N_`lC@M%!KRG`f9&+LRP#|CFnd|#5gVg4$oj~YsidlJ01^x;BvidCW z`^xLprTZ%2J_H~D0SG_<0uX?}!xgxC;`2w=*66p(j@mG^p=~IRtu{Nl)tf^%0XzW*>otvY{`G+@%SU_>+}z~>-09eX3x+Zt)+Cdi^1)Z_g0E; zKbs@Tw(5|vGx_;*CSgJ{XO(G?pCfNK9h*CS&%8`zfBnAnO?z>j6h*jQ_FeI`KN5@4 zZ~fOPG}M8k*h5QcTquU#uvPowj$2E&l6l8B%#qP)V%X`(XI&3ngG{UUv;JdhTdFzl zT%Pu|rIL5%>8$GoVONPcD+*Ye*ga+6?*qzI*33a009U<00Izz z00bZa0SG_<0^gXxbJ`oJ#ZoaLmT~jOKl^6@sjMJmg&6k#zp){M?m_?p5P$##AOHaf zKmY;|fWTKGz>y_^W!Fmrll^~Y^+zgX2LB9h2YwAaCIuW2fB*y_009U<00Izz00ch0 zz&OM(A4?{K#cQakFz#uq$fwrD|F2`fh!zxb^0= zXjIJu*ZE!;;7I#=;rHZ zBOyOLsW$V@^CT3k!+QjKfRrL(&Jr@o7AHZYN`DzI9RC+HIA3ae>coR~8pi zdK{+Rhm=XQt)1gyN3^9*d7m$k@J5Nk;hAL)78g=#vj5KnEGqb0;IGh60V}i?EPncF zjus#Q0SG_<0uX=z1Rwwb2z>1VhL0?6(4S{*gE4(%-NIF43s|=Bxo>R&D-vG!tu0`& Y!Q;NQ1*{`1kwNmPw(Mq*EMSS?UwB%7kpKVy diff --git a/Plex/refresh-metadata.py b/Plex/refresh-metadata.py index e030aca..ffd8f4d 100644 --- a/Plex/refresh-metadata.py +++ b/Plex/refresh-metadata.py @@ -9,7 +9,9 @@ import urllib3.exceptions from alive_progress import alive_bar -from helpers import booler, get_all_from_library, get_plex, load_and_upgrade_env +from config import Config +from helpers import (get_all_from_library, get_plex, get_redaction_list, + get_target_libraries) from logs import logger, plogger, setup_logger from requests import ReadTimeout from urllib3.exceptions import ReadTimeoutError @@ -24,37 +26,19 @@ VERSION = "0.1.0" -env_file_path = Path(".env") - ACTIVITY_LOG = f"{SCRIPT_NAME}.log" setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') plex = get_plex() -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -DELAY = int(os.getenv("DELAY")) -REFRESH_1970_ONLY = booler(os.getenv("REFRESH_1970_ONLY")) - -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] - -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - all_libs = plex.library.sections() - for lib in all_libs: - if lib.type == "movie" or lib.type == "show": - LIB_ARRAY.append(lib.title.strip()) - -tmdb_str = "tmdb://" -tvdb_str = "tvdb://" +LIB_ARRAY = get_target_libraries(plex) + +DELAY = config.get_int('general.delay', 0) +REFRESH_1970_ONLY = config.get_bool('refresh_metadata.refresh_1970_only', False) def progress(count, total, status=""): diff --git a/Plex/rematch-items.py b/Plex/rematch-items.py index c6564d0..b64efe4 100644 --- a/Plex/rematch-items.py +++ b/Plex/rematch-items.py @@ -1,6 +1,5 @@ #!/usr/bin/env python import logging -import os import sys import textwrap from datetime import datetime @@ -8,7 +7,9 @@ import urllib3.exceptions from alive_progress import alive_bar -from helpers import booler, get_all_from_library, get_plex, load_and_upgrade_env +from config import Config +from helpers import (get_all_from_library, get_plex, get_redaction_list, + get_target_libraries) from logs import plogger, setup_logger from requests import ReadTimeout from urllib3.exceptions import ReadTimeoutError @@ -26,27 +27,14 @@ VERSION = "0.2.1" -env_file_path = Path(".env") - ACTIVITY_LOG = f"{SCRIPT_NAME}.log" setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() - -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -UNMATCHED_ONLY = booler(os.getenv("UNMATCHED_ONLY")) - -if LIBRARY_NAMES: - LIB_ARRAY = LIBRARY_NAMES.split(",") -else: - LIB_ARRAY = [LIBRARY_NAME] +config = Config('../config.yaml') -tmdb_str = "tmdb://" -tvdb_str = "tvdb://" +UNMATCHED_ONLY = config.get_bool('rematch_items.unmatched_only', False) def progress(count, total, status=""): @@ -63,12 +51,7 @@ def progress(count, total, status=""): plex = get_plex() -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - all_libs = plex.library.sections() - for lib in all_libs: - if lib.type == "movie" or lib.type == "show": - LIB_ARRAY.append(lib.title.strip()) +LIB_ARRAY = get_target_libraries(plex) for lib in LIB_ARRAY: the_lib = plex.library.section(lib) diff --git a/Plex/reset-posters-plex.py b/Plex/reset-posters-plex.py index 7ca4500..aa96e3a 100644 --- a/Plex/reset-posters-plex.py +++ b/Plex/reset-posters-plex.py @@ -8,13 +8,9 @@ from timeit import default_timer as timer from alive_progress import alive_bar -from helpers import ( - booler, - get_all_from_library, - get_overlay_status, - get_plex, - load_and_upgrade_env, -) +from config import Config +from helpers import (get_all_from_library, get_overlay_status, get_plex, + get_target_libraries) from logs import blogger, plogger, setup_logger start = timer() @@ -33,65 +29,44 @@ VERSION = "0.1.3" -env_file_path = Path(".env") - ACTIVITY_LOG = f"{SCRIPT_NAME}.log" setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -TARGET_LABELS = os.getenv("TARGET_LABELS") +TARGET_LABELS = config.get('reset_posters.target_labels', '') if TARGET_LABELS == "this label, that label": print( - "TARGET_LABELS in the .env file must be empty or have a meaningful value.", + "TARGET_LABELS in the config.yaml must be empty or have a meaningful value.", "info", "a", ) exit() - -TRACK_RESET_STATUS = booler(os.getenv("TRACK_RESET_STATUS")) -RETAIN_RESET_STATUS_FILE = booler(os.getenv("RETAIN_RESET_STATUS_FILE")) -DRY_RUN = booler(os.getenv("DRY_RUN")) -FLUSH_STATUS_AT_START = booler(os.getenv("FLUSH_STATUS_AT_START")) -RESET_SEASONS_WITH_SERIES = booler(os.getenv("RESET_SEASONS_WITH_SERIES")) -OVERRIDE_OVERLAY_STATUS = booler(os.getenv("OVERRIDE_OVERLAY_STATUS")) - -REMOVE_LABELS = booler(os.getenv("REMOVE_LABELS")) -RESET_SEASONS = booler(os.getenv("RESET_SEASONS")) -RESET_EPISODES = booler(os.getenv("RESET_EPISODES")) - -DELAY = 0 -try: - DELAY = int(os.getenv("DELAY")) -except: - DELAY = 0 - -if TARGET_LABELS: - LBL_ARRAY = TARGET_LABELS.split(",") else: - LBL_ARRAY = ["xy22y1973"] + if TARGET_LABELS: + LBL_ARRAY = TARGET_LABELS.split(",") + else: + LBL_ARRAY = ["xy22y1973"] -if LIBRARY_NAMES: - LIB_ARRAY = LIBRARY_NAMES.split(",") -else: - LIB_ARRAY = [LIBRARY_NAME] +TRACK_RESET_STATUS = config.get_bool('reset_posters.track_reset_status', False) +RETAIN_RESET_STATUS_FILE = config.get_bool('reset_posters.retain_reset_status_file', False) +DRY_RUN = config.get_bool('reset_posters.dry_run', False) +FLUSH_STATUS_AT_START = config.get_bool('reset_posters.flush_status_at_start', False) +RESET_SEASONS_WITH_SERIES = config.get_bool('reset_posters.reset_seasons_with_series', False) +OVERRIDE_OVERLAY_STATUS = config.get_bool('reset_posters.override_overlay_status', False) -plex = get_plex() -plogger("connection success", "info", "a") +REMOVE_LABELS = config.get_bool('reset_posters.remove_labels', False) +RESET_SEASONS = config.get_bool('reset_posters.reset_seasons', False) +RESET_EPISODES = config.get_bool('reset_posters.reset_episodes', False) -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - all_libs = plex.library.sections() - for lib in all_libs: - if lib.type == "movie" or lib.type == "show": - LIB_ARRAY.append(lib.title.strip()) +DELAY = config.get_int('general.delay', 0) + +plex = get_plex() +LIB_ARRAY = get_target_libraries(plex) def sleep_for_a_while(): sleeptime = DELAY diff --git a/Plex/reset-posters-tmdb.py b/Plex/reset-posters-tmdb.py index d1e88ea..3f4475f 100644 --- a/Plex/reset-posters-tmdb.py +++ b/Plex/reset-posters-tmdb.py @@ -11,17 +11,14 @@ import requests import validators from alive_progress import alive_bar -from helpers import ( - booler, - get_all_from_library, - get_ids, - get_overlay_status, - get_plex, - load_and_upgrade_env, -) +from config import Config +from helpers import (get_all_from_library, get_ids, get_overlay_status, + get_plex, get_redaction_list, get_target_libraries) from logs import blogger, logger, plogger, setup_logger from tmdbapis import TMDbAPIs +config = Config('../config.yaml') + # import tvdb_v4_official start = timer() @@ -38,69 +35,47 @@ VERSION = "0.1.2" -env_file_path = Path(".env") - ACTIVITY_LOG = f"{SCRIPT_NAME}.log" setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() - -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") -TMDB_KEY = os.getenv("TMDB_KEY") -TVDB_KEY = os.getenv("TVDB_KEY") TARGET_LABELS = os.getenv("TARGET_LABELS") if TARGET_LABELS == "this label, that label": print( - "TARGET_LABELS in the .env file must be empty or have a meaningful value.", + "TARGET_LABELS in the config.yaml must be empty or have a meaningful value.", "info", "a", ) exit() +else: + if TARGET_LABELS: + LBL_ARRAY = TARGET_LABELS.split(",") + else: + LBL_ARRAY = ["xy22y1973"] -TRACK_RESET_STATUS = booler(os.getenv("TRACK_RESET_STATUS")) -CLEAR_RESET_STATUS = booler( - os.getenv( - "CLEAR_RESET_STATUS", - ) -) +TRACK_RESET_STATUS = config.get_bool('reset_posters.track_reset_status', False) +CLEAR_RESET_STATUS = config.get_bool('reset_posters.clear_reset_status', False) +RETAIN_RESET_STATUS_FILE = config.get_bool('reset_posters.retain_reset_status_file', False) +DRY_RUN = config.get_bool('reset_posters.dry_run', False) +FLUSH_STATUS_AT_START = config.get_bool('reset_posters.flush_status_at_start', False) +RESET_SEASONS_WITH_SERIES = config.get_bool('reset_posters.reset_seasons_with_series', False) +OVERRIDE_OVERLAY_STATUS = config.get_bool('reset_posters.override_overlay_status', False) +LOCAL_RESET_ARCHIVE = config.get_bool('reset_posters.local_reset_archive', False) -RETAIN_RESET_STATUS_FILE = os.getenv("RETAIN_RESET_STATUS_FILE") -REMOVE_LABELS = booler(os.getenv("REMOVE_LABELS")) -RESET_SEASONS = booler(os.getenv("RESET_SEASONS")) -RESET_EPISODES = booler(os.getenv("RESET_EPISODES")) -RESET_SEASONS_WITH_SERIES = booler(os.getenv("RESET_SEASONS_WITH_SERIES")) -LOCAL_RESET_ARCHIVE = booler(os.getenv("LOCAL_RESET_ARCHIVE")) -DRY_RUN = booler(os.getenv("DRY_RUN")) -FLUSH_STATUS_AT_START = booler(os.getenv("FLUSH_STATUS_AT_START")) -OVERRIDE_OVERLAY_STATUS = booler(os.getenv("OVERRIDE_OVERLAY_STATUS")) - -DELAY = 0 -try: - DELAY = int(os.getenv("DELAY")) -except: - DELAY = 0 - -if TARGET_LABELS: - LBL_ARRAY = TARGET_LABELS.split(",") -else: - LBL_ARRAY = ["xy22y1973"] +REMOVE_LABELS = config.get_bool('reset_posters.remove_labels', False) +RESET_SEASONS = config.get_bool('reset_posters.reset_seasons', False) +RESET_EPISODES = config.get_bool('reset_posters.reset_episodes', False) -if LIBRARY_NAMES: - LIB_ARRAY = LIBRARY_NAMES.split(",") -else: - LIB_ARRAY = [LIBRARY_NAME] +DELAY = config.get_int('general.delay', 0) IS_WINDOWS = platform.system() == "Windows" # Commented out until this doesn't throw a 400 # tvdb = tvdb_v4_official.TVDB(TVDB_KEY) -tmdb = TMDbAPIs(TMDB_KEY, language="en") +tmdb = TMDbAPIs(str(config.get("general.tmdb_key", "NO_KEY_SPECIFIED")), language="en") local_dir = os.path.join(os.getcwd(), "posters") @@ -127,16 +102,7 @@ def localFilePath(tgt_dir, rating_key): size_str = "original" plex = get_plex() - -logger(("connection success"), "info", "a") - -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - all_libs = plex.library.sections() - for lib in all_libs: - if lib.type == "movie" or lib.type == "show": - LIB_ARRAY.append(lib.title.strip()) - +LIB_ARRAY = get_target_libraries(plex) def sleep_for_a_while(): sleeptime = DELAY @@ -359,7 +325,7 @@ def track_completion(id_array, status_file, item_id): item_count = item_count + 1 item_key = library_item.ratingKey item_title = library_item.title - imdbid, tmdb_id, tvdb_id = get_ids(library_item.guids, TMDB_KEY) + imdbid, tmdb_id, tvdb_id = get_ids(library_item.guids) logger( ( f"{item_title}: ratingKey: {item_key} imdbid: {imdbid} tmdb_id: {tmdb_id} tvdb_id: {tvdb_id}" diff --git a/Plex/reverse-genres.py b/Plex/reverse-genres.py index ecb9eb0..d815c3a 100644 --- a/Plex/reverse-genres.py +++ b/Plex/reverse-genres.py @@ -4,7 +4,9 @@ from pathlib import Path from alive_progress import alive_bar -from helpers import get_all_from_library, get_plex, load_and_upgrade_env +from config import Config +from helpers import (get_all_from_library, get_plex, get_redaction_list, + get_target_libraries) from logs import logger, plogger, setup_logger # current dateTime @@ -17,30 +19,19 @@ VERSION = "0.1.0" -env_file_path = Path(".env") - ACTIVITY_LOG = f"{SCRIPT_NAME}.log" setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') -LIBRARY_NAME = os.getenv("LIBRARY_NAME") -LIBRARY_NAMES = os.getenv("LIBRARY_NAMES") NEW = [] UPDATED = [] -if LIBRARY_NAMES: - LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] -else: - LIB_ARRAY = [LIBRARY_NAME] - plex = get_plex() -logger(("connection success"), "info", "a") - +LIB_ARRAY = get_target_libraries(plex) def reverse_genres(item): reversed_list = [] @@ -68,13 +59,6 @@ def reverse_genres(item): print(f"{item.title} after: {new_genres}") -if LIBRARY_NAMES == "ALL_LIBRARIES": - LIB_ARRAY = [] - all_libs = plex.library.sections() - for lib in all_libs: - if lib.type == "movie" or lib.type == "show": - LIB_ARRAY.append(lib.title.strip()) - for lib in LIB_ARRAY: try: the_lib = plex.library.section(lib) diff --git a/Plex/set-user-rating.py b/Plex/set-user-rating.py index b584af2..84c8406 100644 --- a/Plex/set-user-rating.py +++ b/Plex/set-user-rating.py @@ -3,9 +3,13 @@ from datetime import datetime from pathlib import Path -from helpers import get_all_from_library, get_plex, load_and_upgrade_env +from config import Config +from helpers import (get_all_from_library, get_plex, get_redaction_list, + get_target_libraries) from logs import plogger, setup_logger +config = Config('../config.yaml') + # current dateTime now = datetime.now() @@ -16,20 +20,16 @@ VERSION = "0.0.1" -env_file_path = Path(".env") - ACTIVITY_LOG = f"{SCRIPT_NAME}.log" setup_logger("activity_log", ACTIVITY_LOG) plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") -if load_and_upgrade_env(env_file_path) < 0: - exit() - plex = get_plex() -plogger("connection success", "info", "a") plogger(f"Plex version {plex.version}", "info", "a") +LIB_ARRAY = get_target_libraries(plex) + new_rating = round(random.random() * 10, 1) the_lib = plex.library.section("Test-Movies") diff --git a/Plex/show-all-playlists.py b/Plex/show-all-playlists.py index d0498fa..41604a8 100644 --- a/Plex/show-all-playlists.py +++ b/Plex/show-all-playlists.py @@ -1,10 +1,10 @@ #!/usr/bin/env python import logging -import os from datetime import datetime from pathlib import Path -from helpers import get_plex, load_and_upgrade_env +from config import Config +from helpers import get_plex # current dateTime now = datetime.now() @@ -17,8 +17,6 @@ VERSION = "0.1.0" -env_file_path = Path(".env") - logging.basicConfig( filename=f"{SCRIPT_NAME}.log", filemode="w", @@ -29,10 +27,9 @@ logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") print(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') -PLEX_OWNER = os.getenv("PLEX_OWNER") +PLEX_OWNER = config.get("target.plex_owner") plex = get_plex() diff --git a/Plex/user-emails.py b/Plex/user-emails.py index 17618ff..6f47c1b 100644 --- a/Plex/user-emails.py +++ b/Plex/user-emails.py @@ -3,7 +3,8 @@ from datetime import datetime from pathlib import Path -from helpers import get_plex, load_and_upgrade_env +from config import Config +from helpers import get_plex # current dateTime now = datetime.now() @@ -15,8 +16,6 @@ VERSION = "0.1.0" -env_file_path = Path(".env") - logging.basicConfig( filename=f"{SCRIPT_NAME}.log", filemode="w", @@ -27,8 +26,7 @@ logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") print(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") -if load_and_upgrade_env(env_file_path) < 0: - exit() +config = Config('../config.yaml') print("connecting...") plex = get_plex() diff --git a/README.md b/README.md index 6957de2..f70d7c8 100644 --- a/README.md +++ b/README.md @@ -42,28 +42,30 @@ ok not really ### After you've done one of the above: Once you have the requirements installed via whatever means, you are ready to set up the script-specific stuff. -1. cd to script directory [`Plex`, `Kometa`, `TMDB`, etc] - for example: - ``` - cd Plex - ``` -1. Copy `.env.example` to `.env` +1. Copy `config.yaml.template` to `config.yaml` Linux or Mac: ``` - cp .env.example .env + cp config.yaml.template config.yaml ``` Windows: ``` - copy .env.example .env + copy config.yaml.template config.yaml + ``` +1. Edit `config.yaml` 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. + + Edit the file with whatever text editor you wish, so long as you save it as **plain text**. + +1. cd to script directory [`Plex`, `Kometa`, etc] + for example: + ``` + cd Plex ``` -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. - Edit the file with whatever text editor you wish. 1. Run the desired script. -All these scripts use the same `.env` and requirements. +All these scripts use the same `config.yaml` and requirements. ## Plex scripts: @@ -112,3 +114,4 @@ See the [Plex Image Picker README](Plex%20Image%20Picker/README.md) for details. 1. [bullmoose](https://github.com/bullmoose20/Plex-Stuff) 2. [Casvt](https://github.com/Casvt/Plex-scripts) 3. [maximuskowalski](https://github.com/maximuskowalski/maxmisc) +1 \ No newline at end of file diff --git a/config.template.yaml b/config.template.yaml new file mode 100644 index 0000000..084b6cf --- /dev/null +++ b/config.template.yaml @@ -0,0 +1,143 @@ +plex_api: + header_identifier: "media-scripts" + timeout: 360 + auth_server: + base_url: 'YOUR_PLEX_URL' + token: 'YOUR_PLEX_TOKEN' + log: + backup_count: 3 + format: "%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s" + level: "INFO" + path: "plexapi.log" + rotate_bytes: 512000 + show_secrets: 0 + skip_verify_ssl: 0 + +general: + tmdb_key: "TMDB_API_KEY" # https://developers.themoviedb.org/3/getting-started/introduction + tvdb_key: "TVDB_V4_API_KEY" # currently not used; https://thetvdb.com/api-information + delay: 1 # optional delay between items + library_names: Movies,TV Shows,Movies 4K # comma-separated list of libraries to act on + superchat: 0 + +kometa: + config_dir: /kometa/is/here + +image_download: + what_to_grab: + ### collection-related + include_collection_artwork: 1 # should get-all-posters retrieve collection posters? + only_collection_artwork: 0 # should get-all-posters retrieve ONLY collection posters? + only_these_collections: "Bing|Bang|Boing" # only grab artwork for these collections and items in them + + ### tv-related + seasons: 1 # should get-all-posters retrieve season posters? + episodes: 1 # should get-all-posters retrieve episode posters? [requires GRAB_SEASONS] + + ### background-related + backgrounds: 1 # should get-all-posters retrieve backgrounds? + artwork: 1 # current background is downloaded with current poster + + ### quantity-related + only_current: 0 # should get-all-posters retrieve ONLY current artwork? + poster_depth: 20 # grab this many posters [0 grabs all] [ONLY_CURRENT overrides this] + + ### what-to-keep + keep_junk: 0 # keep files that script would normally delete [incorrect filetypes, mainly] + find_overlaid_images: 0 # check all downloaded images for overlays + retain_overlaid_images: 0 # keep images that have an overlay EXIF tag [this will override the following two] + retain_kometa_overlaid_images: 0 # keep images that have the Kometa overlay EXIF tag + retain_tcm_overlaid_images: 0 # keep images that have the TCM overlay EXIF tag + + ## where-to-put-it + where_to_put_it: + use_asset_naming: 1 # should grab-all-posters name images to match Kometa's Asset Directory requirements? + use_asset_folders: 1 # should those Kometa-Asset-Directory names use asset folders? + use_asset_subfolders: 0 # create asset folders in subfolders ["Collections", "Other", or [0-9, A-Z]] ] + assets_by_libraries: 1 # should those Kometa-Asset-Directory images be sorted into library folders? + asset_dir: "assets" # top-level directory for those Kometa-Asset-Directory images + # if asset-directory naming is on, the next three are ignored + poster_dir: "extracted_posters" # put downloaded posters here + current_poster_dir: "current_posters" # put downloaded current posters and artwork here + poster_consolidate: 0 # if false, posters are separated into folders by library + + ## tracking + tracking: + track_urls: 1 # If set to 1, URLS are tracked and won't be downloaded twice + track_completion: 1 # If set to 1, movies/shows are tracked as complete by rating id + track_image_sources: 1 # keep a file containing file names and source URLs + + ## general + general: + poster_download: 1 # if false, generate a script rather than downloading + folders_only: 0 # Just build out the folder hierarchy; no image downloading + default_years_back: 2 # in absence of a "last run date", grab things added this many years back. + # 0 sets the fallback date to the beginning of time + threaded_downloads: 0 # should downloads be done in the background in threads? + reset_libraries: "Bing,Bang,Boing" # reset "last time" count to the fallback date for these libraries + reset_collections: "Bing,Bang,Boing" # CURRENTLY UNUSED + add_source_exif_comment: 1 # CURRENTLY UNUSED + +status: + plex_owner: "yournamehere" # account name of the server owner + target_plex_url: "https://plex.domain2.tld" # As above, the target of apply_all_status + target_plex_token: "PLEX-TOKEN-TWO" # As above, the target of apply_all_status + target_plex_owner: "yournamehere" # As above, the target of apply_all_status + library_map: '{"LIBRARY_ON_PLEX":"LIBRARY_ON_TARGET_PLEX", ...}' + # In apply_all_status, map libraries according to this JSON. + +reset_posters: + track_reset_status: 1 # should reset-posters-* keep track of status and pick up where it left off? + clear_reset_status: 0 + local_reset_archive: 1 # should reset-posters-tmdb keep a local archive of posters? + override_overlay_status: 0 + target_labels: this label, that label # comma-separated list of labels to reset posters on + remove_labels: 0 # attempt to remove the TARGET_LABELs from items after resetting the poster + reset_seasons: 1 # reset-posters-* resets season artwork as well in TV libraries + reset_episodes: 1 # reset-posters-* resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] + retain_reset_status_file: 0 # Don't delete the reset progress file at the end + flush_status_at_start: 0 # Delete the reset progress file at the start instead of reading it + reset_seasons_with_series: 0 # If there isn't a season poster, use the series poster + dry_run: 0 # [currently only works with reset-posters-*]; don't actually do anything, just log + +# # LIST ITEM IDS ENV VARS +# INCLUDE_COLLECTION_MEMBERS=0 +# ONLY_COLLECTION_MEMBERS=0 +list_item_ids: + include_collection_members: 0 + only_collection_members: 0 + +delete_collection: + keep_collections: "bing,bang" # List of collections to keep + +refresh_metadata: + refresh_1970_only: 1 # If 1, only refresh things that have an originally-available date of 1970-01-01 + +rematch_items: + unmatched_only: 1 # If 1, only rematch things that are currently unmatched + +reset_added_at: + adjust_date_futures_only: 0 # Only look at items that show up as added in the future + adjust_date_epoch_only: 1 # Only adjust items that have "originally available" dates of `1970-01-01` + +actor: + cast_depth: 20 # how deep to go into the cast for actor collections + top_count: 10 # how many actors to export + job_type: "Actor" + known_for_only: 0 # ignore cast members who are not primarily known as actors + build_collections: 0 # build yaml for Kometa config.yml + num_collections: 20 # this many actors in Kometa yaml + track_gender: 1 # Pay attention to actor gender [as recorded on TMDB] + min_gender_none: 5 # include minimum this many "none" gendered actors in the YAML, if possible + min_gender_female: 5 # include minimum this many "female" gendered actors in the YAML, if possible + min_gender_male: 5 # include minimum this many "male" gendered actors in the YAML, if possible + min_gender_nb: 5 # include minimum this many "non-binary" gendered actors in the YAML, if possible + +low_poster_count: + poster_threshold: 10 # how many posters counts as a "low" count? + +crew: + depth: 20 + count: 100 + target_job: Director + show_jobs: 0 From 3a4ba675a88f6684856ffbc6419fa995efb89128 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Tue, 9 Sep 2025 23:10:00 -0500 Subject: [PATCH 2/7] Tweaks --- .envrc | 2 +- Kometa/README.md | 163 +++++------------------------------ Kometa/metadata-extractor.py | 109 ++++++++++++----------- Plex/README.md | 145 +------------------------------ config.template.yaml | 27 ++++++ 5 files changed, 111 insertions(+), 335 deletions(-) diff --git a/.envrc b/.envrc index e33bda4..53a7fd8 100644 --- a/.envrc +++ b/.envrc @@ -1,4 +1,4 @@ -layout python3 +layout pyenv 3.11.10 python -m pip install --upgrade pip python -m pip install -r requirements.txt diff --git a/Kometa/README.md b/Kometa/README.md index 0c88346..973caca 100644 --- a/Kometa/README.md +++ b/Kometa/README.md @@ -10,148 +10,7 @@ All these scripts use the same `config.yaml` and requirements. ### `config.template.yaml` contents -```yaml -plex_api: - header_identifier: "media-scripts" - timeout: 360 - auth_server: - base_url: 'YOUR_PLEX_URL' - token: 'YOUR_PLEX_TOKEN' - log: - backup_count: 3 - format: "%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s" - level: "INFO" - path: "plexapi.log" - rotate_bytes: 512000 - show_secrets: 0 - skip_verify_ssl: 0 - -general: - tmdb_key: "TMDB_API_KEY" # https://developers.themoviedb.org/3/getting-started/introduction - tvdb_key: "TVDB_V4_API_KEY" # currently not used; https://thetvdb.com/api-information - delay: 1 # optional delay between items - library_names: Movies,TV Shows,Movies 4K # comma-separated list of libraries to act on - superchat: 0 - -kometa: - config_dir: /kometa/is/here # location of Kometa config dir as seen by this script - -image_download: - what_to_grab: - ### collection-related - include_collection_artwork: 1 # should get-all-posters retrieve collection posters? - only_collection_artwork: 0 # should get-all-posters retrieve ONLY collection posters? - only_these_collections: "Bing|Bang|Boing" # only grab artwork for these collections and items in them - - ### tv-related - seasons: 1 # should get-all-posters retrieve season posters? - episodes: 1 # should get-all-posters retrieve episode posters? [requires GRAB_SEASONS] - - ### background-related - backgrounds: 1 # should get-all-posters retrieve backgrounds? - artwork: 1 # current background is downloaded with current poster - - ### quantity-related - only_current: 0 # should get-all-posters retrieve ONLY current artwork? - poster_depth: 20 # grab this many posters [0 grabs all] [ONLY_CURRENT overrides this] - - ### what-to-keep - keep_junk: 0 # keep files that script would normally delete [incorrect filetypes, mainly] - find_overlaid_images: 0 # check all downloaded images for overlays - retain_overlaid_images: 0 # keep images that have an overlay EXIF tag [this will override the following two] - retain_kometa_overlaid_images: 0 # keep images that have the Kometa overlay EXIF tag - retain_tcm_overlaid_images: 0 # keep images that have the TCM overlay EXIF tag - - ## where-to-put-it - where_to_put_it: - use_asset_naming: 1 # should grab-all-posters name images to match Kometa's Asset Directory requirements? - use_asset_folders: 1 # should those Kometa-Asset-Directory names use asset folders? - use_asset_subfolders: 0 # create asset folders in subfolders ["Collections", "Other", or [0-9, A-Z]] ] - assets_by_libraries: 1 # should those Kometa-Asset-Directory images be sorted into library folders? - asset_dir: "assets" # top-level directory for those Kometa-Asset-Directory images - # if asset-directory naming is on, the next three are ignored - poster_dir: "extracted_posters" # put downloaded posters here - current_poster_dir: "current_posters" # put downloaded current posters and artwork here - poster_consolidate: 0 # if false, posters are separated into folders by library - - ## tracking - tracking: - track_urls: 1 # If set to 1, URLS are tracked and won't be downloaded twice - track_completion: 1 # If set to 1, movies/shows are tracked as complete by rating id - track_image_sources: 1 # keep a file containing file names and source URLs - - ## general - general: - poster_download: 1 # if false, generate a script rather than downloading - folders_only: 0 # Just build out the folder hierarchy; no image downloading - default_years_back: 2 # in absence of a "last run date", grab things added this many years back. - # 0 sets the fallback date to the beginning of time - threaded_downloads: 0 # should downloads be done in the background in threads? - reset_libraries: "Bing,Bang,Boing" # reset "last time" count to the fallback date for these libraries - reset_collections: "Bing,Bang,Boing" # CURRENTLY UNUSED - add_source_exif_comment: 1 # CURRENTLY UNUSED - -status: - plex_owner: "yournamehere" # account name of the server owner - target_plex_url: "https://plex.domain2.tld" # As above, the target of apply_all_status - target_plex_token: "PLEX-TOKEN-TWO" # As above, the target of apply_all_status - target_plex_owner: "yournamehere" # As above, the target of apply_all_status - library_map: '{"LIBRARY_ON_PLEX":"LIBRARY_ON_TARGET_PLEX", ...}' - # In apply_all_status, map libraries according to this JSON. - -reset_posters: - track_reset_status: 1 # should reset-posters-* keep track of status and pick up where it left off? - clear_reset_status: 0 - local_reset_archive: 1 # should reset-posters-tmdb keep a local archive of posters? - override_overlay_status: 0 - target_labels: this label, that label # comma-separated list of labels to reset posters on - remove_labels: 0 # attempt to remove the TARGET_LABELs from items after resetting the poster - reset_seasons: 1 # reset-posters-* resets season artwork as well in TV libraries - reset_episodes: 1 # reset-posters-* resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] - retain_reset_status_file: 0 # Don't delete the reset progress file at the end - flush_status_at_start: 0 # Delete the reset progress file at the start instead of reading it - reset_seasons_with_series: 0 # If there isn't a season poster, use the series poster - dry_run: 0 # [currently only works with reset-posters-*]; don't actually do anything, just log - -list_item_ids: - include_collection_members: 0 - only_collection_members: 0 - -delete_collection: - keep_collections: "bing,bang" # List of collections to keep - -refresh_metadata: - refresh_1970_only: 1 # If 1, only refresh things that have an originally-available date of 1970-01-01 - -rematch_items: - unmatched_only: 1 # If 1, only rematch things that are currently unmatched - -reset_added_at: - adjust_date_futures_only: 0 # Only look at items that show up as added in the future - adjust_date_epoch_only: 1 # Only adjust items that have "originally available" dates of `1970-01-01` - -actor: - cast_depth: 20 # how deep to go into the cast for actor collections - top_count: 10 # how many actors to export - job_type: "Actor" - known_for_only: 0 # ignore cast members who are not primarily known as actors - build_collections: 0 # build yaml for Kometa config.yml - num_collections: 20 # this many actors in Kometa yaml - track_gender: 1 # Pay attention to actor gender [as recorded on TMDB] - min_gender_none: 5 # include minimum this many "none" gendered actors in the YAML, if possible - min_gender_female: 5 # include minimum this many "female" gendered actors in the YAML, if possible - min_gender_male: 5 # include minimum this many "male" gendered actors in the YAML, if possible - min_gender_nb: 5 # include minimum this many "non-binary" gendered actors in the YAML, if possible - -low_poster_count: - poster_threshold: 10 # how many posters counts as a "low" count? - -crew: - depth: 20 - count: 100 - target_job: Director - show_jobs: 0 -``` +The `config.yaml` template can be viewed here: [../config.template.yaml](../config.template.yaml). ## Scripts: 1. [clean-overlay-backup.py](#clean-overlay-backuppy) - clean out leftover overlay backup art @@ -452,6 +311,24 @@ You want to seed a Kometa metadata file with the contents of one or more librari Here is a basic script to do that. +Script-specific variables in `config.yaml`: + +```yaml +metadata: + download_images: false # should the script download files like images and themes + only_first_genre: true # should the script include only the first genre that Plex has assigned to the item? + + include_audience_rating: true + include_content_rating: true + # ... rest of fields redacted for space... +``` + +There has recently been a regression in some PlexAPI code such that for the moment you should leave `download_images: false` + +This should be cleared up relatively soon. + +You can turn off individual fields with the `include_` flags. + ### Usage 1. setup as above 1. Run with `metadata-extractor.py` @@ -462,6 +339,8 @@ IMPORTANT NOTES: This script backs up all Kometa-supported metadata [with a few minor exceptions], which includes things you may not have changed. It also includes the "Overlay" label. It backs up this label because it *also* backs up the current art, which might be overlaid. You will probably want to edit or trim this file before using it to restore. +NOTE: This functionality is hobbled currently by the image-downloading issue. + Metadata not backed up: ``` metadata_language Movie, Show diff --git a/Kometa/metadata-extractor.py b/Kometa/metadata-extractor.py index 877c647..3173b9c 100644 --- a/Kometa/metadata-extractor.py +++ b/Kometa/metadata-extractor.py @@ -68,6 +68,8 @@ PLEXAPI_AUTH_SERVER_TOKEN = config.get("plex_api.auth_server.token") +INCLUDE_FILES = config.get_bool("metadata.include_files", False) +ONLY_ONE_GENRE = config.get_bool("metadata.only_first_genre", False) tmdb = TMDbAPIs(str(config.get("general.tmdb_key", "NO_KEY_SPECIFIED")), language="en") @@ -78,6 +80,7 @@ show_dir = f"{local_dir}/shows" movie_dir = f"{local_dir}/movies" + os.makedirs(show_dir, exist_ok=True) os.makedirs(movie_dir, exist_ok=True) @@ -155,12 +158,15 @@ def getDownloadBasePath(): def doDownload(url, savefile, savepath): - file_path = download( - url, - PLEXAPI_AUTH_SERVER_TOKEN, - filename=savefile, - savepath=savepath, - ) + if INCLUDE_FILES: + file_path = download( + url, + PLEXAPI_AUTH_SERVER_TOKEN, + filename=savefile, + savepath=savepath, + ) + else: + file_path = None return file_path @@ -253,29 +259,31 @@ def get_common_video_info(item): tmpDict = {"match": matchDict} - tmpDict["content_rating"] = item.contentRating - tmpDict["title"] = item.title - if item.titleSort is not None: + if config.get_bool("metadata.include_content_rating"): + tmpDict["content_rating"] = item.contentRating + if config.get_bool("metadata.include_title"): + tmpDict["title"] = item.title + if config.get_bool("metadata.include_sort_title") and item.titleSort is not None: tmpDict["sort_title"] = item.titleSort - if item.originalTitle is not None: + if config.get_bool("metadata.include_original_title") and item.originalTitle is not None: tmpDict["original_title"] = item.originalTitle - if item.originallyAvailableAt is not None: + if config.get_bool("metadata.include_originally_available") and item.originallyAvailableAt is not None: tmpDict["originally_available"] = item.originallyAvailableAt.strftime( "%Y-%m-%d" ) - if item.userRating is not None: + if config.get_bool("metadata.include_user_rating") and item.userRating is not None: tmpDict["user_rating"] = item.userRating - if item.audienceRating is not None: + if config.get_bool("metadata.include_audience_rating") and item.audienceRating is not None: tmpDict["audience_rating"] = item.audienceRating - if item.rating is not None: + if config.get_bool("metadata.include_critic_rating") and item.rating is not None: tmpDict["critic_rating"] = item.rating - if item.studio is not None: + if config.get_bool("metadata.include_studio") and item.studio is not None: tmpDict["studio"] = item.studio - if item.tagline is not None: + if config.get_bool("metadata.include_tagline") and item.tagline is not None: tmpDict["tagline"] = item.tagline - if item.summary is not None: + if config.get_bool("metadata.include_summary") and item.summary is not None: tmpDict["summary"] = item.summary poster_path = getPoster(item) if poster_path is not None: @@ -286,21 +294,24 @@ def get_common_video_info(item): tmpDict["file_background"] = background_path if item.type == "movie": - if item.editionTitle is not None: + if config.get_bool("metadata.include_edition") and item.editionTitle is not None: tmpDict["edition"] = item.editionTitle - if len(item.directors) > 0: + if config.get_bool("metadata.include_director") and len(item.directors) > 0: tmpDict["director"] = [str(item) for item in item.directors] - if len(item.countries) > 0: + if config.get_bool("metadata.include_country") and len(item.countries) > 0: tmpDict["country"] = [str(item) for item in item.countries] - if len(item.genres) > 0: - tmpDict["genre"] = [str(item) for item in item.genres] - if len(item.writers) > 0: + if config.get_bool("metadata.include_genre") and len(item.genres) > 0: + if ONLY_ONE_GENRE: + tmpDict["genre"] = [str(item.genres[0])] + else: + tmpDict["genre"] = [str(item) for item in item.genres] + if config.get_bool("metadata.include_writer") and len(item.writers) > 0: tmpDict["writer"] = [str(item) for item in item.writers] - if len(item.producers) > 0: + if config.get_bool("metadata.include_producer") and len(item.producers) > 0: tmpDict["producer"] = [str(item) for item in item.producers] - if len(item.collections) > 0: + if config.get_bool("metadata.include_collection") and len(item.collections) > 0: tmpDict["collection"] = [str(item) for item in item.collections] - if len(item.labels) > 0: + if config.get_bool("metadata.include_labels") and len(item.labels) > 0: tmpDict["label"] = [str(item) for item in item.labels] # metadata_language1 default, ar-SA, ca-ES, cs-CZ, da-DK, de-DE, el-GR, en-AU, en-CA, en-GB, en-US, es-ES, es-MX, et-EE, fa-IR, fi-FI, fr-CA, fr-FR, he-IL, hi-IN, hu-HU, id-ID, it-IT, ja-JP, ko-KR, lt-LT, lv-LV, nb-NO, nl-NL, pl-PL, pt-BR, pt-PT, ro-RO, ru-RU, sk-SK, sv-SE, th-TH, tr-TR, uk-UA, vi-VN, zh-CN, zh-HK, zh-TW Movies, Shows @@ -316,11 +327,11 @@ def get_common_video_info(item): ) else: - if len(item.genres) > 0: + if config.get_bool("metadata.include_genre") and len(item.genres) > 0: tmpDict["genre"] = [str(item) for item in item.genres] - if len(item.collections) > 0: + if config.get_bool("metadata.include_collection") and len(item.collections) > 0: tmpDict["collection"] = [str(item) for item in item.collections] - if len(item.labels) > 0: + if config.get_bool("metadata.include_labels") and len(item.labels) > 0: tmpDict["label"] = [str(item) for item in item.labels] if item.episodeSort > -1: @@ -375,10 +386,10 @@ def get_common_video_info(item): "yes" if item.enableCreditsMarkerGeneration > 0 else "no" ) - if item.audioLanguage != "": + if config.get_bool("metadata.include_audio_language") and item.audioLanguage != "": tmpDict["audio_language"] = item.audioLanguage - if item.subtitleLanguage != "": + if config.get_bool("metadata.include_subtitle_language") and item.subtitleLanguage != "": tmpDict["subtitle_language"] = item.subtitleLanguage # subtitle mode. (-1 = Account default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). @@ -394,16 +405,16 @@ def get_season_info(season): try: tmpDict = {"title": season.title} - if season.userRating is not None: + if config.get_bool("metadata.include_user_rating") and season.userRating is not None: tmpDict["user_rating"] = season.userRating - if season.summary != "": + if config.get_bool("metadata.include_summary") and season.summary != "": tmpDict["summary"] = season.summary - if len(season.collections) > 0: + if config.get_bool("metadata.include_collection") and len(season.collections) > 0: tmpDict["collection"] = [str(item) for item in season.collections] - if len(season.labels) > 0: + if config.get_bool("metadata.include_labels") and len(season.labels) > 0: tmpDict["label"] = [str(item) for item in season.labels] poster_path = getPoster(season) @@ -414,10 +425,10 @@ def get_season_info(season): if background_path is not None: tmpDict["file_background"] = background_path - if season.audioLanguage != "": + if config.get_bool("metadata.include_audio_language") and season.audioLanguage != "": tmpDict["audio_language"] = season.audioLanguage - if season.subtitleLanguage != "": + if config.get_bool("metadata.include_subtitle_language") and season.subtitleLanguage != "": tmpDict["subtitle_language"] = season.subtitleLanguage # subtitle mode. (-1 = Account default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). @@ -433,36 +444,36 @@ def get_episode_info(episode): try: tmpDict = {"title": episode.title} - if episode.titleSort is not None: + if config.get_bool("metadata.include_sort_title") and episode.titleSort is not None: tmpDict["sort_title"] = episode.titleSort - if episode.originallyAvailableAt is not None: + if config.get_bool("metadata.include_originally_available") and episode.originallyAvailableAt is not None: tmpDict["originally_available"] = episode.originallyAvailableAt.strftime( "%Y-%m-%d" ) # content_rating Text to change Content Rating. Movies, Shows, Episodes - if episode.userRating is not None: + if config.get_bool("metadata.include_user_rating") and episode.userRating is not None: tmpDict["user_rating"] = episode.userRating - if episode.audienceRating is not None: + if config.get_bool("metadata.include_audience_rating") and episode.audienceRating is not None: tmpDict["audience_rating"] = episode.audienceRating - if episode.rating is not None: + if config.get_bool("metadata.include_critic_rating") and episode.rating is not None: tmpDict["critic_rating"] = episode.rating - if season.summary != "": - tmpDict["summary"] = season.summary + if config.get_bool("metadata.include_summary") and episode.summary != "": + tmpDict["summary"] = episode.summary - if len(episode.directors) > 0: + if config.get_bool("metadata.include_director") and len(episode.directors) > 0: tmpDict["director"] = [str(item) for item in episode.directors] - if len(episode.writers) > 0: + if config.get_bool("metadata.include_writer") and len(episode.writers) > 0: tmpDict["writer"] = [str(item) for item in episode.writers] - if len(episode.collections) > 0: + if config.get_bool("metadata.include_collection") and len(episode.collections) > 0: tmpDict["collection"] = [str(item) for item in episode.collections] - if len(episode.labels) > 0: + if config.get_bool("metadata.include_labels") and len(episode.labels) > 0: tmpDict["label"] = [str(item) for item in episode.labels] - if len(episode.producers) > 0: + if config.get_bool("metadata.include_producer") and len(episode.producers) > 0: tmpDict["producer"] = [str(item) for item in episode.producers] poster_path = getPoster(item) diff --git a/Plex/README.md b/Plex/README.md index 41eb69f..dc41f40 100644 --- a/Plex/README.md +++ b/Plex/README.md @@ -8,152 +8,11 @@ See the top-level [README](../README.md) for setup instructions. All these scripts use the same `config.yaml` and requirements. -NOTE: on 08-22-2025 these scripts have changed to using a yaml config rather than an env file. TYOu will need to transfer your settings manually from one to the other. +NOTE: on 09-09-2025 these scripts have changed to using a yaml config rather than an env file. TYOu will need to transfer your settings manually from one to the other. ### `config.template.yaml` contents -```yaml -plex_api: - header_identifier: "media-scripts" - timeout: 360 - auth_server: - base_url: 'YOUR_PLEX_URL' - token: 'YOUR_PLEX_TOKEN' - log: - backup_count: 3 - format: "%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s" - level: "INFO" - path: "plexapi.log" - rotate_bytes: 512000 - show_secrets: 0 - skip_verify_ssl: 0 - -general: - tmdb_key: "TMDB_API_KEY" # https://developers.themoviedb.org/3/getting-started/introduction - tvdb_key: "TVDB_V4_API_KEY" # currently not used; https://thetvdb.com/api-information - delay: 1 # optional delay between items - library_names: Movies,TV Shows,Movies 4K # comma-separated list of libraries to act on - superchat: 0 - -kometa: - config_dir: /kometa/is/here - -image_download: - what_to_grab: - ### collection-related - include_collection_artwork: 1 # should get-all-posters retrieve collection posters? - only_collection_artwork: 0 # should get-all-posters retrieve ONLY collection posters? - only_these_collections: "Bing|Bang|Boing" # only grab artwork for these collections and items in them - - ### tv-related - seasons: 1 # should get-all-posters retrieve season posters? - episodes: 1 # should get-all-posters retrieve episode posters? [requires GRAB_SEASONS] - - ### background-related - backgrounds: 1 # should get-all-posters retrieve backgrounds? - artwork: 1 # current background is downloaded with current poster - - ### quantity-related - only_current: 0 # should get-all-posters retrieve ONLY current artwork? - poster_depth: 20 # grab this many posters [0 grabs all] [ONLY_CURRENT overrides this] - - ### what-to-keep - keep_junk: 0 # keep files that script would normally delete [incorrect filetypes, mainly] - find_overlaid_images: 0 # check all downloaded images for overlays - retain_overlaid_images: 0 # keep images that have an overlay EXIF tag [this will override the following two] - retain_kometa_overlaid_images: 0 # keep images that have the Kometa overlay EXIF tag - retain_tcm_overlaid_images: 0 # keep images that have the TCM overlay EXIF tag - - ## where-to-put-it - where_to_put_it: - use_asset_naming: 1 # should grab-all-posters name images to match Kometa's Asset Directory requirements? - use_asset_folders: 1 # should those Kometa-Asset-Directory names use asset folders? - use_asset_subfolders: 0 # create asset folders in subfolders ["Collections", "Other", or [0-9, A-Z]] ] - assets_by_libraries: 1 # should those Kometa-Asset-Directory images be sorted into library folders? - asset_dir: "assets" # top-level directory for those Kometa-Asset-Directory images - # if asset-directory naming is on, the next three are ignored - poster_dir: "extracted_posters" # put downloaded posters here - current_poster_dir: "current_posters" # put downloaded current posters and artwork here - poster_consolidate: 0 # if false, posters are separated into folders by library - - ## tracking - tracking: - track_urls: 1 # If set to 1, URLS are tracked and won't be downloaded twice - track_completion: 1 # If set to 1, movies/shows are tracked as complete by rating id - track_image_sources: 1 # keep a file containing file names and source URLs - - ## general - general: - poster_download: 1 # if false, generate a script rather than downloading - folders_only: 0 # Just build out the folder hierarchy; no image downloading - default_years_back: 2 # in absence of a "last run date", grab things added this many years back. - # 0 sets the fallback date to the beginning of time - threaded_downloads: 0 # should downloads be done in the background in threads? - reset_libraries: "Bing,Bang,Boing" # reset "last time" count to the fallback date for these libraries - reset_collections: "Bing,Bang,Boing" # CURRENTLY UNUSED - add_source_exif_comment: 1 # CURRENTLY UNUSED - -status: - plex_owner: "yournamehere" # account name of the server owner - target_plex_url: "https://plex.domain2.tld" # As above, the target of apply_all_status - target_plex_token: "PLEX-TOKEN-TWO" # As above, the target of apply_all_status - target_plex_owner: "yournamehere" # As above, the target of apply_all_status - library_map: '{"LIBRARY_ON_PLEX":"LIBRARY_ON_TARGET_PLEX", ...}' - # In apply_all_status, map libraries according to this JSON. - -reset_posters: - track_reset_status: 1 # should reset-posters-* keep track of status and pick up where it left off? - clear_reset_status: 0 - local_reset_archive: 1 # should reset-posters-tmdb keep a local archive of posters? - override_overlay_status: 0 - target_labels: this label, that label # comma-separated list of labels to reset posters on - remove_labels: 0 # attempt to remove the TARGET_LABELs from items after resetting the poster - reset_seasons: 1 # reset-posters-* resets season artwork as well in TV libraries - reset_episodes: 1 # reset-posters-* resets episode artwork as well in TV libraries [requires RESET_SEASONS=True] - retain_reset_status_file: 0 # Don't delete the reset progress file at the end - flush_status_at_start: 0 # Delete the reset progress file at the start instead of reading it - reset_seasons_with_series: 0 # If there isn't a season poster, use the series poster - dry_run: 0 # [currently only works with reset-posters-*]; don't actually do anything, just log - -list_item_ids: - include_collection_members: 0 - only_collection_members: 0 - -delete_collection: - keep_collections: "bing,bang" # List of collections to keep - -refresh_metadata: - refresh_1970_only: 1 # If 1, only refresh things that have an originally-available date of 1970-01-01 - -rematch_items: - unmatched_only: 1 # If 1, only rematch things that are currently unmatched - -reset_added_at: - adjust_date_futures_only: 0 # Only look at items that show up as added in the future - adjust_date_epoch_only: 1 # Only adjust items that have "originally available" dates of `1970-01-01` - -actor: - cast_depth: 20 # how deep to go into the cast for actor collections - top_count: 10 # how many actors to export - job_type: "Actor" - known_for_only: 0 # ignore cast members who are not primarily known as actors - build_collections: 0 # build yaml for Kometa config.yml - num_collections: 20 # this many actors in Kometa yaml - track_gender: 1 # Pay attention to actor gender [as recorded on TMDB] - min_gender_none: 5 # include minimum this many "none" gendered actors in the YAML, if possible - min_gender_female: 5 # include minimum this many "female" gendered actors in the YAML, if possible - min_gender_male: 5 # include minimum this many "male" gendered actors in the YAML, if possible - min_gender_nb: 5 # include minimum this many "non-binary" gendered actors in the YAML, if possible - -low_poster_count: - poster_threshold: 10 # how many posters counts as a "low" count? - -crew: - depth: 20 - count: 100 - target_job: Director - show_jobs: 0 -``` +The `config.yaml` template can be viewed here: [../config.template.yaml](../config.template.yaml). ## Scripts: 1. [adjust-added-dates.py](#adjust-added-datespy) - fix broken added and perhaps originally available dates in your library diff --git a/config.template.yaml b/config.template.yaml index 084b6cf..b36f56a 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -141,3 +141,30 @@ crew: count: 100 target_job: Director show_jobs: 0 + +metadata: + only_first_genre: true + + include_files: false + + include_audience_rating: true + include_content_rating: true + include_country: true + include_critic_rating: true + include_user_rating: true + include_director: true + include_genre: true + include_original_title: true + include_originally_available: true + include_producer: true + include_sort_title: true + include_studio: true + include_summary: true + include_tagline: true + include_title: true + include_writer: true + include_edition: true + include_collection: true + include_labels: true + include_audio_language: true + include_subtitle_language: true From adbb0cd7f76aad1df43b71f23fc05696e595ae88 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Wed, 17 Sep 2025 08:33:47 -0500 Subject: [PATCH 3/7] testing tweaks --- .vscode/launch.json | 41 ++++++++++++ Kometa/captions.py | 67 ++++++++++++++++++++ Plex/config.py | 131 +++++++++++++++++++++++++++++++++++++++ Plex/grab-all-posters.py | 73 +++++++++++----------- Plex/helpers.py | 8 +++ config.template.yaml | 12 ++-- 6 files changed, 291 insertions(+), 41 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 Kometa/captions.py create mode 100644 Plex/config.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..873a59e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Run current Kometa file", + "type": "debugpy", + "justMyCode": false, + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}/Kometa", + "purpose": [ + "debug-in-terminal" + ], + "args": [ ] + }, + { + "name": "Run current Plex file", + "type": "debugpy", + "justMyCode": false, + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}/Plex", + "purpose": [ + "debug-in-terminal" + ], + "args": [ ] + }, + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + } + ] +} \ No newline at end of file diff --git a/Kometa/captions.py b/Kometa/captions.py new file mode 100644 index 0000000..d632a18 --- /dev/null +++ b/Kometa/captions.py @@ -0,0 +1,67 @@ +from pytube import Channel +import os + +def download_subtitles_from_channel(channel_url, output_path='.'): + """ + Downloads subtitles for all videos from a YouTube channel. + + Args: + channel_url (str): The URL of the YouTube channel. + output_path (str): The directory to save the subtitles. + """ + try: + c = Channel(channel_url) + print(f"Processing channel: {c.channel_name}") + + # Create output directory if it doesn't exist + if not os.path.exists(output_path): + os.makedirs(output_path) + print(f"Created output directory: {output_path}") + + video_count = 0 + caption_count = 0 + + for video in c.videos: + video_count += 1 + print(f"\nProcessing video {video_count}: {video.title}") + + try: + # Check if captions are available + if video.captions: + # You can specify the language code, e.g., 'en' for English + # Use video.captions to see all available caption languages + if 'en' in video.captions: + caption = video.captions['en'] + print("Downloading English captions...") + + # Generate a safe filename + filename = f"{video.title}.en.srt" + safe_filename = "".join(x for x in filename if x.isalnum() or x in "._- ").strip() + file_path = os.path.join(output_path, safe_filename) + + # Save the captions as an .srt file + with open(file_path, 'w', encoding='utf-8') as f: + f.write(caption.generate_srt_file()) + caption_count += 1 + print(f"✅ Downloaded and saved captions to: {file_path}") + else: + print("⚠️ English captions not found for this video. Skipping.") + else: + print("⚠️ No captions available for this video. Skipping.") + + except Exception as e: + print(f"An error occurred while processing video {video.title}: {e}") + + print(f"\n--- Script finished ---") + print(f"Successfully processed {len(c.videos)} videos.") + print(f"Successfully downloaded captions for {caption_count} videos.") + + except Exception as e: + print(f"An error occurred with the channel URL: {e}") + +# --- USAGE --- +# Replace with the URL of the YouTube channel you want to download from +CHANNEL_URL = 'https://www.youtube.com/@freecodecamp' +OUTPUT_FOLDER = './youtube_captions' + +download_subtitles_from_channel(CHANNEL_URL, OUTPUT_FOLDER) \ No newline at end of file diff --git a/Plex/config.py b/Plex/config.py new file mode 100644 index 0000000..03e750e --- /dev/null +++ b/Plex/config.py @@ -0,0 +1,131 @@ +import os + +import yaml + + +class Config: + """ + A class to handle configuration settings loaded from a YAML file. + """ + _instance = None + + def __new__(cls, config_path="config.yaml"): + if cls._instance is None: + cls._instance = super(Config, cls).__new__(cls) + cls._instance._initialize(config_path) + return cls._instance + + def _initialize(self, config_path): + if not os.path.exists(config_path): + raise FileNotFoundError(f"Configuration file not found at {config_path}") + + try: + with open(config_path, 'r') as file: + self._settings = yaml.safe_load(file) + except yaml.YAMLError as e: + raise ValueError(f"Error parsing YAML file: {e}") + + def __getattr__(self, name): + """ + Allows accessing settings like attributes (e.g., config.database.host). + """ + if name in self._settings: + value = self._settings[name] + if isinstance(value, dict): + # Recursively wrap nested dictionaries + return _DictWrapper(value) + return value + + # Fallback to the default __getattr__ behavior + return super().__getattr__(name) + + def get(self, key, default=None): + """ + Provides a safe way to get a value with an optional default, + similar to dictionary's .get() method. + """ + keys = key.split('.') + current_dict = self._settings + for k in keys: + if isinstance(current_dict, dict) and k in current_dict: + current_dict = current_dict[k] + else: + return default + return current_dict + + def get_int(self, key, default=0): + """ + Provides a safe way to get a value with an optional default, + similar to dictionary's .get() method. + """ + keys = key.split('.') + current_dict = self._settings + for k in keys: + if isinstance(current_dict, dict) and k in current_dict: + current_dict = current_dict[k] + else: + return default + return current_dict + + def get_bool(self, key, default=False): + """ + Provides a safe way to get a value with an optional default, + similar to dictionary's .get() method. + """ + keys = key.split('.') + current_dict = self._settings + for k in keys: + if isinstance(current_dict, dict) and k in current_dict: + current_dict = current_dict[k] + else: + return default + if type(current_dict) is str: + current_dict = eval(current_dict) + return bool(current_dict) + + +class _DictWrapper: + """ + Helper class to enable attribute-style access for nested dictionaries. + """ + def __init__(self, data): + self._data = data + + def __getattr__(self, name): + if name in self._data: + value = self._data[name] + if isinstance(value, dict): + return _DictWrapper(value) + return value + raise AttributeError(f"'{self.__class__.__name__}' has no attribute '{name}'") + +# Example Usage: +if __name__ == "__main__": + # Create a dummy config.yaml file for the example + sample_config_content = """ + tvdb: + apikey: "bed9264b-82e9-486b-af01-1bb201bcb595" # Enter TMDb API Key (REQUIRED) + + omdb: + apikey: "9e62df51" # Enter OMDb API Key (Optional) + """ + with open("config.yaml", "w") as f: + f.write(sample_config_content) + + try: + config = Config() + + print("--- Attribute Access ---") + print(f"tvdb key: {config.tvdb.apikey}") + print(f"omdb key: {config.omdb.apikey}") + + print("\n--- 'get' Method Access ---") + print(f"tvdb key: {config.get('tvdb.apikey')}") + print(f"Default Value Test: {config.get('omdb.sproing', 'default_value')}") + + except (FileNotFoundError, ValueError) as e: + print(f"An error occurred: {e}") + finally: + # Clean up the dummy file + if os.path.exists("config.yaml"): + os.remove("config.yaml") \ No newline at end of file diff --git a/Plex/grab-all-posters.py b/Plex/grab-all-posters.py index f1b4144..c39c7ff 100644 --- a/Plex/grab-all-posters.py +++ b/Plex/grab-all-posters.py @@ -143,43 +143,41 @@ def superchat(msg, level, logfile): print("================== ATTENTION ==================") ID_FILES = False -POSTER_CONSOLIDATE = config.get_bool('image_download.general.poster_consolidate', True) -INCLUDE_COLLECTION_ARTWORK = config.get_bool('image_download.image.include_collection_artwork', True) -ONLY_COLLECTION_ARTWORK = config.get_bool('image_download.image.only_collection_artwork', False) +POSTER_CONSOLIDATE = config.get_bool('image_download.general.poster_consolidate', False) +INCLUDE_COLLECTION_ARTWORK = config.get_bool('image_download.what_to_grab.include_collection_artwork', False) +ONLY_COLLECTION_ARTWORK = config.get_bool('image_download.what_to_grab.only_collection_artwork', False) DELAY = config.get_int('general.delay', 1) -GRAB_BACKGROUNDS = config.get_bool('image_download.general.grab_backgrounds', True) -GRAB_SEASONS = config.get_bool('image_download.general.grab_seasons', True) -ONLY_SEASONS = config.get_bool('image_download.general.only_seasons', False) +GRAB_POSTERS = config.get_bool('image_download.what_to_grab.artwork', True) +# GRAB_BACKGROUNDS = config.get_bool('image_download.what_to_grab.backgrounds', True) +GRAB_SEASONS = config.get_bool('image_download.what_to_grab.seasons', True) +GRAB_EPISODES = config.get_bool('image_download.what_to_grab.episodes', True) -GRAB_EPISODES = config.get_bool('image_download.general.grab_episodes', True) -ONLY_EPISODES = config.get_bool('image_download.general.only_episodes', False) - -ONLY_CURRENT = config.get_bool('image_download.general.only_current', False) +ONLY_CURRENT = config.get_bool('image_download.what_to_grab.only_current', False) if ONLY_CURRENT: - POSTER_DIR = config.get('image_download.general.current_poster_dir', 'current_posters') + POSTER_DIR = config.get('image_download.where_to_put_it.current_poster_dir', 'current_posters') -TRACK_URLS = config.get_bool('image_download.general.track_urls', True) -TRACK_COMPLETION = config.get_bool('image_download.general.track_completion', False) +TRACK_URLS = config.get_bool('image_download.tracking.track_urls', True) +TRACK_COMPLETION = config.get_bool('image_download.tracking.track_completion', False) +TRACK_IMAGE_SOURCES = config.get_bool('image_download.tracking.track_image_sources', False) -ASSET_DIR = config.get('image_download.general.asset_dir', 'assets') +ASSET_DIR = config.get('image_download.where_to_put_it.asset_dir', 'assets') ASSET_PATH = Path(ASSET_DIR) -USE_ASSET_NAMING = config.get_bool('image_download.general.use_asset_naming', False) -USE_ASSET_FOLDERS = config.get_bool('image_download.general.use_asset_folders', False) -ASSETS_BY_LIBRARIES = config.get_bool('image_download.general.assets_by_libraries', False) -NO_FS_WARNING = config.get_bool('image_download.general.no_fs_warning', False) +USE_ASSET_NAMING = config.get_bool('image_download.where_to_put_it.use_asset_naming', False) +USE_ASSET_FOLDERS = config.get_bool('image_download.where_to_put_it.use_asset_folders', False) +ASSETS_BY_LIBRARIES = config.get_bool('image_download.where_to_put_it.assets_by_libraries', False) +NO_FS_WARNING = config.get_bool('image_download.where_to_put_it.no_fs_warning', False) ADD_SOURCE_EXIF_COMMENT = config.get_bool('image_download.general.add_source_exif_comment', False) SRC_ARRAY = [] -TRACK_IMAGE_SOURCES = config.get_bool('image_download.general.track_image_sources', False) IGNORE_SHRINKING_LIBRARIES = config.get_bool('image_download.general.ignore_shrinking_libraries', False) -RETAIN_OVERLAID_IMAGES = config.get_bool('image_download.general.retain_overlaid_images', False) -FIND_OVERLAID_IMAGES = config.get_bool('image_download.general.find_overlaid_images', False) -RETAIN_KOMETA_OVERLAID_IMAGES = config.get_bool('image_download.general.retain_kometa_overlaid_images', False) -RETAIN_TCM_OVERLAID_IMAGES = config.get_bool('image_download.general.retain_tcm_overlaid_images', False) +RETAIN_OVERLAID_IMAGES = config.get_bool('image_download.what_to_grab.retain_overlaid_images', False) +FIND_OVERLAID_IMAGES = config.get_bool('image_download.what_to_grab.find_overlaid_images', False) +RETAIN_KOMETA_OVERLAID_IMAGES = config.get_bool('image_download.what_to_grab.retain_kometa_overlaid_images', False) +RETAIN_TCM_OVERLAID_IMAGES = config.get_bool('image_download.what_to_grab.retain_tcm_overlaid_images', False) if RETAIN_OVERLAID_IMAGES: RETAIN_KOMETA_OVERLAID_IMAGES = RETAIN_OVERLAID_IMAGES @@ -192,7 +190,7 @@ def superchat(msg, level, logfile): USE_ASSET_SUBFOLDERS = False FOLDERS_ONLY = False else: - USE_ASSET_SUBFOLDERS = config.get_bool('image_download.general.use_asset_subfolders', False) + USE_ASSET_SUBFOLDERS = config.get_bool('image_download.where_to_put_it.use_asset_subfolders', False) FOLDERS_ONLY = config.get_bool('image_download.general.folders_only', False) if FOLDERS_ONLY: ONLY_CURRENT = FOLDERS_ONLY @@ -214,14 +212,19 @@ def superchat(msg, level, logfile): print("Other file hierarchies are incompatible with the") print("KOMETA asset naming setup at this time.") print("================== ATTENTION ==================") - print("To skip this in future runs, add 'NO_FS_WARNING=1' to .env") + print("To skip this in future runs, add this setting to config.yml:") + print("") + print("image_download:") + print(" where_to_put_it:") + print(" no_fs_warning: 1") + print("") print("pausing for 15 seconds...") time.sleep(15) if not DELAY: DELAY = 0 -KEEP_JUNK = config.get_bool('image_download.general.keep_junk', False) +KEEP_JUNK = config.get_bool('image_download.what_to_grab.keep_junk', False) SCRIPT_FILE = "get_images.sh" SCRIPT_SEED = f"#!/bin/bash{os.linesep}{os.linesep}# SCRIPT TO GRAB IMAGES{os.linesep}{os.linesep}" @@ -421,7 +424,7 @@ def get_subdir(item): if item.type == "collection": level_01, msg = validate_filename(f"collection-{item.title}") else: - imdbid, tmid, tvid = get_ids(item.guids, None) + imdbid, tmid, tvid = get_ids(item.guids) if item.type == "season": level_01, msg = validate_filename( f"{item.parentTitle}-{TOPLEVEL_TMID}" @@ -591,7 +594,7 @@ def process_the_thing(params): try: thumbPath = download( f"{src_URL}", - PLEX_TOKEN, + config.get('plex_api.auth_server.token'), filename=tgt_filename, savepath=folder_path, ) @@ -777,7 +780,7 @@ def get_art(item, artwork_path, tmid, tvid, uuid, lib_title): src_URL = art.key if src_URL[0] == "/": - src_URL = f"{PLEX_URL}{art.key}&X-Plex-Token={PLEX_TOKEN}" + src_URL = f"{config.get('plex_api.auth_server.base_url')}{art.key}&X-Plex-Token={config.get('plex_api.auth_server.token')}" art_params["source"] = "local" art_params["src_URL"] = src_URL @@ -842,7 +845,7 @@ def get_posters(lib, item, uuid, title): show_title = None if item.type != "collection": - imdbid, tmid, tvid = get_ids(item.guids, None) + imdbid, tmid, tvid = get_ids(item.guids) if item.type == "show": show_title = item.title if item.type == "season": @@ -1040,7 +1043,7 @@ def get_posters(lib, item, uuid, title): if src_URL[0] == "/": src_URL = ( - f"{PLEX_URL}{poster.key}&X-Plex-Token={PLEX_TOKEN}" + f"{config.get('plex_api.auth_server.base_url')}{poster.key}&X-Plex-Token={config.get('plex_api.auth_server.token')}" ) art_params["source"] = "local" @@ -1089,7 +1092,7 @@ def get_posters(lib, item, uuid, title): attempts += 1 - if GRAB_BACKGROUNDS: + if config.get_bool('image_download.what_to_grab.backgrounds', True): get_art(item, artwork_path, tmid, tvid, uuid, lib_title) else: plogger( @@ -1165,7 +1168,7 @@ def add_script_line(artwork_path, poster_file_path, src_URL_with_token): the_lib = plex.library.section(lib) if lib_type_supported(the_lib): the_uuid = the_lib.uuid - superchat(f"{the_lib} uuid {the_uuid}", "info", "a") + superchat(f"{the_lib.title} uuid {the_uuid}", "info", "a") if the_lib.title in RESET_ARRAY or RESET_ARRAY[0] == "ALL_LIBRARIES": plogger( @@ -1179,13 +1182,13 @@ def add_script_line(artwork_path, poster_file_path, src_URL_with_token): if last_run_lib is None: plogger( - f"no last run date for {the_lib}, using {fallback_date}", + f"no last run date for {the_lib.title}, using {fallback_date}", "info", "a", ) last_run_lib = fallback_date - superchat(f"{the_lib} last run date: {last_run_lib}", "info", "a") + superchat(f"{the_lib.title} last run date: {last_run_lib}", "info", "a") ID_ARRAY = [] the_title = the_lib.title diff --git a/Plex/helpers.py b/Plex/helpers.py index 76c35ae..0166a23 100644 --- a/Plex/helpers.py +++ b/Plex/helpers.py @@ -729,3 +729,11 @@ def check_for_images(file_path): return True return False + +def get_redaction_list(): + config = Config() + redaction_list = [] + redaction_list.append(config.get("plex_api.auth_server.base_url")) + redaction_list.append(config.get("plex_api.auth_server.token")) + + return redaction_list diff --git a/config.template.yaml b/config.template.yaml index b36f56a..cb033e1 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -148,23 +148,23 @@ metadata: include_files: false include_audience_rating: true + include_audio_language: true + include_collection: true include_content_rating: true include_country: true include_critic_rating: true - include_user_rating: true include_director: true + include_edition: true include_genre: true + include_labels: true include_original_title: true include_originally_available: true include_producer: true include_sort_title: true include_studio: true + include_subtitle_language: true include_summary: true include_tagline: true include_title: true + include_user_rating: true include_writer: true - include_edition: true - include_collection: true - include_labels: true - include_audio_language: true - include_subtitle_language: true From 7710bf1a4352c81129f3604df109fdbd6762fd9e Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Tue, 23 Sep 2025 17:11:30 -0500 Subject: [PATCH 4/7] minor tweaks --- Plex/adjust-added-dates.py | 6 +- Plex/apply-all-status.py | 7 +- Plex/grab-all-posters.py | 186 ++++++++++++++++++++++++++++++++++++- Plex/grab-imdb-posters.py | 4 +- Plex/list-item-ids.py | 2 +- menu.py | 171 ++++++++++++++++++++++++++++++++++ 6 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 menu.py diff --git a/Plex/adjust-added-dates.py b/Plex/adjust-added-dates.py index 24a9b89..5afd803 100644 --- a/Plex/adjust-added-dates.py +++ b/Plex/adjust-added-dates.py @@ -37,9 +37,9 @@ logger("connection success", "info", "a") -plogger(f"Adjusting future dates only: {config.get_bool("adjust_date.futures_only", False)}", "info", "a") +plogger(f"Adjusting future dates only: {config.get_bool('adjust_date.futures_only', False)}", "info", "a") -plogger(f"Adjusting epoch dates only: {config.get_bool("adjust_date.epoch_only", False)}", "info", "a") +plogger(f"Adjusting epoch dates only: {config.get_bool('adjust_date.epoch_only', False)}", "info", "a") EPOCH_DATE = datetime(1970, 1, 1, 0, 0, 0) @@ -178,7 +178,7 @@ def is_epoch(the_date): else: blogger( - f"skipping {item.title}: EPOCH_ONLY {config.get_bool("adjust_date.epoch_only", False)}, originally available date {orig_date}", + f"skipping {item.title}: EPOCH_ONLY {config.get_bool('adjust_date.epoch_only', False)}, originally available date {orig_date}", "info", "a", bar, diff --git a/Plex/apply-all-status.py b/Plex/apply-all-status.py index b1c478c..b918d1c 100644 --- a/Plex/apply-all-status.py +++ b/Plex/apply-all-status.py @@ -18,6 +18,9 @@ # DONE 0.1.1: guard against empty library map # DONE 0.2.0: config class +print("CURRENTLY BROKEN") +exit() + # current dateTime now = datetime.now() @@ -32,9 +35,9 @@ config = Config('../config.yaml') -PLEX_OWNER = config.get("target.plex_owner") +PLEX_OWNER = config.get("status.plex_owner") -LIBRARY_MAP = config.get("target.library_map", "{}") +LIBRARY_MAP = config.get("status.library_map", "{}") try: lib_map = json.loads(LIBRARY_MAP) diff --git a/Plex/grab-all-posters.py b/Plex/grab-all-posters.py index c39c7ff..b3cb70d 100644 --- a/Plex/grab-all-posters.py +++ b/Plex/grab-all-posters.py @@ -73,10 +73,11 @@ # 0.8.9b change one blogger to plogger since there's no bar in that context # 0.8.9c use sanitize_filename on illegal names and actually use that name # 0.8.9d add JUNK reason to filename for visibility outside superchat +# 0.9.0 change to config class instead of env SCRIPT_NAME = Path(__file__).stem -VERSION = "0.8.9c" +VERSION = "0.9.0" config = Config('../config.yaml') @@ -153,6 +154,7 @@ def superchat(msg, level, logfile): # GRAB_BACKGROUNDS = config.get_bool('image_download.what_to_grab.backgrounds', True) GRAB_SEASONS = config.get_bool('image_download.what_to_grab.seasons', True) GRAB_EPISODES = config.get_bool('image_download.what_to_grab.episodes', True) +GRAB_LOGOS = config.get_bool('image_download.what_to_grab.logos', True) ONLY_CURRENT = config.get_bool('image_download.what_to_grab.only_current', False) @@ -470,7 +472,7 @@ def get_progress_string(item): return ret_val -def get_image_name(params, tgt_ext, background=False): +def get_image_name(params, tgt_ext, background=False, logo=False): ret_val = "" item_type = params["type"] @@ -491,6 +493,8 @@ def get_image_name(params, tgt_ext, background=False): if background: ret_val = f"_background{base_name}" + elif logo: + ret_val = f"_logo{base_name}" else: if item_type == "season": # _Season##.ext @@ -511,6 +515,8 @@ def get_image_name(params, tgt_ext, background=False): if background: ret_val = f"background-{base_name}" + elif logo: + ret_val = f"logo-{base_name}" else: if item_type == "season" or item_type == "episode": ret_val = f"{item_se_str}-{safe_name}-{base_name}" @@ -542,6 +548,7 @@ def process_the_thing(params): # assets/One Show/Adam-12 Collection background = params["background"] + logo = params["logo"] src_URL = params["src_URL"] provider = params["provider"] source = params["source"] @@ -554,7 +561,7 @@ def process_the_thing(params): result["status"] = "Nothing happened" tgt_ext = ".dat" if ID_FILES else ".jpg" - tgt_filename = get_image_name(params, tgt_ext, background) + tgt_filename = get_image_name(params, tgt_ext, background, logo) # in asset case, I have '_poster.ext' superchat(f"target filename {tgt_filename}", "info", "a") @@ -668,6 +675,173 @@ def __init__(self, provider, key): self.provider = provider self.key = key + # property logoUrl + # lockLogo()[source] + # unlockLogo()[source] + # logos()[source] + # uploadLogo(url=None, filepath=None)[source] + +def get_logo(item, artwork_path, tmid, tvid, uuid, lib_title): + global SCRIPT_STRING + + superchat( + f"entering get_logo {item.title}, {artwork_path}, {tmid}, {tvid}, {uuid}, {lib_title}", + "info", + "a", + ) + + attempts = 0 + if ONLY_CURRENT: + all_logos = [] + all_logos.append(poster_placeholder("current", item.logoUrl)) + else: + all_logos = item.logos() + + if USE_ASSET_NAMING: + logo_path = artwork_path + else: + logo_path = Path(artwork_path, "logos") + + while attempts < 5: + try: + progress_str = f"{get_progress_string(item)} - {len(all_logos)} logos" + + blogger(progress_str, "info", "a", bar) + + import fnmatch + + if ONLY_CURRENT: + no_point_in_looking = False + else: + count = 0 + logos_to_go = 0 + + if os.path.exists(logo_path): + # if I'm using asset naming, the names all start with `logo`` + if USE_ASSET_NAMING: + count = len( + fnmatch.filter(os.listdir(logo_path), "logo*.*") + ) + else: + count = len(fnmatch.filter(os.listdir(logo_path), "*.*")) + logger(f"{count} files in {logo_path}", "info", "a") + + logos_to_go = count - POSTER_DEPTH + + if logos_to_go < 0: + logo_to_go = abs(logos_to_go) + else: + logo_to_go = 0 + + logger( + f"{logo_to_go} needed to reach depth {POSTER_DEPTH}", "info", "a" + ) + + no_more_to_get = count >= len(all_logos) + full_for_now = count >= POSTER_DEPTH and POSTER_DEPTH > 0 + no_point_in_looking = full_for_now or no_more_to_get + if no_more_to_get: + logger( + f"Grabbed all available logos: {no_more_to_get}", "info", "a" + ) + if full_for_now: + logger( + f"full_for_now: {full_for_now} - {POSTER_DEPTH} image(s) retrieved already", + "info", + "a", + ) + + if not no_point_in_looking: + idx = 1 + for logo in all_logos: + if logo.key is not None: + if POSTER_DEPTH > 0 and idx > POSTER_DEPTH: + logger( + f"Reached max depth of {POSTER_DEPTH}; exiting loop", + "info", + "a", + ) + break + + art_params = {} + art_params["tmid"] = tmid + art_params["tvid"] = tvid + art_params["idx"] = idx + art_params["path"] = logo_path + art_params["provider"] = logo.provider + art_params["source"] = "remote" + art_params["uuid"] = uuid + art_params["lib_title"] = lib_title + + art_params["type"] = item.TYPE + art_params["title"] = item.title + + try: + art_params["seasonNumber"] = item.seasonNumber + except: + art_params["seasonNumber"] = None + + try: + art_params["episodeNumber"] = item.episodeNumber + except: + art_params["episodeNumber"] = None + + art_params["se_str"] = get_SE_str(item) + + art_params["logo"] = True + art_params["background"] = False + + src_URL = logo.key + if src_URL[0] == "/": + src_URL = f"{config.get('plex_api.auth_server.base_url')}{logo.key}&X-Plex-Token={config.get('plex_api.auth_server.token')}" + art_params["source"] = "local" + + art_params["src_URL"] = src_URL + + bar.text = f"{progress_str} - {idx}" + logger(f"processing {progress_str} - {idx}", "info", "a") + + superchat( + f"Built out params for {item.title}: {art_params}", + "info", + "a", + ) + if not TRACK_URLS or ( + TRACK_URLS and not check_url(src_URL, uuid) + ): + if THREADED_DOWNLOADS: + future = executor.submit( + process_the_thing, art_params + ) # does not block + # append it to the queue + my_futures.append(future) + superchat( + f"Added {item.title} to the download queue", + "info", + "a", + ) + else: + superchat( + f"Downloading {item.title} directly", "info", "a" + ) + process_the_thing(art_params) + else: + logger( + f"SKIPPING {item.title} as its URL was found in the URL tracking table: {src_URL} ", + "info", + "a", + ) + + else: + logger("skipping empty internal art object", "info", "a") + + idx += 1 + + attempts = 6 + except Exception as ex: + progress_str = f"EX: {ex} {item.title}" + logger(progress_str, "info", "a") + attempts += 1 def get_art(item, artwork_path, tmid, tvid, uuid, lib_title): global SCRIPT_STRING @@ -777,6 +951,7 @@ def get_art(item, artwork_path, tmid, tvid, uuid, lib_title): art_params["se_str"] = get_SE_str(item) art_params["background"] = True + art_params["logo"] = False src_URL = art.key if src_URL[0] == "/": @@ -1038,6 +1213,7 @@ def get_posters(lib, item, uuid, title): art_params["se_str"] = get_SE_str(item) art_params["background"] = False + art_params["logo"] = False src_URL = poster.key @@ -1094,6 +1270,10 @@ def get_posters(lib, item, uuid, title): if config.get_bool('image_download.what_to_grab.backgrounds', True): get_art(item, artwork_path, tmid, tvid, uuid, lib_title) + + if config.get_bool('image_download.what_to_grab.logos', True): + get_logo(item, artwork_path, tmid, tvid, uuid, lib_title) + else: plogger( "Skipping {item.title}, error determining target subdirectory", "info", "a" diff --git a/Plex/grab-imdb-posters.py b/Plex/grab-imdb-posters.py index b599c1b..c272ceb 100644 --- a/Plex/grab-imdb-posters.py +++ b/Plex/grab-imdb-posters.py @@ -27,7 +27,7 @@ level=logging.INFO, ) -logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") +logging.info(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}") print(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") config = Config('../config.yaml') @@ -72,7 +72,7 @@ def progress(count, total, status=""): for item in items: item_count = item_count + 1 - imdb_id, tmdb_id, tvdb_id = get_ids(item.guids, TMDB_KEY) + imdb_id, tmdb_id, tvdb_id = get_ids(item.guids) tmpDict = {} tmpDict["title"] = item.title diff --git a/Plex/list-item-ids.py b/Plex/list-item-ids.py index dfdd6ec..e1bdccc 100644 --- a/Plex/list-item-ids.py +++ b/Plex/list-item-ids.py @@ -7,7 +7,7 @@ from alive_progress import alive_bar from config import Config from helpers import (get_all_from_library, get_ids, get_plex, - get_target_libraries) + get_target_libraries, get_redaction_list) from logs import logger, plogger, setup_logger SCRIPT_NAME = Path(__file__).stem diff --git a/menu.py b/menu.py new file mode 100644 index 0000000..042654b --- /dev/null +++ b/menu.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +import os +import sys +import yaml +import subprocess +from pathlib import Path + +def load_config(): + """Load configuration from config.yaml""" + config_path = Path("config.yaml") + if not config_path.exists(): + print("❌ config.yaml not found. Please create it first.") + sys.exit(1) + + try: + with open(config_path, 'r') as f: + return yaml.safe_load(f) + except Exception as e: + print(f"❌ Error loading config.yaml: {e}") + sys.exit(1) + +def get_available_scripts(directory): + """Get list of available Python scripts in a directory""" + scripts = [] + script_dir = Path(directory) + if script_dir.exists(): + for file in script_dir.glob("*.py"): + if not file.name.startswith("_") and file.name != "menu.py": + scripts.append(file.name) + return sorted(scripts) + +def display_main_menu(config): + """Display the main category selection menu""" + print("\n" + "="*60) + print("🎬 PLEX MEDIA MANAGEMENT TOOLKIT") + print("="*60) + + # Show current config info + plex_url = config.get('plex_api', {}).get('auth_server', {}).get('base_url', 'Not configured') + libraries = config.get('general', {}).get('library_names', '').split(',') + print(f"📡 Plex Server: {plex_url}") + print(f"📚 Libraries: {len([lib.strip() for lib in libraries if lib.strip()])} configured") + print("-"*60) + + print("\nSelect Script Category:") + print(" 1. 🎭 Plex Scripts") + print(" 2. 🎨 Kometa Scripts") + print(" 3. 🎬 TMDB Scripts") + print(" 4. 🖼️ Plex Image Picker") + print(" 5. Exit") + print("-"*60) + +def display_script_menu(category, scripts): + """Display scripts for a specific category""" + print(f"\n{category} - Available Scripts:") + print("-"*60) + + script_map = {} + for i, script in enumerate(scripts, 1): + display_name = script.replace('.py', '').replace('-', ' ').title() + print(f" {i:2d}. {display_name}") + script_map[i] = script + + print(f" {len(scripts)+1:2d}. Back to Main Menu") + print("-"*40) + + return script_map + +def run_script(script_path): + """Execute the selected script""" + print(f"\n🚀 Running {script_path}...") + print("-"*40) + + try: + # Change to the script's directory and run it + script_dir = Path(script_path).parent + script_name = Path(script_path).name + + result = subprocess.run( + [sys.executable, script_name], + cwd=script_dir, + check=True + ) + print(f"\n✅ {script_name} completed successfully!") + except subprocess.CalledProcessError as e: + print(f"\n❌ {script_path} failed with exit code {e.returncode}") + except KeyboardInterrupt: + print(f"\n⚠️ {script_path} interrupted by user") + except Exception as e: + print(f"\n❌ Error running {script_path}: {e}") + + input("\nPress Enter to continue...") + +def handle_category_selection(category, directory, config): + """Handle script selection within a category""" + scripts = get_available_scripts(directory) + + if not scripts: + print(f"\n❌ No scripts found in {directory}") + input("Press Enter to continue...") + return + + while True: + try: + script_map = display_script_menu(category, scripts) + + choice = input(f"\nSelect script (1-{len(script_map)+1}): ").strip() + + if not choice.isdigit(): + print("❌ Please enter a valid number") + continue + + choice = int(choice) + + if choice == len(script_map) + 1: # Back to main menu + break + elif choice in script_map: + script_path = Path(directory) / script_map[choice] + run_script(script_path) + else: + print("❌ Invalid selection") + + except KeyboardInterrupt: + break + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + input("Press Enter to continue...") + +def main(): + """Main menu loop""" + # Load configuration + config = load_config() + + while True: + try: + display_main_menu(config) + + choice = input("\nSelect category (1-5): ").strip() + + if not choice.isdigit(): + print("❌ Please enter a valid number") + continue + + choice = int(choice) + + if choice == 1: + handle_category_selection("🎭 Plex Scripts", "Plex", config) + elif choice == 2: + handle_category_selection("🎨 Kometa Scripts", "Kometa", config) + elif choice == 3: + handle_category_selection("🎬 TMDB Scripts", "TMDB", config) + elif choice == 4: + print("\n🖼️ Starting Plex Image Picker...") + print("Navigate to: Plex Image Picker directory and run 'flask run'") + input("Press Enter to continue...") + elif choice == 5: + print("\n👋 Goodbye!") + break + else: + print("❌ Invalid selection") + + except KeyboardInterrupt: + print("\n\n👋 Goodbye!") + break + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + input("Press Enter to continue...") + +if __name__ == "__main__": + main() \ No newline at end of file From 19791fb11e0afb3d7e50a79b58b550cc735e027e Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Tue, 23 Sep 2025 17:14:03 -0500 Subject: [PATCH 5/7] add logo settings --- Plex/README.md | 2 ++ config.template.yaml | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Plex/README.md b/Plex/README.md index dc41f40..b122d4b 100644 --- a/Plex/README.md +++ b/Plex/README.md @@ -224,6 +224,8 @@ image_download: backgrounds: 1 # should get-all-posters retrieve backgrounds? artwork: 1 # current background is downloaded with current poster + logos: 1 # should get-all-posters retrieve logos? + ### quantity-related only_current: 0 # should get-all-posters retrieve ONLY current artwork? poster_depth: 20 # grab this many posters [0 grabs all] [ONLY_CURRENT overrides this] diff --git a/config.template.yaml b/config.template.yaml index cb033e1..e79b492 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -21,7 +21,7 @@ general: superchat: 0 kometa: - config_dir: /kometa/is/here + config_dir: /kometa/is/here/ image_download: what_to_grab: @@ -38,6 +38,8 @@ image_download: backgrounds: 1 # should get-all-posters retrieve backgrounds? artwork: 1 # current background is downloaded with current poster + logos: 1 + ### quantity-related only_current: 0 # should get-all-posters retrieve ONLY current artwork? poster_depth: 20 # grab this many posters [0 grabs all] [ONLY_CURRENT overrides this] From 56e75e706e569a8ede945fe44550e402503dbb14 Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Tue, 23 Sep 2025 18:06:00 -0500 Subject: [PATCH 6/7] TMDB Logo Download --- TMDB/config.py | 131 ++++++++ TMDB/helpers.py | 739 +++++++++++++++++++++++++++++++++++++++++++++ TMDB/tmdb_logos.py | 144 +++++++++ 3 files changed, 1014 insertions(+) create mode 100644 TMDB/config.py create mode 100644 TMDB/helpers.py create mode 100644 TMDB/tmdb_logos.py diff --git a/TMDB/config.py b/TMDB/config.py new file mode 100644 index 0000000..03e750e --- /dev/null +++ b/TMDB/config.py @@ -0,0 +1,131 @@ +import os + +import yaml + + +class Config: + """ + A class to handle configuration settings loaded from a YAML file. + """ + _instance = None + + def __new__(cls, config_path="config.yaml"): + if cls._instance is None: + cls._instance = super(Config, cls).__new__(cls) + cls._instance._initialize(config_path) + return cls._instance + + def _initialize(self, config_path): + if not os.path.exists(config_path): + raise FileNotFoundError(f"Configuration file not found at {config_path}") + + try: + with open(config_path, 'r') as file: + self._settings = yaml.safe_load(file) + except yaml.YAMLError as e: + raise ValueError(f"Error parsing YAML file: {e}") + + def __getattr__(self, name): + """ + Allows accessing settings like attributes (e.g., config.database.host). + """ + if name in self._settings: + value = self._settings[name] + if isinstance(value, dict): + # Recursively wrap nested dictionaries + return _DictWrapper(value) + return value + + # Fallback to the default __getattr__ behavior + return super().__getattr__(name) + + def get(self, key, default=None): + """ + Provides a safe way to get a value with an optional default, + similar to dictionary's .get() method. + """ + keys = key.split('.') + current_dict = self._settings + for k in keys: + if isinstance(current_dict, dict) and k in current_dict: + current_dict = current_dict[k] + else: + return default + return current_dict + + def get_int(self, key, default=0): + """ + Provides a safe way to get a value with an optional default, + similar to dictionary's .get() method. + """ + keys = key.split('.') + current_dict = self._settings + for k in keys: + if isinstance(current_dict, dict) and k in current_dict: + current_dict = current_dict[k] + else: + return default + return current_dict + + def get_bool(self, key, default=False): + """ + Provides a safe way to get a value with an optional default, + similar to dictionary's .get() method. + """ + keys = key.split('.') + current_dict = self._settings + for k in keys: + if isinstance(current_dict, dict) and k in current_dict: + current_dict = current_dict[k] + else: + return default + if type(current_dict) is str: + current_dict = eval(current_dict) + return bool(current_dict) + + +class _DictWrapper: + """ + Helper class to enable attribute-style access for nested dictionaries. + """ + def __init__(self, data): + self._data = data + + def __getattr__(self, name): + if name in self._data: + value = self._data[name] + if isinstance(value, dict): + return _DictWrapper(value) + return value + raise AttributeError(f"'{self.__class__.__name__}' has no attribute '{name}'") + +# Example Usage: +if __name__ == "__main__": + # Create a dummy config.yaml file for the example + sample_config_content = """ + tvdb: + apikey: "bed9264b-82e9-486b-af01-1bb201bcb595" # Enter TMDb API Key (REQUIRED) + + omdb: + apikey: "9e62df51" # Enter OMDb API Key (Optional) + """ + with open("config.yaml", "w") as f: + f.write(sample_config_content) + + try: + config = Config() + + print("--- Attribute Access ---") + print(f"tvdb key: {config.tvdb.apikey}") + print(f"omdb key: {config.omdb.apikey}") + + print("\n--- 'get' Method Access ---") + print(f"tvdb key: {config.get('tvdb.apikey')}") + print(f"Default Value Test: {config.get('omdb.sproing', 'default_value')}") + + except (FileNotFoundError, ValueError) as e: + print(f"An error occurred: {e}") + finally: + # Clean up the dummy file + if os.path.exists("config.yaml"): + os.remove("config.yaml") \ No newline at end of file diff --git a/TMDB/helpers.py b/TMDB/helpers.py new file mode 100644 index 0000000..0166a23 --- /dev/null +++ b/TMDB/helpers.py @@ -0,0 +1,739 @@ +import getpass +import hashlib +import itertools +import json +import os +import shutil +from pathlib import Path + +import plexapi +import requests +from config import Config +from dotenv import load_dotenv, set_key, unset_key +from pathvalidate import is_valid_filename, sanitize_filename +from PIL import Image +from plexapi.exceptions import Unauthorized +from plexapi.myplex import MyPlexAccount +from plexapi.server import PlexServer + +# Your fixed client identifier +CLIENT_IDENTIFIER = 'MediaScripts-chazlarson' +# File to store token + server URL +AUTH_FILE = '.plex_auth.json' +# Default network timeout (seconds) +DEFAULT_TIMEOUT = 360 + +stock_md5 = { + "plexapi.config.ini": '6209bb0c2ab877e6b74f757a004c84c9', +} + +def file_has_changed(filepath): + """ + Calculates the MD5 checksum of a file. + + Args: + filepath: The path to the file. + + Returns: + The MD5 checksum as a hexadecimal string. + """ + if filepath.name not in stock_md5: + print(f"File {filepath.name} not in stock_md5, returning True") + return True + old_hash = stock_md5.get(filepath.name) + md5_hash = hashlib.md5() + with open(filepath, "rb") as file: + # Read the file in chunks to handle large files efficiently + for chunk in iter(lambda: file.read(4096), b""): + md5_hash.update(chunk) + new_hash = md5_hash.hexdigest() + return new_hash != old_hash + + +def copy_file(source_path, destination_path): + """Copies a file from source to destination using pathlib. + + Args: + source_path (str or Path): Path to the source file. + destination_path (str or Path): Path to the destination file. + """ + source_path = Path(source_path) + destination_path = Path(destination_path) + + if source_path.is_file(): + shutil.copy(source_path, destination_path) + print(f"File copied from {source_path} to {destination_path}") + else: + print(f"Source path {source_path} is not a file.") + +def has_overlay(image_path): + kometa_overlay = False + tcm_overlay = False + + with Image.open(image_path) as image: + exif_tags = image.getexif() + kometa_overlay = ( + exif_tags is not None + and 0x04BC in exif_tags + and exif_tags[0x04BC] == "overlay" + ) + tcm_overlay = ( + exif_tags is not None + and 0x4242 in exif_tags + and exif_tags[0x4242] == "titlecard" + ) + + return kometa_overlay, tcm_overlay + + +def booler(thing): + if type(thing) is str: + thing = eval(thing) + return bool(thing) + + +def redact(thing, badthing): + return thing.replace(badthing, "(REDACTED)") + + +def redact(the_url, str_list): + ret_val = the_url + for thing in str_list: + ret_val = ret_val.replace(thing, "[REDACTED]") + return ret_val + + +def load_auth(): + """Return saved auth dict or None.""" + try: + with open(AUTH_FILE, 'r') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return None + + +def save_auth(data): + """Save auth dict and lock down file permissions.""" + with open(AUTH_FILE, 'w') as f: + json.dump(data, f) + try: + os.chmod(AUTH_FILE, 0o600) + except Exception: + pass + +def choose_server(servers): + """Prompt the user to choose one of the available Plex Media Server resources.""" + print("\nAvailable Plex Media Servers:") + for idx, res in enumerate(servers, start=1): + print(f" [{idx}] {res.name} ({res.clientIdentifier})") + while True: + choice = input(f"Select server [1–{len(servers)}]: ").strip() + if choice.isdigit(): + idx = int(choice) + if 1 <= idx <= len(servers): + return servers[idx-1] + print("❌ Invalid selection; please enter a number from the list.") + +def get_timeout(): + """Prompt user for network timeout value, with a safe default.""" + val = input(f"Network timeout in seconds [default {DEFAULT_TIMEOUT}]: ").strip() + if not val: + return DEFAULT_TIMEOUT + try: + t = float(val) + if t <= 0: + raise ValueError() + return t + except ValueError: + print(f"⚠️ Invalid timeout '{val}', using default {DEFAULT_TIMEOUT}.") + return DEFAULT_TIMEOUT + +def get_skip_ssl(): + """Prompt user whether to skip SSL certificate verification.""" + val = input("Skip SSL certificate verification? [y/N]: ").strip().lower() + return val in ('y', 'yes') + +def make_session(skip_ssl): + """Return a requests.Session configured for SSL verification or not.""" + if skip_ssl: + sess = requests.Session() + sess.verify = False + return sess + return None + +def do_login(timeout, session): + """Prompt for user/pass, let user pick server, connect & return PlexServer.""" + username = input('Plex Username: ') + password = getpass.getpass('Plex Password: ') + account = MyPlexAccount(username, password) + print(f"✔ Logged in as {account.username}") + + servers = [r for r in account.resources() if r.product == 'Plex Media Server'] + if not servers: + raise RuntimeError("No Plex Media Server found on your account.") + + resource = choose_server(servers) + print(f"→ Connecting to server: {resource.name} (timeout={timeout}s)") + + # resource.connect accepts a `session` and `timeout` argument + plex = resource.connect(timeout=timeout, session=session) + print(f"✔ Connected to Plex server: {plex.friendlyName}") + + token = getattr(account, 'authenticationToken', None) or getattr(account, '_token') + baseurl = getattr(plex, 'baseurl', None) or getattr(plex, '_baseurl') + save_auth({'token': token, 'baseurl': baseurl}) + print(f"⚑ Saved auth to {AUTH_FILE}") + return plex + + +def get_plex(): + plex = None + config = Config('../config.yaml') + os.environ['PLEXAPI_HEADER_IDENTIFIER'] = f"{config.get('plex_api.header_identifier')}" + os.environ['PLEXAPI_PLEXAPI_TIMEOUT'] = f"{config.get('plex_api.timeout')}" + os.environ['PLEXAPI_AUTH_SERVER_BASEURL'] = f"{config.get('plex_api.auth_server.base_url')}" + os.environ['PLEXAPI_AUTH_SERVER_TOKEN'] = f"{config.get('plex_api.auth_server.token')}" + os.environ['PLEXAPI_LOG_BACKUP_COUNT'] = f"{config.get('plex_api.log.backup_count')}" + os.environ['PLEXAPI_LOG_FORMAT'] = f"{config.get('plex_api.log.format')}" + os.environ['PLEXAPI_LOG_LEVEL'] = f"{config.get('plex_api.log.level')}" + os.environ['PLEXAPI_LOG_PATH'] = f"{config.get('plex_api.log.path')}" + os.environ['PLEXAPI_LOG_ROTATE_BYTES'] = f"{config.get('plex_api.log.rotate_bytes')}" + os.environ['PLEXAPI_LOG_SHOW_SECRETS'] = f"{config.get('plex_api.log.show_secrets')}" + os.environ['PLEXAPI_SKIP_VERIFYSSL'] = f"{config.get('plex_api.skip_verify_ssl')}" # ignore self signed certificate errors + + try: + print("creating plex with plexapi config") + plex = PlexServer() + print(f"connected to {plex.friendlyName}") + except Exception as ex: + print(f"plexapi config failed: {ex}") + auth = load_auth() + if auth: + try: + print("creating plex with saved auth") + plex = PlexServer(auth['url'], token=auth['token']) + print(f"connected to {plex.friendlyName}") + except Unauthorized: + print("Saved auth is invalid. Please re-authenticate.") + auth = None + else: + print("No saved auth found. Please authenticate.") + timeout = get_timeout() + skip_ssl = get_skip_ssl() + session = make_session(skip_ssl) + plex = do_login(timeout, session) + + return plex + +def get_target_libraries(plex): + if plex: + ALL_LIBS = plex.library.sections() + else: + print(f"Plex connection failed") + return None + + print(f"{len(ALL_LIBS)} libraries found") + + config = Config() + + LIBRARY_NAMES = config.get("general.library_names") + + if LIBRARY_NAMES and len(LIBRARY_NAMES) > 0: + LIB_ARRAY = [s.strip() for s in LIBRARY_NAMES.split(",")] + else: + LIB_ARRAY = None + print(f"No libraries specified in config") + print(f"Processing all {len(ALL_LIBS)} libraries") + + if LIB_ARRAY is None: + LIB_ARRAY = [] + for lib in ALL_LIBS: + LIB_ARRAY.append(f"{lib.title.strip()}") + + return LIB_ARRAY + +imdb_str = "imdb://" +tmdb_str = "tmdb://" +tvdb_str = "tvdb://" + + +def get_ids(theList): + imdbid = None + tmid = None + tvid = None + for guid in theList: + if imdb_str in guid.id: + imdbid = guid.id.replace(imdb_str, "") + if tmdb_str in guid.id: + tmid = guid.id.replace(tmdb_str, "") + if tvdb_str in guid.id: + tvid = guid.id.replace(tvdb_str, "") + + return imdbid, tmid, tvid + + +def validate_filename(filename): + # return filename + if is_valid_filename(filename): + return filename, None + else: + mapping_name = sanitize_filename(filename) + stat_string = f"Log Folder Name: {filename} is invalid using {mapping_name}" + return mapping_name, stat_string + + +def getPath(library, item, season=False): + if item.type == "collection": + return "Collection", item.title + else: + if library.type == "movie": + for media in item.media: + for part in media.parts: + return Path(part.file).parent, Path(part.file).stem + elif library.type == "show": + for episode in item.episodes(): + for media in episode.media: + for part in media.parts: + if season: + return Path(part.file).parent, Path(part.file).stem + return ( + Path(part.file).parent.parent, + Path(part.file).parent.parent.stem, + ) + + +def normalise_environment(key_values): + """Converts denormalised dict of (string -> string) pairs, where the first string + is treated as a path into a nested list/dictionary structure + { + "FOO__1__BAR": "setting-1", + "FOO__1__BAZ": "setting-2", + "FOO__2__FOO": "setting-3", + "FOO__2__BAR": "setting-4", + "FIZZ": "setting-5", + } + to the nested structure that this represents + { + "FOO": [{ + "BAR": "setting-1", + "BAZ": "setting-2", + }, { + "FOO": "setting-3", + "BAR": "setting-4", + }], + "FIZZ": "setting-5", + } + If all the keys for that level parse as integers, then it's treated as a list + with the actual keys only used for sorting + This function is recursive, but it would be extremely difficult to hit a stack + limit, and this function would typically by called once at the start of a + program, so efficiency isn't too much of a concern. + + Copyright (c) 2018 Department for International Trade. All rights reserved. + This work is licensed under the terms of the MIT license. + For a copy, see https://opensource.org/licenses/MIT. + """ + + # Separator is chosen to + # - show the structure of variables fairly easily; + # - avoid problems, since underscores are usual in environment variables + separator = "__" + + def get_first_component(key): + return key.split(separator)[0] + + def get_later_components(key): + return separator.join(key.split(separator)[1:]) + + without_more_components = { + key: value for key, value in key_values.items() if not get_later_components(key) + } + + with_more_components = { + key: value for key, value in key_values.items() if get_later_components(key) + } + + def grouped_by_first_component(items): + def by_first_component(item): + return get_first_component(item[0]) + + # groupby requires the items to be sorted by the grouping key + return itertools.groupby( + sorted(items, key=by_first_component), + by_first_component, + ) + + def items_with_first_component(items, first_component): + return { + get_later_components(key): value + for key, value in items + if get_first_component(key) == first_component + } + + nested_structured_dict = { + **without_more_components, + **{ + first_component: normalise_environment( + items_with_first_component(items, first_component) + ) + for first_component, items in grouped_by_first_component( + with_more_components.items() + ) + }, + } + + def all_keys_are_ints(): + def is_int(string): + try: + int(string) + return True + except ValueError: + return False + + return all([is_int(key) for key, value in nested_structured_dict.items()]) + + def list_sorted_by_int_key(): + return [ + value + for key, value in sorted( + nested_structured_dict.items(), key=lambda key_value: int(key_value[0]) + ) + ] + + return list_sorted_by_int_key() if all_keys_are_ints() else nested_structured_dict + + +def get_type(type): + if type == "movie": + return plexapi.video.Movie + if type == "show": + return plexapi.video.Show + if type == "episode": + return plexapi.video.Episode + return None + + +def get_size(the_lib, tgt_class=None, filter=None): + lib_size = 0 + foo = [] + + if filter is not None: + foo = the_lib.search(libtype=tgt_class, filters=filter) + else: + foo = the_lib.search(libtype=tgt_class) + + lib_size = len(foo) + + return lib_size + + +def get_all_from_library(the_lib, tgt_class=None, filter=None): + if tgt_class is None: + tgt_class = the_lib.type + + lib_size = get_size(the_lib, tgt_class, filter) + + c_start = 0 + c_size = 500 + results = [] + while lib_size is None or c_start <= lib_size: + if filter is not None: + results.extend( + the_lib.search( + libtype=tgt_class, + maxresults=c_size, + container_start=c_start, + container_size=lib_size, + filters=filter, + ) + ) + else: + results.extend( + the_lib.search( + libtype=tgt_class, + maxresults=c_size, + container_start=c_start, + container_size=lib_size, + ) + ) + + print(f"Loaded: {len(results)}/{lib_size}", end="\r") + c_start += c_size + if len(results) < c_start: + c_start = lib_size + 1 + return lib_size, results + + +def get_overlay_status(the_lib): + overlay_items = the_lib.search(label="Overlay") + + ret_val = len(overlay_items) > 0 + + return ret_val + + +def get_xml(plex_url, plex_token, lib_index): + ssn = requests.Session() + ssn.headers.update({"Accept": "application/json"}) + ssn.params.update({"X-Plex-Token": plex_token}) + media_output = ssn.get(f"{plex_url}/library/sections/{lib_index}/all").json() + return media_output + + +def get_xml_libraries(plex_url, plex_token): + media_output = None + try: + ssn = requests.Session() + ssn.headers.update({"Accept": "application/json"}) + ssn.params.update({"X-Plex-Token": plex_token}) + print("- making request") + raw_output = ssn.get(f"{plex_url}/library/sections/") + if raw_output.status_code == 200: + print("- success") + media_output = raw_output.json() + except Exception as ex: + print(f"- problem getting libraries: {ex}") + + return media_output + + +def get_xml_watched(plex_url, plex_token, lib_index, lib_type="movie"): + output_array = [] + + ssn = requests.Session() + ssn.headers.update({"Accept": "application/json"}) + ssn.params.update({"X-Plex-Token": plex_token}) + media_output = ssn.get( + f"{plex_url}/library/sections/{lib_index}/all?viewCount>=1" + ).json() + + if "Metadata" in media_output["MediaContainer"].keys(): + if lib_type == "movie": + # library is a movie lib; loop through every movie + movie_count = len(media_output["MediaContainer"]["Metadata"]) + movie_idx = 1 + for movie in media_output["MediaContainer"]["Metadata"]: + print(f"> {movie_idx:05}/{movie_count:05}", end="\r") + if "viewCount" in movie.keys(): + output_array.append(movie) + movie_idx += 1 + elif lib_type == "show": + # library is show lib; loop through every show + show_count = len(media_output["MediaContainer"]["Metadata"]) + show_idx = 1 + for show in media_output["MediaContainer"]["Metadata"]: + print(f"> {show_idx:05}/{show_count:05} ", end="\r") + if "viewedLeafCount" in show.keys() and show["viewedLeafCount"] > 0: + show_output = ssn.get( + f"{plex_url}/library/metadata/{show['ratingKey']}/allLeaves?viewCount>=1" + ).json() + # loop through episodes of show to check if targeted season exists + # loop through episodes of show + if "Metadata" in show_output["MediaContainer"].keys(): + episode_list = show_output["MediaContainer"]["Metadata"] + episode_count = len(episode_list) + episode_idx = 1 + for episode in episode_list: + print( + f"> {show_idx:05}/{show_count:05} {episode_idx:05}/{episode_count:05}", + end="\r", + ) + if "viewCount" in episode.keys(): + output_array.append(episode) + episode_idx += 1 + show_idx += 1 + + return output_array + + +def get_media_details(plex_url, plex_token, rating_key): + ssn = requests.Session() + ssn.headers.update({"Accept": "application/json"}) + ssn.params.update({"X-Plex-Token": plex_token}) + media_output = ssn.get(f"{plex_url}/library/metadata/{rating_key}").json() + + return media_output + + +def get_all_watched(plex, the_lib): + results = the_lib.search(unwatched=False) + return results + + +def char_range(c1, c2): + """Generates the characters from `c1` to `c2`, inclusive.""" + for c in range(ord(c1), ord(c2) + 1): + yield chr(c) + + +ALPHABET = [] +NUMBERS = [] + +for c in char_range("a", "z"): + ALPHABET.append(c) + +for c in char_range("0", "9"): + NUMBERS.append(c) + + +def remove_articles(thing): + if thing.startswith("The "): + thing = thing.replace("The ", "") + if thing.startswith("A "): + thing = thing.replace("A ", "") + if thing.startswith("An "): + thing = thing.replace("An ", "") + if thing.startswith("El "): + thing = thing.replace("El ", "") + + return thing + + +def get_letter_dir(thing): + ret_val = "Other" + + thing = remove_articles(thing) + + first_char = thing[0] + + if first_char.lower() in ALPHABET: + ret_val = first_char.upper() + else: + if first_char in NUMBERS: + ret_val = first_char + + return ret_val + + +def load_and_upgrade_env(file_path): + status = 0 + + if os.path.exists(file_path): + load_dotenv(dotenv_path=file_path, override=True) + else: + print("No environment [.env] file. Creating base file.") + if os.path.exists(".env.example"): + src_file = os.path.join(".", ".env.example") + tgt_file = os.path.join(".", ".env") + shutil.copyfile(src_file, tgt_file) + print("Please edit config.yaml to suit and rerun script.") + else: + print("No example [.env.example] file. Cannot create base file.") + status = -1 + + PLEX_URL = os.getenv("PLEX_URL") + PLEX_TOKEN = os.getenv("PLEX_TOKEN") + + if PLEX_URL is not None: + # Add the PLEXAPI env vars + set_key( + dotenv_path=file_path, + key_to_set="PLEXAPI_PLEXAPI_TIMEOUT", + value_to_set="360", + ) + + set_key( + dotenv_path=file_path, + key_to_set="PLEXAPI_AUTH_SERVER_BASEURL", + value_to_set=PLEX_URL, + ) + set_key( + dotenv_path=file_path, + key_to_set="PLEXAPI_AUTH_SERVER_TOKEN", + value_to_set=PLEX_TOKEN, + ) + unset_key( + dotenv_path=file_path, + key_to_unset="PLEX_URL", + quote_mode="always", + encoding="utf-8", + ) + unset_key( + dotenv_path=file_path, + key_to_unset="PLEX_TOKEN", + quote_mode="always", + encoding="utf-8", + ) + + set_key( + dotenv_path=file_path, + key_to_set="PLEXAPI_LOG_BACKUP_COUNT", + value_to_set="3", + ) + set_key( + dotenv_path=file_path, + key_to_set="PLEXAPI_LOG_FORMAT", + value_to_set="%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s", + ) + set_key( + dotenv_path=file_path, key_to_set="PLEXAPI_LOG_LEVEL", value_to_set="INFO" + ) + set_key( + dotenv_path=file_path, + key_to_set="PLEXAPI_LOG_PATH", + value_to_set="plexapi.log", + ) + set_key( + dotenv_path=file_path, + key_to_set="PLEXAPI_LOG_ROTATE_BYTES", + value_to_set="512000", + ) + set_key( + dotenv_path=file_path, + key_to_set="PLEXAPI_LOG_SHOW_SECRETS", + value_to_set="false", + ) + + # and load the new file + load_dotenv(dotenv_path=file_path) + status = 1 + + if ( + os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") is None + or os.getenv("PLEXAPI_AUTH_SERVER_BASEURL") == "https://plex.domain.tld" + ): + print("You must specify PLEXAPI_AUTH_SERVER_BASEURL in the config.yaml.") + # status = -1 + + if ( + os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") is None + or os.getenv("PLEXAPI_AUTH_SERVER_TOKEN") == "PLEX-TOKEN" + ): + print("You must specify PLEXAPI_AUTH_SERVER_TOKEN in the config.yaml.") + # status = -1 + + return status + + +def check_for_images(file_path): + jpg_path = file_path.replace(".dat", ".jpg") + png_path = file_path.replace(".dat", ".png") + + dat_file = Path(file_path) + jpg_file = Path(jpg_path) + png_file = Path(png_path) + + dat_here = dat_file.is_file() + jpg_here = jpg_file.is_file() + png_here = png_file.is_file() + + if dat_here: + os.remove(file_path) + + if jpg_here and png_here: + os.remove(jpg_path) + + os.remove(png_path) + + if jpg_here or png_here: + return True + + return False + +def get_redaction_list(): + config = Config() + redaction_list = [] + redaction_list.append(config.get("plex_api.auth_server.base_url")) + redaction_list.append(config.get("plex_api.auth_server.token")) + + return redaction_list diff --git a/TMDB/tmdb_logos.py b/TMDB/tmdb_logos.py new file mode 100644 index 0000000..44c7a22 --- /dev/null +++ b/TMDB/tmdb_logos.py @@ -0,0 +1,144 @@ +from pathlib import Path +import requests +import os +from alive_progress import alive_bar +from config import Config +from plexapi.server import PlexServer +from helpers import get_plex, get_target_libraries, get_all_from_library + +def download_tmdb_logo(tmdb_obj): + """ + Downloads the logo for a movie or TV show from TMDB. + + Args: + tmdb_id (int): The TMDB ID of the movie or TV show. + api_key (str): Your TMDB API key. + save_path (str): The directory where the logo will be saved. + show_type (str): The type of content, either "movie" or "tv". + """ + # TMDB API endpoint for images + base_url = "https://api.themoviedb.org/3" + image_base_url = "https://image.tmdb.org/t/p/original" + + api_key = config.get("general.tmdb_key", "NO_KEY_SPECIFIED") + + # Construct the API request URL + api_url = f"{base_url}/{tmdb_obj['type']}/{tmdb_obj['tmdb_id']}/images?api_key={api_key}" + + save_path = config.get("image_download.where_to_put_it.logo_dir", "tmdb_logos") + + try: + # Get the list of images from the TMDB API + response = requests.get(api_url) + response.raise_for_status() # Raise an exception for bad status codes + data = response.json() + + # Find the first logo file path (logos are typically under "logos") + logo_path = None + if "logos" in data and data["logos"]: + logo_path = data["logos"][0]["file_path"] + + # If a logo path is found, download the image + if logo_path: + full_image_url = f"{image_base_url}{logo_path}" + + # Get the file name from the URL + file_name = os.path.basename(logo_path) + + # Download the image content + image_response = requests.get(full_image_url) + image_response.raise_for_status() + + # Create the full file path to save the image + folder_path = os.path.join(save_path, tmdb_obj['library'], tmdb_obj['title']) + Path(folder_path).mkdir(parents=True, exist_ok=True) + + # Create the full file path to save the image + file_path = os.path.join(folder_path, file_name) + + # Save the image in binary write mode + with open(file_path, "wb") as f: + f.write(image_response.content) + + print(f"Successfully downloaded logo for TMDB ID {tmdb_obj['tmdb_id']} to {file_path}") + else: + print(f"No logo found for TMDB ID {tmdb_obj['tmdb_id']}") + + except requests.exceptions.RequestException as e: + print(f"An error occurred: {e}") + except Exception as e: + print(f"An unexpected error occurred: {e}") + +def get_tmdb_ids_from_plex(): + """ + Connects to a Plex server and retrieves TMDB IDs from a specific library. + + Args: + baseurl (str): The URL of your Plex server. + token (str): Your Plex authentication token. + library_name (str): The name of the library (e.g., 'Movies', 'TV Shows'). + + Returns: + A list of dictionaries, where each dictionary contains the title and TMDB ID. + """ + try: + plex = get_plex() + LIB_ARRAY = get_target_libraries(plex) + + tmdb_ids = [] + + for lib in LIB_ARRAY: + # Iterate through all items in the specified library + the_lib = plex.library.section(lib) + item_count, items = get_all_from_library( + the_lib, None, None + ) + + if item_count > 0: + print(f"looping over {item_count} items...", "info", "a") + item_count = 0 + + with alive_bar( + item_count, + dual_line=True, + title=f"Grab all posters {the_lib.title}", + ) as bar: + + for item in items: + tmdb_id = None + + # Plex stores external IDs in a list of GUIDs + for guid in item.guids: + if guid.id.startswith('tmdb://'): + tmdb_id = guid.id.split('://')[1] + break + + # Only add items with a found TMDB ID to the list + if tmdb_id: + if item.TYPE == "show": + type = 'tv' + else: + type = 'movie' + + tmdb_ids.append({ + "library": the_lib.title, + "title": item.title, + "tmdb_id": int(tmdb_id), + "type": type + }) + + return tmdb_ids + + except Exception as e: + print(f"An error occurred: {e}") + return None + +config = Config('../config.yaml') + +tmdb_data = get_tmdb_ids_from_plex() + +if tmdb_data: + print(f"Found {len(tmdb_data)} items with TMDB IDs.") + for item in tmdb_data: + print(f"Title: {item['title']}, TMDB ID: {item['tmdb_id']}, Type: {item['type']}") + download_tmdb_logo(item) From fbc16ab435eb52e10e14c81dd16f4e424b59db9e Mon Sep 17 00:00:00 2001 From: Chaz Larson Date: Wed, 24 Sep 2025 17:35:01 -0500 Subject: [PATCH 7/7] Logo tweaks --- Plex/grab-all-posters.py | 15 +++++++++++++-- requirements.txt | 3 +-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Plex/grab-all-posters.py b/Plex/grab-all-posters.py index b3cb70d..c3bb628 100644 --- a/Plex/grab-all-posters.py +++ b/Plex/grab-all-posters.py @@ -20,6 +20,7 @@ from logs import blogger, logger, plogger, setup_logger from pathvalidate import sanitize_filename from plexapi.utils import download +import plexapi # TODO: lib_stats[lib_key] = item_count in sqlite # TODO: Track Collection status in sqlite with guid @@ -78,6 +79,7 @@ SCRIPT_NAME = Path(__file__).stem VERSION = "0.9.0" +MIN_PLEXAPI_VERSION = "4.16.1" config = Config('../config.yaml') @@ -104,6 +106,14 @@ def superchat(msg, level, logfile): plogger(f"Starting {SCRIPT_NAME} {VERSION} at {RUNTIME_STR}", "info", "a") +if plexapi.__version__ < MIN_PLEXAPI_VERSION: + plogger(f"This script requires PlexAPI {MIN_PLEXAPI_VERSION} or later. You have {plexapi.__version__}.", "error", "a") + plogger(f"Please update the requirements. Exiting...", "error", "a") + exit() +else: + plogger(f"Running under PlexAPI {plexapi.__version__}.", "info", "a") + + ID_FILES = True URL_ARRAY = [] @@ -1271,8 +1281,9 @@ def get_posters(lib, item, uuid, title): if config.get_bool('image_download.what_to_grab.backgrounds', True): get_art(item, artwork_path, tmid, tvid, uuid, lib_title) - if config.get_bool('image_download.what_to_grab.logos', True): - get_logo(item, artwork_path, tmid, tvid, uuid, lib_title) + if not item.TYPE == "collection": + if config.get_bool('image_download.what_to_grab.logos', True): + get_logo(item, artwork_path, tmid, tvid, uuid, lib_title) else: plogger( diff --git a/requirements.txt b/requirements.txt index 54d58fb..a970192 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ alive-progress==2.4.1 imdbpy pathvalidate -PlexAPI +PlexAPI>=4.16.1 pre-commit pyopenssl python-dotenv @@ -18,7 +18,6 @@ sqlalchemy<2.0 tabulate validators pillow - Flask GitPython num2words