├── 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 | 
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 | 
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 | [](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 |
--------------------------------------------------------------------------------