├── requirements.txt ├── Overlays ├── FINAL.png ├── SEASON.png └── MIDSEASON.png ├── config.example.yml ├── Modules ├── path_handler.py ├── Trakt.py └── Sonarr.py ├── FLFP.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.28.0 2 | PyYAML==6.0 3 | plexapi==4.16.1 4 | tqdm==4.64.0 -------------------------------------------------------------------------------- /Overlays/FINAL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netplexflix/Finale-Labeler-For-Plex/HEAD/Overlays/FINAL.png -------------------------------------------------------------------------------- /Overlays/SEASON.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netplexflix/Finale-Labeler-For-Plex/HEAD/Overlays/SEASON.png -------------------------------------------------------------------------------- /Overlays/MIDSEASON.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netplexflix/Finale-Labeler-For-Plex/HEAD/Overlays/MIDSEASON.png -------------------------------------------------------------------------------- /config.example.yml: -------------------------------------------------------------------------------- 1 | sonarr: 2 | url: 'http://localhost:8989' 3 | api_key: 'YOUR_SONARR_API_KEY' 4 | 5 | trakt: 6 | client_id: "YOUR_TRAKT_API_CLIENT_ID" 7 | client_secret: "YOUR_TRAKT_API_CLIENT_SECRET" 8 | desired_episode_types: #these episode types will be used as the labels to be applied in Plex 9 | - "mid_season_finale" 10 | - "season_finale" 11 | - "series_finale" 12 | 13 | plex: 14 | url: 'http://localhost:32400' 15 | token: 'YOUR_PLEX_TOKEN' 16 | library_title: 'TV Shows' 17 | 18 | general: 19 | launch_method: 0 #0=menu, 1=Sonarr, 2=Trakt, 3=Both consecutively 20 | recent_days: 14 21 | skip_unmonitored: true 22 | skip_genres: true 23 | genres_to_skip: 24 | - "Talk Show" 25 | - "News" 26 | - "Stand-Up" 27 | - "Awards Show" 28 | skip_labels: true 29 | labels_to_skip: 30 | - "Skip" 31 | - "Exclude" 32 | label_series_in_plex: true 33 | plex_label: "Finale" 34 | remove_labels_if_no_longer_matched: true 35 | only_finale_unwatched: false 36 | 37 | paths: 38 | path_mappings: 39 | # Examples: 40 | # windows_to_nas: 41 | # "D:/Media/": "/volume1/Media/" 42 | # docker_to_nas: 43 | # "/tv": "/volume1/Media/TV Shows" 44 | # Set your platform: 'windows', 'linux', 'nas', or 'docker' 45 | platform: "windows" -------------------------------------------------------------------------------- /Modules/path_handler.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import os 3 | import yaml 4 | import platform 5 | from typing import Dict, Optional 6 | 7 | class PathHandler: 8 | def __init__(self, config: dict): 9 | self.path_mappings = config.get('paths', {}).get('path_mappings', {}) 10 | self.platform = config.get('paths', {}).get('platform', self.detect_platform()) 11 | 12 | @staticmethod 13 | def detect_platform() -> str: 14 | """Automatically detect the platform.""" 15 | system = platform.system().lower() 16 | if system == 'windows': 17 | return 'windows' 18 | elif system == 'linux': 19 | # Check if running in Docker 20 | if os.path.exists('/.dockerenv'): 21 | return 'docker' 22 | return 'linux' 23 | return 'unknown' 24 | 25 | def normalize_path(self, path: str) -> str: 26 | """Normalize path for current platform.""" 27 | # Convert to Path object for cross-platform compatibility 28 | path_obj = Path(path) 29 | 30 | # Convert to string representation appropriate for the platform 31 | if self.platform == 'windows': 32 | return str(path_obj.as_posix()) 33 | return str(path_obj) 34 | 35 | def map_path(self, path: str, reverse: bool = False) -> str: 36 | """ 37 | Map paths between different systems (e.g., local to NAS or vice versa) 38 | 39 | Args: 40 | path: The path to map 41 | reverse: If True, map from NAS to local path instead of local to NAS 42 | """ 43 | if not path: 44 | return path 45 | 46 | normalized_path = self.normalize_path(path) 47 | 48 | # No mappings defined, return normalized path 49 | if not self.path_mappings: 50 | return normalized_path 51 | 52 | # Get the correct mapping direction 53 | mappings = self.path_mappings.items() 54 | if reverse: 55 | mappings = {v: k for k, v in self.path_mappings.items()}.items() 56 | 57 | # Try each mapping 58 | for source, target in mappings: 59 | source = self.normalize_path(source) 60 | target = self.normalize_path(target) 61 | 62 | if normalized_path.startswith(source): 63 | return normalized_path.replace(source, target, 1) 64 | 65 | return normalized_path 66 | 67 | def get_absolute_path(self, path: str) -> str: 68 | """Convert relative path to absolute path.""" 69 | return str(Path(path).resolve()) -------------------------------------------------------------------------------- /FLFP.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | import os 4 | import requests 5 | import yaml 6 | from datetime import datetime, timedelta 7 | from pathlib import Path 8 | 9 | VERSION = '2.4' 10 | 11 | # Get the directory of the script being executed 12 | script_dir = Path(__file__).parent 13 | requirements_path = script_dir / "requirements.txt" 14 | config_path = script_dir / "config.yml" 15 | 16 | # ANSI color codes 17 | GREEN = '\033[32m' 18 | ORANGE = '\033[33m' 19 | BLUE = '\033[34m' 20 | RED = '\033[31m' 21 | RESET = '\033[0m' 22 | BOLD = '\033[1m' 23 | 24 | def load_config(): 25 | try: 26 | with config_path.open("r", encoding="utf-8") as file: 27 | return yaml.safe_load(file) 28 | except FileNotFoundError: 29 | sys.exit(f"{RED}ERROR: Could not find config.yml at {config_path}{RESET}") 30 | except Exception as e: 31 | sys.exit(f"{RED}ERROR: An error occurred while loading config.yml: {e}{RESET}") 32 | 33 | # Load configuration 34 | config = load_config() 35 | # Retrieve launch_method from config.yml 36 | general_config = config.get("general", {}) 37 | launch_method = general_config.get("launch_method", 0) 38 | 39 | def check_requirements(): 40 | print("\nChecking requirements:") 41 | try: 42 | with open(requirements_path, "r") as req_file: 43 | requirements = req_file.readlines() 44 | 45 | unmet_requirements = [] 46 | for req in requirements: 47 | req = req.strip() 48 | if not req: # Skip empty lines 49 | continue 50 | try: 51 | pkg_name, required_version = req.split("==") 52 | installed_version = subprocess.check_output( 53 | [sys.executable, "-m", "pip", "show", pkg_name] 54 | ).decode().split("Version: ")[1].split("\n")[0] 55 | 56 | if installed_version == required_version: 57 | print(f"{pkg_name}: {GREEN}OK{RESET}") 58 | elif installed_version < required_version: 59 | print(f"{pkg_name}: {ORANGE}Upgrade needed{RESET}") 60 | unmet_requirements.append(req) 61 | else: 62 | print(f"{pkg_name}: {GREEN}OK{RESET}") 63 | except (IndexError, subprocess.CalledProcessError): 64 | print(f"{pkg_name}: {RED}Missing{RESET}") 65 | unmet_requirements.append(req) 66 | 67 | if unmet_requirements: 68 | answer = input("Install requirements? (y/n): ").strip().lower() 69 | if answer == "y": 70 | subprocess.run([sys.executable, "-m", "pip", "install", "-r", str(requirements_path)]) 71 | else: 72 | sys.exit(f"{RED}Script ended due to unmet requirements.{RESET}") 73 | 74 | except Exception as e: 75 | sys.exit(f"{RED}Error checking requirements: {e}{RESET}") 76 | 77 | def is_newer_version(remote_version, current_version): 78 | def parse_version(v): 79 | return [int(x) for x in v.strip('v').split('.')] 80 | 81 | try: 82 | return parse_version(remote_version) > parse_version(current_version) 83 | except Exception: 84 | return False 85 | 86 | def check_for_updates(current_version): 87 | GITHUB_API_URL = "https://api.github.com/repos/netplexflix/Finale-Labeler-For-Plex/releases/latest" 88 | try: 89 | response = requests.get(GITHUB_API_URL) 90 | response.raise_for_status() 91 | data = response.json() 92 | remote_version = data.get("tag_name", "").lstrip('v') 93 | if not remote_version: 94 | print(f"{RED}Could not determine the latest version from GitHub Releases.{RESET}") 95 | return 96 | if is_newer_version(remote_version, current_version): 97 | print(f"{ORANGE}A newer version (v{remote_version}) is available.{RESET}") 98 | except Exception as e: 99 | print(f"{RED}ERROR: Failed to check for updates: {e}{RESET}") 100 | 101 | def run_script(script_name): 102 | try: 103 | script_path = script_dir / "Modules" / script_name 104 | subprocess.run([sys.executable, str(script_path)], check=True) 105 | except subprocess.CalledProcessError as error: 106 | print(f"{RED}An error occurred while running {script_name}: {error}{RESET}") 107 | 108 | def display_title_and_methods(): 109 | title = f"{BOLD}{BLUE}{'*' * 40}\nPlex Finale Labeler {VERSION}\n{'*' * 40}{RESET}" 110 | print(title) 111 | check_for_updates(VERSION) 112 | explanation = f""" 113 | Make sure you have correctly configured config.yml 114 | 115 | {BOLD}{BLUE}Method 1: Sonarr{RESET} 116 | Connects to Sonarr to identify the latest episode of the latest season and checks if it's downloaded. 117 | 118 | {BOLD}{BLUE}Method 2: Trakt{RESET} 119 | Uses your Trakt API to get the episode_types. 120 | """ 121 | print(explanation) 122 | 123 | def validate_path_config(config): 124 | paths_config = config.get('paths', {}) 125 | platform = paths_config.get('platform') 126 | 127 | if platform and platform not in ['windows', 'linux', 'nas', 'docker']: 128 | print(f"{RED}ERROR: Invalid platform in config. Must be one of: windows, linux, nas, docker{RESET}") 129 | sys.exit(1) 130 | 131 | mappings = paths_config.get('path_mappings', {}) 132 | if mappings: 133 | for source, target in mappings.items(): 134 | if not source or not target: 135 | print(f"{RED}ERROR: Invalid path mapping: {source} -> {target}{RESET}") 136 | sys.exit(1) 137 | 138 | def main(): 139 | if launch_method == 0: 140 | check_requirements() 141 | 142 | start_time = datetime.now() 143 | consecutive_run = False 144 | 145 | validate_path_config(config) 146 | 147 | if launch_method in [1, 2, 3]: 148 | if launch_method in [1, 3]: 149 | print(f"{BOLD}{BLUE}Running Method 1: Sonarr{RESET}") 150 | run_script("Sonarr.py") 151 | if launch_method in [2, 3]: 152 | print(f"{BOLD}{BLUE}Running Method 2: Trakt{RESET}") 153 | run_script("Trakt.py") 154 | consecutive_run = launch_method == 3 155 | else: 156 | display_title_and_methods() 157 | print(f"{BOLD}{GREEN}Select a method:{RESET}") 158 | print("1: Method 1 (Sonarr)") 159 | print("2: Method 2 (Trakt)") 160 | print("3: Both (Runs both methods consecutively)") 161 | 162 | choice = input("Enter your choice (1, 2, or 3): ").strip() 163 | print("===================\n") 164 | 165 | if choice == "1": 166 | print(f"{BOLD}{BLUE}Running Method 1: Sonarr{RESET}") 167 | run_script("Sonarr.py") 168 | elif choice == "2": 169 | print(f"{BOLD}{BLUE}Running Method 2: Trakt{RESET}") 170 | run_script("Trakt.py") 171 | elif choice == "3": 172 | consecutive_run = True 173 | print(f"{BOLD}{BLUE}Running Method 1: Sonarr{RESET}") 174 | run_script("Sonarr.py") 175 | print(f"{BOLD}{BLUE}Running Method 2: Trakt{RESET}") 176 | run_script("Trakt.py") 177 | else: 178 | print(f"{RED}Invalid selection. Please run the script again and choose 1, 2, or 3.{RESET}") 179 | return 180 | 181 | if consecutive_run: 182 | end_time = datetime.now() 183 | total_runtime = str(timedelta(seconds=int((end_time - start_time).total_seconds()))) 184 | print(f"\nTotal Runtime: {total_runtime}") 185 | 186 | if __name__ == "__main__": 187 | main() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📺 Finale Labeler for Plex ✅ 2 | >[!NOTE] 3 | > The functionality of this script has been consolidated in [TV Show Status for Kometa](https://github.com/netplexflix/TV-show-status-for-Kometa) 4 | 5 | This script checks your Plex TV library and lists your TV shows for which a (season) finale is present which aired within the set timeframe,
6 | and optionally labels/unlabels these shows in Plex based on chosen criteria.
7 | 8 | The added labels can then be used to create "Ready to Binge" collections
9 | and/or apply overlays (E.g.: "Season Finale", "Final Episode",..) using [**Kometa**](https://kometa.wiki/). 10 | 11 | Overlays can serve as an easy visual indicator that the shows's Season Finale or Final Episode has been added to your Plex. 12 | 13 | ![github example](https://github.com/user-attachments/assets/ba858c1f-3408-4103-9f46-73dcb6811ace) 14 | 15 | --- 16 | 17 | ## ✨ What It Does 18 | 19 | 1. ☑ **One, or both, of two methods are used:**
20 | - **METHOD 1: Sonarr**:
21 | Uses [**Sonarr**](https://sonarr.tv/) to identify Shows for which the last episode of a season was downloaded. 22 | 23 | >**+** Will identify every final episode
24 | >**+** Faster
25 | >**-** Could incorrectly identify episode as a finale (if not all episodes of season are listed in Sonarr)
26 | >**-** Does not identify mid season finales 27 | 28 | - **METHOD 2: Trakt**:
29 | Uses your [**Trakt**](https://trakt.tv/) API to check each TV show's most recent episode for (mid)season or series finale status.
30 | >**+** Identifies mid season finales
31 | >**+** Clear differentiation between Mid-Season Finales, Season Finales and Series Finales
32 | >**-** 'finale' flags are currently missing for many shows, especially less popular and foreign ones
33 | >**-** Could incorrectly identify finale episode if info on Trakt is wrong
34 | >**-** Slower 35 | 36 | 2. ▼ **Optionally Filters/Skips shows based on the following criteria** 37 | - If `Skip_Unmonitored` is `True`, the script ignores shows that are unmonitored in Sonarr. (When using Method 1) 38 | - If `Skip_Genres` is `True`, it checks Plex for genres (`Genres_to_Skip`) to exclude certain shows (e.g. “Talk Show”,“Stand-Up”,"Award Show" etc.). 39 | - If `Skip_Labels` is `True`, it checks Plex for labels (`Labels_to_Skip`) to exclude certain shows (e.g. "Skip","Exclude" etc) 40 | - If `Only_Finale_Unwatched` is `True`, only include shows for which the identified finale episode is the only remaining unwatched episode that season. 41 | 42 | 3. 📋 **Lists the Qualifying TV Shows** 43 | - The script will show you a list of TV Shows on your Plex that qualify the set criteria. 44 | - When using Method 1 (Sonarr) it will also 45 | - Mark Unmonitored shows (in case `Skip_Unmonitored` was set to false). 46 | - Show a seperate list of aired finales (matching the criteria) which you haven't downloaded yet. 47 | 48 | 4. ✏️ **Adds/Removes labels in Plex on TV Show level** (Optional) 49 | - **Adds** labels to your matched shows if `Label_series_in_plex` is `True` and all criteria are met.
50 | - Method 1 (Sonarr) applies the label chosen under `plex_label`
51 | - Method 2 (Trakt) applies the `episode_status` as label to differentiate between the possible statuses (mid_season_finale, season_finale and series_finale by default) 52 | - **Removes** labels if `remove_labels_if_no_longer_matched` is `True` and the criteria are no longer met. 53 | > [!TIP] 54 | > **Special Case**: If `label_series_in_plex = False` and `remove_labels_if_no_longer_matched = True`, the script removes the labels from **all** shows in Plex (essentially a cleanup scenario). 55 | 56 | --- 57 | 58 | ## 📝 Requirements 59 | 60 | - **Plex** with a valid Plex token. 61 | - **[Sonarr](https://sonarr.tv/)** (Required for Method 1) 62 | - **[Trakt API credentials](https://trakt.docs.apiary.io/#introduction/create-an-app)** (Required for Method 2) 63 | - **Python 3.7+** 64 | - **dependencies** Can be installed using the requirements.txt (See "Installation & Usage" below) 65 | 66 | --- 67 | 68 | ## ⚙️ Configuration 69 | 70 | Rename `config.example.yml` to `config.yml` and open it in any text editor (e.g., Notepad++).
71 | You need to fill in or adjust the variables: 72 | 73 | ### Sonarr: (Needed for Method 1) 74 | - `url` Default: `http://localhost:8989`. Edit if needed 75 | - `api_key` Can be found in Sonarr under settings => General 76 | ### Trakt: (Needed for Method 2) 77 | - `client_id` Found under [Your API Apps](https://trakt.tv/oauth/applications). See [HERE](https://trakt.docs.apiary.io/#introduction/create-an-app) for more info on how to get Trakt API credentials. 78 | - `client_secret` 79 | - `desired_episode_types` These episode statuses will be used to identify and label. If you don't wish to have mid season finales you can remove that line 80 | ### Plex: 81 | - `url` Default: `http://localhost:32400`. Edit if needed. 82 | - `token` [Finding your Plex token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/) 83 | - `library_title` Default: `TV Shows`. Edit if your TV show library is named differently. 84 | 85 | ### General: 86 | - **launch_method:** `0`=launches a menu, `1`=runs Sonarr method, `2`= runs Trakt method, `3`= runs both consecutively 87 | - **recent_days:** (e.g., `14`). Timeframe in days within which the finale needs to have aired (Downloaded finales with future air dates will also be included). 88 | - **skip_unmonitored:** (`true`/`false`). Ignore shows that are unmonitored in Sonarr. (Only used by Method 1) 89 | - **skip_genres:** (`true`/`false`). Ignore shows with genres specified with `genres_to_skip`. 90 | - **genres_to_skip:** (which genres to skip) 91 | - **skip_labels:** (`true`/`false`). Ignore shows with labels specified with `labels_to_skip`. 92 | - **labels_to_skip:** (which labels to skip) 93 | 94 | - **label_series_in_plex:** (`true`/`false`). Whether or not to add labels to your TV Shows in Plex. If set to `false` the script will simply list the qualifying shows. 95 | - **plex_label:** default `"Finale"`. Which label to apply when using Method 1 (Sonarr). When using Method 2 (Trakt), the types specified under `desired_episode_types` will be used as labels 96 | - **remove_labels_if_no_longer_matched:** (`true`/`false`) Removes the label set under `plex_label` if using Method 1, or labels set under `desired_episode_types` if using Method 2 for any show that no longer qualifies for it. 97 | - **only_finale_unwatched:** (`true`/`false`) Label only shows for which the finale episode itself is the only unwatched episode in the season. 98 | 99 | - **path_mappings:** Map your paths if needed 100 | - **platform:** The platform from which you are launching the script 101 | > Example: Your Plex is looking for media on your NAS on path "/volume1/media/", and you have this path mapped in windows as "P:/", then you write `"P:/": "/volume1/media"` and under `platform:` you write `"windows"` 102 | 103 | --- 104 | 105 | ## 🚀 Installation & Usage 106 | 107 | > [!IMPORTANT] 108 | > Make sure you first correctly edit the **Configuration** variables (Sonarr, Trakt, Plex, General) as described above. 109 | 110 | 1. **Install Python 3.7 or Higher** 111 | - Go to python.org and install the latest version of Python 112 | - Make sure you can run `python --version` in a terminal or command prompt (Windows users can search “Command Prompt,” Mac/Linux users can open “Terminal”). If correctly installed it should return a version number e.g. "Python 3.11.2" 113 | 114 | 2. **Install Dependencies** 115 | In your terminal, make sure you are in your script path and type: 116 | ```bash 117 | python -m pip install -r requirements.txt 118 | ``` 119 | 120 | 3. **Launch the Script** 121 | In your terminal, make sure you are in your script path and type: 122 | ```bash 123 | python FLFP.py 124 | ``` 125 | > [!TIP] 126 | > Windows users can create a batch file to quickly launch the script: 127 | > Open a text editor, paste the following code and Save as a .bat file 128 | > (Edit the paths to the python.exe and your FLFP.py according to where they are on your computer.) 129 | > ```bash 130 | > "C:\Users\User1\AppData\Local\Programs\Python\Python311\python.exe" "C:\Scripts\FLFP\FLFP.py" -r 131 | > pause 132 | > ``` 133 | 134 | > [!IMPORTANT] 135 | > Set launch_method to `1`,`2` or `3` depending on your desired method if you are scheduling the script, as `launch_method` `0` will prompt for a menu selection 136 | 137 | --- 138 | 139 | ## 📜 Notes 140 | 141 | - **Which Method should I use?** 142 | 143 | This comes down to personal preference. I prefer Method 1 as it correctly applies the labels to all season Finales and I don't really care about midseason finales. 144 | I know that this method could theoretically incorrectly label in case not all episodes of a season are listed yet in Sonarr, but 145 | A) I could not find any such instances in my library and B) If I come across one I'll either go edit TVDB myself or exclude said TV Show with a label. 146 | 147 | The 'finale' labels found via Trakt are applied manually by people and are missing for a considerable amount of shows, especially foreign and lesser popular ones. 148 | (TRAKT, TVDB and TMDB seem to aggregated these flags from the same source (TVDB?)) 149 | Using Method 2 will make it very unlikely an episode is incorrectly identified as a finale, but will result in more Shows being looked over. 150 | 151 | --- 152 | 153 | ## ☄️ Kometa Overlay Configs 154 | 155 | You can use the following logic examples to add overlays with Kometa: 156 | 157 | ### METHOD 1: 158 | For Season Finale: 159 | ``` 160 | SEASON: 161 | name: SEASON 162 | plex_search: 163 | all: 164 | label: Finale 165 | ``` 166 | 167 | For Final Episode: 168 | ``` 169 | FINAL: 170 | name: FINAL 171 | plex_search: 172 | all: 173 | label: Finale 174 | filters: 175 | tvdb_status: 176 | - Ended 177 | - Cancelled 178 | suppress_overlays: 179 | - SEASON 180 | ``` 181 | 182 | ### METHOD 2: 183 | For Season Finale: 184 | ``` 185 | SEASON: 186 | name: SEASON 187 | plex_search: 188 | all: 189 | label: season_finale 190 | ``` 191 | 192 | For Mid-Season Finale: 193 | ``` 194 | MIDSEASON: 195 | name: MIDSEASON 196 | plex_search: 197 | all: 198 | label: mid_season_finale 199 | ``` 200 | 201 | For Final Episode: 202 | ``` 203 | FINAL: 204 | name: FINAL 205 | plex_search: 206 | all: 207 | label: series_finale 208 | ``` 209 | 210 | ### Using both methods for best results: 211 | ``` 212 | SEASON: 213 | overlay: 214 | name: SEASON 215 | file: config/overlays/SEASON.png 216 | plex_search: 217 | all: 218 | label: Finale 219 | 220 | FINAL: 221 | suppress_overlays: SEASON 222 | overlay: 223 | name: FINAL 224 | file: config/overlays/FINAL.png 225 | plex_search: 226 | all: 227 | label: Finale 228 | filters: 229 | tmdb_status: 230 | - ended 231 | - canceled 232 | 233 | MIDSEASON: 234 | suppress_overlays: SEASON 235 | overlay: 236 | name: MIDSEASON 237 | file: config/overlays/MIDSEASON.png 238 | plex_search: 239 | all: 240 | label: mid_season_finale 241 | ``` 242 | Overlays Example: 243 | 244 | ![ex overlays](https://github.com/user-attachments/assets/1f86d4fa-d9e7-4f12-b452-1af4652c417b) 245 | 246 | --- 247 | 248 | ### ⚠️ **Do you Need Help or have Feedback?** 249 | - Join the [Discord](https://discord.gg/VBNUJd7tx3). 250 | - Open an [Issue](https://github.com/netplexflix/Finale-Labeler-For-Plex/issues) on GitHub. 251 | 252 | --- 253 | 254 | [!["Buy Me A Coffee"](https://github.com/user-attachments/assets/5c30b977-2d31-4266-830e-b8c993996ce7)](https://www.buymeacoffee.com/neekokeen) 255 | -------------------------------------------------------------------------------- /Modules/Trakt.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import os 3 | import sys 4 | import yaml 5 | from plexapi.server import PlexServer 6 | from tqdm import tqdm # For displaying progress bars 7 | from datetime import datetime, timedelta 8 | import time 9 | 10 | # ANSI color codes 11 | GREEN = '\033[32m' 12 | ORANGE = '\033[33m' 13 | BLUE = '\033[34m' 14 | RED = '\033[31m' 15 | RESET = '\033[0m' 16 | BOLD = '\033[1m' 17 | 18 | # Set up logging 19 | script_name = os.path.splitext(os.path.basename(__file__))[0] # Get script name without extension 20 | logs_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "Logs", script_name) 21 | os.makedirs(logs_dir, exist_ok=True) 22 | log_file = os.path.join(logs_dir, f"log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt") 23 | 24 | class Logger: 25 | def __init__(self, log_file): 26 | self.terminal = sys.stdout 27 | self.log = open(log_file, "a", encoding="utf-8") # Specify UTF-8 encoding 28 | 29 | def write(self, message): 30 | self.terminal.write(message) 31 | self.log.write(message) 32 | 33 | def flush(self): 34 | self.terminal.flush() 35 | self.log.flush() 36 | 37 | sys.stdout = Logger(log_file) 38 | sys.stderr = Logger(log_file) 39 | 40 | # Clean up old logs 41 | def clean_old_logs(): 42 | log_files = sorted( 43 | [os.path.join(logs_dir, f) for f in os.listdir(logs_dir) if f.startswith("log_")], 44 | key=os.path.getmtime 45 | ) 46 | while len(log_files) > 31: 47 | os.remove(log_files.pop(0)) 48 | 49 | clean_old_logs() 50 | 51 | # ============================ 52 | # Load Configuration from config.yml 53 | # ============================ 54 | def load_config(): 55 | # Determine the directory where this script resides 56 | current_dir = os.path.dirname(os.path.abspath(__file__)) 57 | # Construct the path to config.yml in the parent folder 58 | config_path = os.path.join(current_dir, "..", "config.yml") 59 | try: 60 | with open(config_path, "r") as file: 61 | return yaml.safe_load(file) 62 | except FileNotFoundError: 63 | print(f"{RED}ERROR: Could not find config.yml at {config_path}.{RESET}") 64 | sys.exit(1) 65 | except Exception as e: 66 | print(f"{RED}ERROR: An error occurred while loading config.yml: {e}{RESET}") 67 | sys.exit(1) 68 | 69 | config = load_config() 70 | 71 | TRAKT_CLIENT_ID = config['trakt']['client_id'] 72 | TRAKT_CLIENT_SECRET = config['trakt']['client_secret'] 73 | DESIRED_EPISODE_TYPES = config['trakt']['desired_episode_types'] 74 | PLEX_URL = config['plex']['url'] 75 | PLEX_TOKEN = config['plex']['token'] 76 | PLEX_LIBRARY_TITLE = config['plex']['library_title'] 77 | 78 | RECENT_DAYS = config['general']['recent_days'] 79 | LABEL_SERIES_IN_PLEX = config['general']['label_series_in_plex'] 80 | REMOVE_LABELS_IF_NO_LONGER_MATCHED = config['general']['remove_labels_if_no_longer_matched'] 81 | SKIP_GENRES = config['general']['skip_genres'] 82 | GENRES_TO_SKIP = config['general']['genres_to_skip'] 83 | SKIP_LABELS = config['general']['skip_labels'] 84 | LABELS_TO_SKIP = config['general']['labels_to_skip'] 85 | ONLY_FINALE_UNWATCHED = config['general']['only_finale_unwatched'] 86 | 87 | 88 | # ============================ 89 | # End of Configuration 90 | # ============================ 91 | 92 | def normalize_plex_label(label): 93 | return label.capitalize() 94 | 95 | def connect_plex(plex_url, plex_token, library_title): 96 | """ 97 | Connects to the Plex server and retrieves the specified library section. 98 | """ 99 | try: 100 | plex = PlexServer(plex_url, plex_token) 101 | library = plex.library.section(library_title) 102 | return library, plex 103 | except Exception as e: 104 | print(f"{RED}Failed to connect to Plex: {e}{RESET}") 105 | exit(1) 106 | 107 | def get_all_tv_shows(library): 108 | """ 109 | Retrieves all TV shows from the specified Plex library section. 110 | """ 111 | try: 112 | shows = library.all() 113 | return shows 114 | except Exception as e: 115 | print(f"{RED}Failed to retrieve TV shows from Plex: {e}{RESET}") 116 | return [] 117 | 118 | def get_last_episode(show): 119 | """ 120 | Determines the last episode of a TV show based on the highest season and episode numbers. 121 | """ 122 | try: 123 | # Reload the show to ensure the latest data is fetched 124 | show.reload() 125 | 126 | # Get all seasons and sort them by season number descending 127 | seasons = sorted(show.seasons(), key=lambda s: s.index, reverse=True) 128 | if not seasons: 129 | return None 130 | 131 | last_season = seasons[0] 132 | # Get all episodes in the last season and sort them by episode number descending 133 | episodes = sorted(last_season.episodes(), key=lambda e: e.index, reverse=True) 134 | if not episodes: 135 | return None 136 | 137 | last_episode = episodes[0] 138 | season_number = last_season.index 139 | episode_number = last_episode.index 140 | episode_title = last_episode.title 141 | 142 | return (season_number, episode_number, episode_title) 143 | except Exception as e: 144 | print(f"{RED}Failed to get last episode for show '{show.title}': {e}{RESET}") 145 | return None 146 | 147 | def search_trakt_show(show_title, client_id): 148 | """ 149 | Searches for a TV show on Trakt and retrieves its Trakt ID, slug, IMDb ID, and TMDB ID. 150 | """ 151 | search_url = "https://api.trakt.tv/search/show" 152 | headers = { 153 | "Content-Type": "application/json", 154 | "trakt-api-version": "2", 155 | "trakt-api-key": client_id 156 | } 157 | params = { 158 | "query": show_title, 159 | "limit": 1, # Fetch the top result 160 | "extended": "full" # Get full details 161 | } 162 | 163 | try: 164 | response = requests.get(search_url, headers=headers, params=params) 165 | response.raise_for_status() 166 | results = response.json() 167 | 168 | if not results: 169 | return None 170 | 171 | # Extract the first result 172 | show = results[0]['show'] 173 | trakt_id = show['ids'].get('trakt') 174 | slug = show['ids'].get('slug') 175 | imdb_id = show['ids'].get('imdb') # Extract IMDb ID 176 | tmdb_id = show['ids'].get('tmdb') # Extract TMDB ID 177 | 178 | return { 179 | 'trakt_id': trakt_id, 180 | 'slug': slug, 181 | 'imdb_id': imdb_id, 182 | 'tmdb_id': tmdb_id 183 | } 184 | 185 | except requests.exceptions.HTTPError as http_err: 186 | if http_err.response.status_code != 404: 187 | print(f"{RED}HTTP error occurred while searching Trakt for '{show_title}': {http_err}{RESET}") 188 | print(f"Response Status Code: {http_err.response.status_code}") 189 | print(f"Response Body: {http_err.response.text}{RESET}") 190 | except Exception as err: 191 | print(f"{RED}An error occurred while searching Trakt for '{show_title}': {err}{RESET}") 192 | 193 | return None 194 | 195 | def get_episode_details(trakt_identifier, season, episode, client_id): 196 | """ 197 | Retrieves the episode_type and first_aired date of a specific episode from Trakt. 198 | """ 199 | if isinstance(trakt_identifier, int): 200 | identifier = trakt_identifier 201 | else: 202 | identifier = trakt_identifier # Assuming slug 203 | 204 | api_url = f"https://api.trakt.tv/shows/{identifier}/seasons/{season}/episodes/{episode}" 205 | headers = { 206 | "Content-Type": "application/json", 207 | "trakt-api-version": "2", 208 | "trakt-api-key": client_id 209 | } 210 | params = { 211 | "extended": "full,images,translations,ratings" 212 | } 213 | 214 | try: 215 | response = requests.get(api_url, headers=headers, params=params) 216 | if response.status_code == 404: 217 | return None 218 | response.raise_for_status() 219 | episode_details = response.json() 220 | 221 | episode_type = episode_details.get('episode_type') 222 | first_aired_str = episode_details.get('first_aired') 223 | 224 | if first_aired_str: 225 | # Handle both 'Z' and fractional seconds 226 | try: 227 | # Example format: '2024-03-21T07:00:00.000Z' 228 | first_aired = datetime.strptime(first_aired_str, "%Y-%m-%dT%H:%M:%S.%fZ") 229 | except ValueError: 230 | try: 231 | # Example format without milliseconds: '2024-03-21T07:00:00Z' 232 | first_aired = datetime.strptime(first_aired_str, "%Y-%m-%dT%H:%M:%SZ") 233 | except ValueError: 234 | print(f"{RED}Unable to parse date '{first_aired_str}' for Trakt episode.{RESET}") 235 | first_aired = None 236 | else: 237 | first_aired = None 238 | 239 | return (episode_type, first_aired) 240 | 241 | except requests.exceptions.HTTPError as http_err: 242 | if http_err.response.status_code != 404: 243 | print(f"{RED}HTTP error occurred while fetching episode details from Trakt: {http_err}{RESET}") 244 | print(f"Response Status Code: {http_err.response.status_code}") 245 | print(f"Response Body: {http_err.response.text}{RESET}") 246 | except Exception as err: 247 | print(f"{RED}An error occurred while fetching episode details from Trakt: {err}{RESET}") 248 | 249 | return None 250 | 251 | def add_label_to_show(show, label): 252 | """ 253 | Adds a label to the given Plex show using the addLabel method. 254 | """ 255 | try: 256 | # Reload the show to ensure the latest labels are fetched 257 | show.reload() 258 | 259 | # Extract label tags as strings 260 | existing_labels = [lab.tag for lab in show.labels] 261 | 262 | # Check if the label already exists 263 | if label in existing_labels: 264 | return False # Label already exists; do nothing 265 | 266 | # Add the label using the addLabel method 267 | show.addLabel(label) 268 | return True # Indicate that label was added 269 | 270 | except AttributeError: 271 | print(f"{RED}The 'addLabel' method does not exist for show '{show.title}'. Please verify the Plex API version and method availability.{RESET}") 272 | except Exception as e: 273 | print(f"{RED}Failed to add label '{label}' to show '{show.title}': {e}{RESET}") 274 | 275 | return False # Indicate that label was not added 276 | 277 | def remove_label_from_show(show, label): 278 | """ 279 | Removes a label from the given Plex show using the removeLabel method. 280 | """ 281 | try: 282 | # Reload the show to ensure the latest labels are fetched 283 | show.reload() 284 | 285 | # Extract label tags as strings 286 | existing_labels = [lab.tag for lab in show.labels] 287 | 288 | # Check if the label exists 289 | if label not in existing_labels: 290 | return False # Label does not exist; do nothing 291 | 292 | # Remove the label using the removeLabel method 293 | show.removeLabel(label) 294 | return True # Indicate that label was removed 295 | 296 | except AttributeError: 297 | print(f"{RED}The 'removeLabel' method does not exist for show '{show.title}'. Please verify the Plex API version and method availability.{RESET}") 298 | except Exception as e: 299 | print(f"{RED}Failed to remove label '{label}' from show '{show.title}': {e}{RESET}") 300 | 301 | return False # Indicate that label was not removed 302 | 303 | def main(): 304 | # Start runtime timer 305 | start_time = time.time() 306 | 307 | # Step 1: Print Configuration Variables 308 | print("\n=== Configuration ===") 309 | print(f"Recent Days: {RECENT_DAYS}") 310 | print(f"Desired Episode Types: {DESIRED_EPISODE_TYPES}") 311 | 312 | # Print Skip Genres along with Genres to Skip on the same line 313 | genre_color = GREEN if SKIP_GENRES else ORANGE 314 | print(f"Skip Genres: {genre_color}{SKIP_GENRES}{RESET} {GENRES_TO_SKIP}") 315 | 316 | # Print Skip Labels along with Labels to Skip on the same line 317 | label_color = GREEN if SKIP_LABELS else ORANGE 318 | print(f"Skip Labels: {label_color}{SKIP_LABELS}{RESET} {LABELS_TO_SKIP}") 319 | 320 | # For the remaining boolean configuration variables, print using colors 321 | def print_bool(var_name, var_value): 322 | color = GREEN if var_value else ORANGE 323 | print(f"{var_name}: {color}{var_value}{RESET}") 324 | 325 | print_bool("Label in Plex:", LABEL_SERIES_IN_PLEX) 326 | print_bool("Remove Labels if No Longer Matched:", REMOVE_LABELS_IF_NO_LONGER_MATCHED) 327 | print_bool("Only Finale Unwatched:", ONLY_FINALE_UNWATCHED) 328 | print("====================\n") 329 | 330 | # Step 2: Connect to Plex and retrieve the library section 331 | library, plex = connect_plex(PLEX_URL, PLEX_TOKEN, PLEX_LIBRARY_TITLE) 332 | if not library: 333 | print("Cannot proceed without a valid library section.") 334 | return 335 | 336 | # Step 3: Get all TV shows in the Plex library 337 | shows = get_all_tv_shows(library) 338 | if not shows: 339 | print("No TV shows found in the library.") 340 | return 341 | 342 | print(f"Found {len(shows)} TV shows in the library '{PLEX_LIBRARY_TITLE}'.\n") 343 | 344 | # Step 4: Define the cutoff date for past episodes 345 | cutoff_past = datetime.now() - timedelta(days=RECENT_DAYS) 346 | 347 | # Step 5: Iterate through each show to find the last episode and its episode_type 348 | qualifying_shows = [] 349 | labels_added = [] 350 | labels_existed = [] 351 | labels_removed = [] 352 | 353 | for show in tqdm(shows, desc="Processing Shows"): 354 | show_title = show.title 355 | # Reload the show to ensure the latest labels and metadata are fetched 356 | try: 357 | show.reload() 358 | except Exception as e: 359 | # Optionally log the error or handle it silently 360 | continue 361 | 362 | # Apply Skipping Logic 363 | if SKIP_GENRES: 364 | show_genres = [genre.tag for genre in show.genres] if show.genres else [] 365 | # Clean genre names by stripping any leading/trailing whitespace 366 | show_genres = [genre.strip() for genre in show_genres] 367 | if any(genre in GENRES_TO_SKIP for genre in show_genres): 368 | continue # Skip this show 369 | 370 | if SKIP_LABELS: 371 | show_labels = [lab.tag for lab in show.labels] 372 | if any(label in LABELS_TO_SKIP for label in show_labels): 373 | continue # Skip this show 374 | 375 | # Get the last episode details 376 | last_episode = get_last_episode(show) 377 | if not last_episode: 378 | continue 379 | 380 | season_number, episode_number, episode_title = last_episode 381 | 382 | # Search for the show on Trakt to get Trakt ID or slug and external IDs 383 | trakt_info = search_trakt_show(show_title, TRAKT_CLIENT_ID) 384 | if not trakt_info: 385 | continue 386 | 387 | trakt_id = trakt_info['trakt_id'] 388 | trakt_slug = trakt_info['slug'] 389 | imdb_id = trakt_info.get('imdb_id') # Retrieve IMDb ID 390 | tmdb_id = trakt_info.get('tmdb_id') # Retrieve TMDB ID 391 | 392 | # Fetch episode_type and first_aired from Trakt 393 | episode_details = get_episode_details(trakt_slug, season_number, episode_number, TRAKT_CLIENT_ID) 394 | if not episode_details: 395 | continue 396 | 397 | episode_type, first_aired = episode_details 398 | 399 | # Validate first_aired 400 | if not first_aired: 401 | continue 402 | 403 | # Determine if the episode has already aired or will air 404 | if first_aired <= datetime.now(): 405 | # Episode has already aired; check if within RECENT_DAYS 406 | if first_aired < cutoff_past: 407 | continue # Skip episodes aired before the cutoff 408 | air_status = f"aired on {first_aired.strftime('%Y-%m-%d')}" 409 | else: 410 | # Episode is scheduled to air in the future; include regardless of days 411 | air_status = f"{BLUE}will air on{RESET} {first_aired.strftime('%Y-%m-%d')}" 412 | 413 | # Check if episode_type is one of the desired types 414 | if episode_type and episode_type.lower() in [etype.lower() for etype in DESIRED_EPISODE_TYPES]: 415 | # If ONLY_FINALE_UNWATCHED is True, check if finale is the only unwatched episode in the season 416 | if ONLY_FINALE_UNWATCHED: 417 | try: 418 | # Get the specific season 419 | season_obj = show.season(season_number) 420 | if not season_obj: 421 | continue 422 | 423 | # Get the specific episode 424 | try: 425 | finale_ep = season_obj.episode(episode_number) 426 | except Exception: 427 | continue 428 | 429 | # Check if the finale episode is unwatched 430 | if finale_ep.isWatched: 431 | continue # Finale episode is watched, skip 432 | 433 | # Check if all other episodes are watched 434 | all_others_watched = all(ep.isWatched for ep in season_obj.episodes() if ep != finale_ep) 435 | 436 | if not all_others_watched: 437 | continue # There are other unwatched episodes, skip 438 | except Exception as e: 439 | # Optionally log the error or handle it silently 440 | continue 441 | 442 | # Append to qualifying shows 443 | qualifying_shows.append({ 444 | "title": show_title, 445 | "season": season_number, 446 | "episode": episode_number, 447 | "episode_title": episode_title, 448 | "episode_type": episode_type, 449 | "air_status": air_status, 450 | "imdb_id": imdb_id, # Add IMDb ID 451 | "tmdb_id": tmdb_id # Add TMDB ID 452 | }) 453 | 454 | # Apply label to the show if enabled 455 | if LABEL_SERIES_IN_PLEX: 456 | # Define the label based on episode_type (normalize to Plex case behavior) 457 | label = normalize_plex_label(episode_type) 458 | 459 | # Reload the show again to ensure we get the latest labels 460 | show.reload() 461 | 462 | # Check if the desired label already exists 463 | current_labels = [lab.tag.capitalize() for lab in show.labels] # Normalize to Plex capitalization 464 | if label in current_labels: 465 | labels_existed.append((show_title, label)) 466 | else: 467 | # Remove any existing labels from DESIRED_EPISODE_TYPES but not the current label 468 | labels_to_remove = [ 469 | lab for lab in current_labels 470 | if lab in [normalize_plex_label(etype) for etype in DESIRED_EPISODE_TYPES] and lab != label 471 | ] 472 | for existing_label in labels_to_remove: 473 | removed = remove_label_from_show(show, existing_label) 474 | if removed: 475 | labels_removed.append((show_title, existing_label)) 476 | # Add the new label 477 | label_added = add_label_to_show(show, label) 478 | if label_added: 479 | labels_added.append((show_title, label)) 480 | # Optional: To prevent hitting Trakt rate limits, add a short delay 481 | time.sleep(0.5) # Sleep for 0.5 seconds 482 | 483 | 484 | # Step 6: Remove labels from shows if configured to do so 485 | if REMOVE_LABELS_IF_NO_LONGER_MATCHED: 486 | try: 487 | # If LABEL_SERIES_IN_PLEX is False, remove all labels in DESIRED_EPISODE_TYPES from all shows 488 | if not LABEL_SERIES_IN_PLEX: 489 | for show in shows: 490 | show.reload() # Ensure we have the latest metadata 491 | current_labels = [lab.tag for lab in show.labels] 492 | labels_to_remove = [ 493 | lab for lab in current_labels 494 | if normalize_plex_label(lab) in [normalize_plex_label(etype) for etype in DESIRED_EPISODE_TYPES] 495 | ] 496 | for label in labels_to_remove: 497 | try: 498 | removed = remove_label_from_show(show, label) 499 | if removed: 500 | labels_removed.append((show.title, label)) 501 | except Exception as e: 502 | print(f"{RED}Error removing label '{label}' from show '{show.title}': {e}{RESET}") 503 | else: 504 | # Standard logic: Remove outdated labels for non-qualifying shows 505 | qualifying_show_titles = set([show['title'] for show in qualifying_shows]) 506 | for show in shows: 507 | show.reload() # Ensure we have the latest metadata 508 | current_labels = [lab.tag for lab in show.labels] 509 | labels_to_remove = [ 510 | lab for lab in current_labels 511 | if normalize_plex_label(lab) in [normalize_plex_label(etype) for etype in DESIRED_EPISODE_TYPES] 512 | and show.title not in qualifying_show_titles 513 | ] 514 | for label in labels_to_remove: 515 | try: 516 | removed = remove_label_from_show(show, label) 517 | if removed: 518 | labels_removed.append((show.title, label)) 519 | except Exception as e: 520 | print(f"{RED}Error removing label '{label}' from show '{show.title}': {e}{RESET}") 521 | except Exception as e: 522 | print(f"{RED}An error occurred while removing outdated labels: {e}{RESET}") 523 | 524 | # Step 7: Display the qualifying shows 525 | if qualifying_shows: 526 | print(f"\n{GREEN}=== Qualifying TV Shows with Finale Episodes === {RESET}") 527 | for item in qualifying_shows: 528 | imdb_display = item['imdb_id'] if item['imdb_id'] else "N/A" 529 | tmdb_display = item['tmdb_id'] if item['tmdb_id'] else "N/A" 530 | print(f"{item['title']} (TMDB: {tmdb_display}, IMDB: {imdb_display}): " 531 | f"Season {item['season']} Episode {item['episode']} '{item['episode_title']}' " 532 | f"({item['episode_type']}) {item['air_status']}") 533 | else: 534 | print(f"\n{BLUE}No TV shows found matching criteria.{RESET}") 535 | print("========================\n") 536 | 537 | # Step 8: Display label operations 538 | print("\n=== Label Operations ===") 539 | 540 | if LABEL_SERIES_IN_PLEX or REMOVE_LABELS_IF_NO_LONGER_MATCHED: 541 | # Display labels added 542 | if labels_added: 543 | for title, label in labels_added: 544 | print(f"{GREEN}+ Added label '{label}' to show '{title}'{RESET}") 545 | 546 | # Display labels that already existed 547 | if labels_existed: 548 | for title, label in labels_existed: 549 | print(f"{ORANGE}= Label '{label}' already exists for show '{title}'{RESET}") 550 | 551 | # Display labels removed 552 | if labels_removed: 553 | for title, label in labels_removed: 554 | print(f"{RED}- Removed label '{label}' from show '{title}'{RESET}") 555 | 556 | # Step 9: Print runtime 557 | end_time = time.time() 558 | runtime_seconds = int(end_time - start_time) 559 | hours, remainder = divmod(runtime_seconds, 3600) 560 | minutes, seconds = divmod(remainder, 60) 561 | print(f"Runtime: {hours:02}:{minutes:02}:{seconds:02}") 562 | 563 | if __name__ == "__main__": 564 | main() 565 | -------------------------------------------------------------------------------- /Modules/Sonarr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | import yaml 7 | import re 8 | import time 9 | import datetime 10 | from datetime import timedelta, datetime as dt 11 | from pathlib import Path 12 | from path_handler import PathHandler 13 | 14 | # Set up logging 15 | script_name = os.path.splitext(os.path.basename(__file__))[0] # Get script name without extension 16 | logs_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "Logs", script_name) 17 | os.makedirs(logs_dir, exist_ok=True) 18 | log_file = os.path.join(logs_dir, f"log_{dt.now().strftime('%Y%m%d_%H%M%S')}.txt") 19 | 20 | class Logger: 21 | def __init__(self, log_file): 22 | self.terminal = sys.stdout 23 | self.log = open(log_file, "a", encoding="utf-8") # Specify UTF-8 encoding 24 | 25 | def write(self, message): 26 | self.terminal.write(message) 27 | self.log.write(message) 28 | 29 | def flush(self): 30 | self.terminal.flush() 31 | self.log.flush() 32 | 33 | sys.stdout = Logger(log_file) 34 | sys.stderr = Logger(log_file) 35 | 36 | # Clean up old logs 37 | def clean_old_logs(): 38 | log_files = sorted( 39 | [os.path.join(logs_dir, f) for f in os.listdir(logs_dir) if f.startswith("log_")], 40 | key=os.path.getmtime 41 | ) 42 | while len(log_files) > 31: 43 | os.remove(log_files.pop(0)) 44 | 45 | clean_old_logs() 46 | 47 | import requests 48 | try: 49 | from plexapi.server import PlexServer 50 | except ImportError: 51 | print("ERROR: python-plexapi is not installed. Run: pip install plexapi") 52 | sys.exit(1) 53 | 54 | # ANSI color codes 55 | GREEN = '\033[32m' 56 | ORANGE = '\033[33m' 57 | BLUE = '\033[34m' 58 | RED = '\033[31m' 59 | RESET = '\033[0m' 60 | 61 | def normalize_sonarr_url(url): 62 | """Ensure Sonarr URL ends with /api/v3 but avoid doubling it.""" 63 | # Remove trailing slashes 64 | url = url.rstrip('/') 65 | 66 | # Check if URL already ends with /api/v3 67 | if not url.endswith('/api/v3'): 68 | # If URL ends with /sonarr, just append /api/v3 69 | if url.endswith('/sonarr'): 70 | url = f"{url}/api/v3" 71 | # If URL doesn't contain /sonarr at all, append /sonarr/api/v3 72 | elif '/sonarr' not in url: 73 | url = f"{url}/api/v3" 74 | # If URL contains /sonarr somewhere but not at the end, assume it's correct 75 | 76 | return url 77 | 78 | # Load configuration from config.yml in parent folder 79 | def load_config(): 80 | current_dir = Path(__file__).parent 81 | config_path = current_dir.parent / "config.yml" 82 | try: 83 | with open(config_path, "r") as file: 84 | config = yaml.safe_load(file) 85 | 86 | # Normalize Sonarr URL 87 | if 'sonarr' in config and 'url' in config['sonarr']: 88 | config['sonarr']['url'] = normalize_sonarr_url(config['sonarr']['url']) 89 | else: 90 | print(f"{RED}ERROR: Sonarr URL not found in config.yml. Please check your configuration.{RESET}") 91 | sys.exit(1) 92 | 93 | if not config['sonarr'].get('api_key'): 94 | print(f"{RED}ERROR: Sonarr API key not found in config.yml. Please check your configuration.{RESET}") 95 | sys.exit(1) 96 | 97 | global path_handler 98 | path_handler = PathHandler(config) 99 | return config 100 | except FileNotFoundError: 101 | print(f"{RED}ERROR: Could not find config.yml at {config_path}.{RESET}") 102 | sys.exit(1) 103 | except yaml.YAMLError as e: 104 | print(f"{RED}ERROR: Invalid YAML format in config.yml: {str(e)}{RESET}") 105 | sys.exit(1) 106 | except Exception as e: 107 | print(f"{RED}ERROR: An error occurred while loading config.yml: {str(e)}{RESET}") 108 | sys.exit(1) 109 | 110 | config = load_config() 111 | 112 | # Extract configurations 113 | SONARR_URL = config['sonarr']['url'] 114 | SONARR_API_KEY = config['sonarr']['api_key'] 115 | 116 | PLEX_URL = config['plex']['url'] 117 | PLEX_TOKEN = config['plex']['token'] 118 | PLEX_LIBRARY_TITLE = config['plex']['library_title'] 119 | 120 | RECENT_DAYS = config['general']['recent_days'] 121 | SKIP_UNMONITORED = config['general']['skip_unmonitored'] 122 | SKIP_GENRES = config['general']['skip_genres'] 123 | GENRES_TO_SKIP = config['general']['genres_to_skip'] 124 | SKIP_LABELS = config['general']['skip_labels'] 125 | LABELS_TO_SKIP = config['general']['labels_to_skip'] 126 | LABEL_SERIES_IN_PLEX = config['general']['label_series_in_plex'] 127 | PLEX_LABEL = config['general']['plex_label'] 128 | REMOVE_LABELS_IF_NO_LONGER_MATCHED = config['general']['remove_labels_if_no_longer_matched'] 129 | ONLY_FINALE_UNWATCHED = config['general']['only_finale_unwatched'] 130 | 131 | # ----------------------# 132 | # Sonarr Finale Logic # 133 | # ----------------------# 134 | def get_sonarr_series(): 135 | """Get all series from Sonarr with improved error handling.""" 136 | try: 137 | url = f"{SONARR_URL}/series?apikey={SONARR_API_KEY}" 138 | resp = requests.get(url, timeout=10) # Add timeout 139 | 140 | # Handle common HTTP errors 141 | if resp.status_code == 401: 142 | print(f"{RED}ERROR: Invalid API key for Sonarr. Please check your config.yml{RESET}") 143 | sys.exit(1) 144 | elif resp.status_code == 404: 145 | print(f"{RED}ERROR: Sonarr API not found at {SONARR_URL}. Please check your URL configuration.{RESET}") 146 | sys.exit(1) 147 | elif resp.status_code != 200: 148 | print(f"{RED}ERROR: Sonarr returned status code {resp.status_code}{RESET}") 149 | sys.exit(1) 150 | 151 | try: 152 | return resp.json() 153 | except requests.exceptions.JSONDecodeError: 154 | print(f"{RED}ERROR: Invalid response from Sonarr. Please check if your Sonarr URL is correct.{RESET}") 155 | print(f"URL used: {url}") 156 | sys.exit(1) 157 | 158 | except requests.exceptions.ConnectionError: 159 | print(f"{RED}ERROR: Could not connect to Sonarr at {SONARR_URL}{RESET}") 160 | print("Please check:") 161 | print("1. If Sonarr is running") 162 | print("2. If the URL in config.yml is correct") 163 | print("3. If you can access Sonarr in your browser") 164 | sys.exit(1) 165 | except requests.exceptions.Timeout: 166 | print(f"{RED}ERROR: Connection to Sonarr timed out. Please check if Sonarr is responding.{RESET}") 167 | sys.exit(1) 168 | except Exception as e: 169 | print(f"{RED}ERROR: Unexpected error while connecting to Sonarr: {str(e)}{RESET}") 170 | sys.exit(1) 171 | 172 | def get_sonarr_episodes(series_id): 173 | url = f"{SONARR_URL}/episode?seriesId={series_id}&apikey={SONARR_API_KEY}" 174 | resp = requests.get(url) 175 | resp.raise_for_status() 176 | return resp.json() 177 | 178 | def is_episode_downloaded(season_number, episode_number, series_id): 179 | url = f"{SONARR_URL}/episodefile?seriesId={series_id}&apikey={SONARR_API_KEY}" 180 | resp = requests.get(url) 181 | if resp.status_code == 400: 182 | return False 183 | resp.raise_for_status() 184 | 185 | episode_files = resp.json() 186 | needle = f"s{season_number:02d}e{episode_number:02d}" 187 | for ef in episode_files: 188 | # Map the path from Sonarr to local system 189 | relative_path = path_handler.map_path(ef.get('relativePath', '')) 190 | if needle in relative_path.lower() and ef.get('size', 0) > 0: 191 | return True 192 | return False 193 | 194 | def get_recent_finales(): 195 | cutoff_date = dt.now() - timedelta(days=RECENT_DAYS) 196 | finales_downloaded = [] 197 | finales_not_downloaded = [] 198 | 199 | all_series = get_sonarr_series() 200 | for s in all_series: 201 | if SKIP_UNMONITORED and not s.get('monitored', True): 202 | continue 203 | 204 | episodes = get_sonarr_episodes(s['id']) 205 | if not episodes: 206 | continue 207 | 208 | valid_seasons = [e['seasonNumber'] for e in episodes if e.get('seasonNumber', 0) > 0] 209 | if not valid_seasons: 210 | continue 211 | last_season = max(valid_seasons) 212 | 213 | season_map = {} 214 | for e in episodes: 215 | snum = e.get('seasonNumber', 0) 216 | if snum > 0: 217 | season_map.setdefault(snum, []).append(e) 218 | 219 | for snum, eps in season_map.items(): 220 | if not eps: 221 | continue 222 | last_ep = max(eps, key=lambda x: x['episodeNumber']) 223 | air_date_utc = last_ep.get('airDateUtc') 224 | if not air_date_utc: 225 | continue 226 | 227 | try: 228 | air_date = dt.fromisoformat(air_date_utc.rstrip('Z')) 229 | except ValueError: 230 | print(f"{RED}ERROR: Invalid airDateUtc format for episode '{last_ep.get('title', 'N/A')}' in show '{s.get('title', 'N/A')}'{RESET}") 231 | continue 232 | 233 | tmdb_id = s.get('tmdbId', 'N/A') 234 | imdb_id = s.get('imdbId', 'N/A') 235 | monitored = s.get('monitored', False) 236 | 237 | if snum == last_season: 238 | if cutoff_date <= air_date <= dt.now(): 239 | downloaded = is_episode_downloaded(last_ep['seasonNumber'], last_ep['episodeNumber'], s['id']) 240 | if downloaded: 241 | finales_downloaded.append(( 242 | s['title'], snum, last_ep['episodeNumber'], last_ep['title'], 243 | air_date.date(), tmdb_id, imdb_id, monitored 244 | )) 245 | else: 246 | finales_not_downloaded.append(( 247 | s['title'], snum, last_ep['episodeNumber'], last_ep['title'], 248 | air_date.date(), tmdb_id, imdb_id, monitored 249 | )) 250 | elif air_date > dt.now(): 251 | downloaded = is_episode_downloaded(last_ep['seasonNumber'], last_ep['episodeNumber'], s['id']) 252 | if downloaded: 253 | finales_downloaded.append(( 254 | s['title'], snum, last_ep['episodeNumber'], last_ep['title'], 255 | air_date.date(), tmdb_id, imdb_id, monitored, True 256 | )) 257 | 258 | return finales_downloaded, finales_not_downloaded 259 | 260 | # --------------------# 261 | # Plex Connection # 262 | # --------------------# 263 | def connect_plex(): 264 | try: 265 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 266 | return plex.library.section(PLEX_LIBRARY_TITLE) 267 | except Exception as e: 268 | print(f"{RED}ERROR: Failed to connect to Plex: {e}{RESET}") 269 | sys.exit(1) 270 | 271 | def build_plex_id_map(plex_shows): 272 | id_map = {} 273 | for show_obj in plex_shows: 274 | try: 275 | show_obj = show_obj.reload() # Fetch full show data, including all genres 276 | except Exception as e: 277 | print(f"{RED}ERROR: Failed to reload show '{show_obj.title}': {e}{RESET}") 278 | continue # Skip this show and proceed with others 279 | 280 | for guid in show_obj.guids: 281 | raw_id = guid.id.lower() 282 | if raw_id.startswith("imdb://"): 283 | imdb_clean = raw_id.split("imdb://", 1)[1].split("?")[0] 284 | id_map[("imdb", imdb_clean)] = show_obj 285 | elif raw_id.startswith("tmdb://"): 286 | tmdb_clean = raw_id.split("tmdb://", 1)[1].split("?")[0] 287 | id_map[("tmdb", tmdb_clean)] = show_obj 288 | 289 | return id_map 290 | 291 | def get_plex_show_by_ids(imdb_id, tmdb_id, show_map): 292 | if imdb_id and str(imdb_id).lower() != "n/a": 293 | candidate = ("imdb", str(imdb_id).lower()) 294 | if candidate in show_map: 295 | return show_map[candidate] 296 | if tmdb_id and str(tmdb_id).lower() != "n/a": 297 | candidate = ("tmdb", str(tmdb_id).lower()) 298 | if candidate in show_map: 299 | return show_map[candidate] 300 | return None 301 | 302 | def skip_show_for_genre(show_obj, genres_to_skip): 303 | show_genres_lower = [genre.tag.lower() for genre in show_obj.genres] 304 | skip_genres_lower = [g.lower() for g in genres_to_skip] 305 | for sg in skip_genres_lower: 306 | if sg in show_genres_lower: 307 | return True 308 | return False 309 | 310 | def skip_show_for_labels(show_obj, labels_to_skip): 311 | current_labels = [lab.tag.lower() for lab in show_obj.labels] 312 | labels_to_skip_lower = [label.lower() for label in labels_to_skip] 313 | for label in labels_to_skip_lower: 314 | if label in current_labels: 315 | return True 316 | return False 317 | 318 | def filter_out_plex_genres_and_labels(finales_list, show_map, skip_genres, skip_labels, genres_to_skip, labels_to_skip): 319 | filtered = [] 320 | for finale in finales_list: 321 | if len(finale) == 9: 322 | _, snum, enum, _, _, tmdb_id, imdb_id, _, _ = finale 323 | elif len(finale) == 8: 324 | _, snum, enum, _, _, tmdb_id, imdb_id, _ = finale 325 | 326 | plex_show = get_plex_show_by_ids(imdb_id, tmdb_id, show_map) 327 | if plex_show: 328 | if skip_genres and skip_show_for_genre(plex_show, genres_to_skip): 329 | continue 330 | if skip_labels and skip_show_for_labels(plex_show, labels_to_skip): 331 | continue 332 | filtered.append(finale) 333 | return filtered 334 | 335 | def filter_shows_with_one_unwatched(finales_list, show_map): 336 | filtered = [] 337 | for finale in finales_list: 338 | if len(finale) == 9: 339 | title, snum, enum, ep_title, air_date, tmdb_id, imdb_id, monitored, _ = finale 340 | elif len(finale) == 8: 341 | title, snum, enum, ep_title, air_date, tmdb_id, imdb_id, monitored = finale 342 | 343 | plex_show = get_plex_show_by_ids(imdb_id, tmdb_id, show_map) 344 | if plex_show: 345 | try: 346 | # Get the specific season 347 | season_obj = plex_show.season(snum) 348 | if not season_obj: 349 | continue 350 | 351 | # Get the specific episode 352 | try: 353 | finale_ep = season_obj.episode(enum) 354 | except Exception: 355 | continue 356 | 357 | # Check if the finale episode is unwatched 358 | if finale_ep.isWatched: 359 | continue # Finale episode is watched, skip 360 | 361 | # Check if all other episodes are watched 362 | all_others_watched = all(ep.isWatched for ep in season_obj.episodes() if ep != finale_ep) 363 | 364 | if all_others_watched: 365 | filtered.append(finale) 366 | except Exception: 367 | continue # Skip this show silently 368 | 369 | return filtered 370 | 371 | # -------------------------------# 372 | # Label Add/Remove Functions # 373 | # -------------------------------# 374 | def add_label_to_show(show_obj, label): 375 | current_labels = [lab.tag for lab in show_obj.labels] 376 | if label in current_labels: 377 | print(f"{GREEN}={RESET} Label '{label}' already exists for show '{show_obj.title}', skipping.") 378 | return 379 | print(f"{ORANGE}+{RESET} Adding label '{label}' to show '{show_obj.title}'") 380 | show_obj.addLabel(label) 381 | show_obj.reload() 382 | 383 | def remove_label_if_present(show_obj, label): 384 | current_labels = [lab.tag for lab in show_obj.labels] 385 | if label in current_labels: 386 | print(f"{RED}-{RESET} Removing label '{label}' from show '{show_obj.title}'") 387 | show_obj.removeLabel(label) 388 | show_obj.reload() 389 | 390 | def remove_label_from_all_shows(label): 391 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 392 | tv_library = plex.library.section(PLEX_LIBRARY_TITLE) 393 | shows = tv_library.all() 394 | 395 | for show_obj in shows: 396 | if label in [lab.tag for lab in show_obj.labels]: 397 | remove_label_if_present(show_obj, label) 398 | 399 | def remove_label_only_unmatched(finales_downloaded, label): 400 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 401 | tv_library = plex.library.section(PLEX_LIBRARY_TITLE) 402 | shows = tv_library.all() 403 | show_map = build_plex_id_map(shows) 404 | 405 | matched_shows_set = set() 406 | for f in finales_downloaded: 407 | if len(f) == 9: 408 | _, snum, enum, _, _, tmdb_id, imdb_id, _, _ = f 409 | elif len(f) == 8: 410 | _, snum, enum, _, _, tmdb_id, imdb_id, _ = f 411 | plex_show = get_plex_show_by_ids(imdb_id, tmdb_id, show_map) 412 | if plex_show: 413 | matched_shows_set.add(plex_show) 414 | 415 | for sh in shows: 416 | if label in [lab.tag for lab in sh.labels]: 417 | if sh not in matched_shows_set: 418 | remove_label_if_present(sh, label) 419 | 420 | def matched_shows(finales_downloaded, label): 421 | """Add label to all matched shows in `finales_downloaded`.""" 422 | plex = PlexServer(PLEX_URL, PLEX_TOKEN) 423 | tv_library = plex.library.section(PLEX_LIBRARY_TITLE) 424 | shows = tv_library.all() 425 | show_map = build_plex_id_map(shows) 426 | 427 | matched = set() 428 | for f in finales_downloaded: 429 | if len(f) == 9: 430 | _, snum, enum, _, _, tmdb_id, imdb_id, _, _ = f 431 | elif len(f) == 8: 432 | _, snum, enum, _, _, tmdb_id, imdb_id, _ = f 433 | plex_show = get_plex_show_by_ids(imdb_id, tmdb_id, show_map) 434 | if plex_show: 435 | matched.add(plex_show) 436 | 437 | for s in matched: 438 | add_label_to_show(s, label) 439 | 440 | def handle_label_logic(finales_downloaded): 441 | if not LABEL_SERIES_IN_PLEX: 442 | if REMOVE_LABELS_IF_NO_LONGER_MATCHED: 443 | # Remove from ALL shows 444 | remove_label_from_all_shows(PLEX_LABEL) 445 | else: 446 | # LABEL_SERIES_IN_PLEX == True 447 | matched_shows(finales_downloaded, PLEX_LABEL) 448 | if REMOVE_LABELS_IF_NO_LONGER_MATCHED: 449 | remove_label_only_unmatched(finales_downloaded, PLEX_LABEL) 450 | 451 | # -----------------# 452 | # TERMINAL RUN # 453 | # -----------------# 454 | if __name__ == "__main__": 455 | start_time = time.time() 456 | 457 | def color_bool_generic(val): 458 | return f"{GREEN}True{RESET}" if val else f"{ORANGE}False{RESET}" 459 | 460 | def color_bool_label_in_plex(): 461 | if LABEL_SERIES_IN_PLEX: 462 | return f"{GREEN}True{RESET} ({PLEX_LABEL})" 463 | else: 464 | return f"{ORANGE}False{RESET}" 465 | 466 | def color_bool_remove_labels(): 467 | if REMOVE_LABELS_IF_NO_LONGER_MATCHED: 468 | return f"{GREEN}True{RESET}" 469 | else: 470 | return f"{ORANGE}False{RESET}" 471 | 472 | def color_bool_skip_genres(): 473 | if SKIP_GENRES: 474 | return f"{GREEN}True{RESET} ({', '.join(GENRES_TO_SKIP)})" 475 | else: 476 | return f"{ORANGE}False{RESET}" 477 | 478 | def color_bool_skip_labels(): 479 | if SKIP_LABELS: 480 | return f"{GREEN}True{RESET} ({', '.join(LABELS_TO_SKIP)})" 481 | else: 482 | return f"{ORANGE}False{RESET}" 483 | 484 | def color_bool_only_finale_unwatched(): 485 | return f"{GREEN}True{RESET}" if ONLY_FINALE_UNWATCHED else f"{ORANGE}False{RESET}" 486 | 487 | # Print configuration summary 488 | print("\n=== Configuration ===") 489 | print(f"Recent Days: {RECENT_DAYS}") 490 | print(f"Skip Unmonitored: {color_bool_generic(SKIP_UNMONITORED)}") 491 | print(f"Skip Genres: {color_bool_skip_genres()}") 492 | print(f"Skip Labels: {color_bool_skip_labels()}") 493 | print(f"Label in Plex: {color_bool_label_in_plex()}") 494 | print(f"Remove Labels if No Longer Matched: {color_bool_remove_labels()}") 495 | print(f"Only Finale Unwatched: {color_bool_only_finale_unwatched()}") 496 | print("====================\n") 497 | 498 | # Fetch recent finales from Sonarr 499 | finales_downloaded, finales_not_downloaded = get_recent_finales() 500 | 501 | # Connect to Plex and build show map 502 | plex_section = connect_plex() 503 | all_plex_shows = plex_section.all() 504 | show_map = build_plex_id_map(all_plex_shows) 505 | 506 | # If skipping genres or labels, filter out based on genres and labels 507 | if SKIP_GENRES or SKIP_LABELS: 508 | filtered_downloaded = filter_out_plex_genres_and_labels( 509 | finales_downloaded, show_map, SKIP_GENRES, SKIP_LABELS, GENRES_TO_SKIP, LABELS_TO_SKIP 510 | ) 511 | filtered_not_downloaded = filter_out_plex_genres_and_labels( 512 | finales_not_downloaded, show_map, SKIP_GENRES, SKIP_LABELS, GENRES_TO_SKIP, LABELS_TO_SKIP 513 | ) 514 | else: 515 | filtered_downloaded = finales_downloaded 516 | filtered_not_downloaded = finales_not_downloaded 517 | 518 | # Apply the new filter if enabled 519 | if ONLY_FINALE_UNWATCHED: 520 | filtered_downloaded = filter_shows_with_one_unwatched(filtered_downloaded, show_map) 521 | filtered_not_downloaded = filter_shows_with_one_unwatched(filtered_not_downloaded, show_map) 522 | 523 | # Print results 524 | if not filtered_downloaded and not filtered_not_downloaded: 525 | print(BLUE + f"No finales aired in the last {RECENT_DAYS} days (or all were skipped by genre, label, and unwatched condition)." + RESET) 526 | else: 527 | if filtered_downloaded: 528 | print(GREEN + f"=== Downloaded Finales in the Last {RECENT_DAYS} Days ({len(filtered_downloaded)}) ===" + RESET) 529 | for finale in filtered_downloaded: 530 | if len(finale) == 9: 531 | title, snum, enum, ep_title, air_date, tmdb_id, imdb_id, monitored, is_future = finale 532 | if is_future: 533 | line = (f"- {title}: Season {snum} Episode {enum} '{ep_title}' " 534 | f"{BLUE}will air on {air_date}{RESET} | TMDb ID: {tmdb_id} | IMDb ID: {imdb_id}") 535 | else: 536 | line = (f"- {title}: Season {snum} Episode {enum} '{ep_title}' aired on {air_date} " 537 | f"| TMDb ID: {tmdb_id} | IMDb ID: {imdb_id}") 538 | elif len(finale) == 8: 539 | title, snum, enum, ep_title, air_date, tmdb_id, imdb_id, monitored = finale 540 | line = (f"- {title}: Season {snum} Episode {enum} '{ep_title}' aired on {air_date} " 541 | f"| TMDb ID: {tmdb_id} | IMDb ID: {imdb_id}") 542 | if not monitored and not SKIP_UNMONITORED: 543 | line += f" {BLUE}(UNMONITORED){RESET}" 544 | print(line) 545 | 546 | if filtered_not_downloaded: 547 | print(ORANGE + f"\n=== Not Downloaded Finales in the Last {RECENT_DAYS} Days ({len(filtered_not_downloaded)}) ===" + RESET) 548 | for finale in filtered_not_downloaded: 549 | if len(finale) == 9: 550 | title, snum, enum, ep_title, air_date, tmdb_id, imdb_id, monitored, is_future = finale 551 | if is_future: 552 | line = (f"- {title}: Season {snum} Episode {enum} '{ep_title}' " 553 | f"{BLUE}will air on {air_date}{RESET} | TMDb ID: {tmdb_id} | IMDb ID: {imdb_id}") 554 | else: 555 | line = (f"- {title}: Season {snum} Episode {enum} '{ep_title}' aired on {air_date} " 556 | f"| TMDb ID: {tmdb_id} | IMDb ID: {imdb_id}") 557 | elif len(finale) == 8: 558 | title, snum, enum, ep_title, air_date, tmdb_id, imdb_id, monitored = finale 559 | line = (f"- {title}: Season {snum} Episode {enum} '{ep_title}' aired on {air_date} " 560 | f"| TMDb ID: {tmdb_id} | IMDb ID: {imdb_id}") 561 | if not monitored and not SKIP_UNMONITORED: 562 | line += f" {BLUE}(UNMONITORED){RESET}" 563 | print(line) 564 | 565 | print() 566 | print("\n=== Label Operations ===") 567 | # Label logic 568 | handle_label_logic(filtered_downloaded) 569 | 570 | end_time = time.time() 571 | elapsed_seconds = int(end_time - start_time) # Truncate decimals 572 | formatted_duration = str(datetime.timedelta(seconds=elapsed_seconds)) 573 | print(f"Runtime: {formatted_duration}\n") 574 | --------------------------------------------------------------------------------