├── .dockerignore ├── requirements.txt ├── .idea └── .gitignore ├── .gitignore ├── apiKeySample.py ├── Dockerfile ├── README.md └── main.py /.dockerignore: -------------------------------------------------------------------------------- 1 | apiKey.py 2 | venv/ 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | guessit 2 | requests 3 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /apiKey.py 2 | /__pycache__ 3 | /not_found_radarr.txt 4 | /not_found_sonarr.txt 5 | /script.log -------------------------------------------------------------------------------- /apiKeySample.py: -------------------------------------------------------------------------------- 1 | # create apiKey.py and mimic this file 2 | aither_key = "AITHER_KEY_HERE" # https://aither.cc/users/YOURUSERNAME/apikeys 3 | radarr_key = "RADARR_KEY_HERE" # radarr -> settings -> general 4 | sonarr_key = "SONARR_KEY_HERE" # sonarr -> settings -> general 5 | radarr_url = "http://IP:PORT" # radarr port typically 7878, local DNS should work if you have it setup, else "localhost" if local machine 6 | sonarr_url = "http://IP:PORT" # sonarr port typically 8989, local DNS should work if you have it setup, else "localhost" if local machine 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 2 | 3 | # Set up a virtual environment to isolate our Python dependencies 4 | RUN python -m venv /venv 5 | ENV PATH="/venv/bin:$PATH" 6 | 7 | # Set the working directory in the container 8 | WORKDIR /aither-exists-check 9 | 10 | # Copy the Python requirements file and install Python dependencies 11 | COPY requirements.txt . 12 | RUN pip install -r requirements.txt 13 | 14 | # Copy the rest of the application's code 15 | COPY . . 16 | 17 | # Set the entry point for the container 18 | ENTRYPOINT ["python", "/aither-exists-check/main.py", "-o", "/output"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aither Exists Check 2 | 3 | A lightweight Python script to check your Radarr and Sonarr media libraries against Aither's uploaded movie torrents. 4 | As this script becomes more granular, and checking against Aither's resolutions, editions etc. it is more and more recommended you *double check* before proceeding to upload! 5 | 6 | ## Features 7 | 8 | - Compare your Radarr and Sonarr libraries with Aither's torrent listings. 9 | - Log missing movies and TV shows from your libraries, respecting banned groups, trumpables, etc. 10 | - Respect Aither's API rate limits. 11 | 12 | ## Prerequisites 13 | 14 | - Python 3.x installed on your system. 15 | - Radarr and/or Sonarr configured and running. 16 | 17 | ## Installation 18 | 19 | 1. Clone this repository: 20 | 21 | ```bash 22 | git clone https://github.com/brah/aither-exists-check.git 23 | ``` 24 | 25 | 2. Navigate to the project directory: 26 | 27 | ```bash 28 | cd aither-exists-check 29 | ``` 30 | 31 | 3. Install the required Python packages: 32 | 33 | ```bash 34 | pip install -r requirements.txt 35 | ``` 36 | 37 | ## Docker 38 | 1. Clone this repository: 39 | ```bash 40 | git clone https://github.com/brah/aither-exists-check.git 41 | ``` 42 | 2. Navigate to the project directory: 43 | ```bash 44 | cd aither-exists-check 45 | ``` 46 | 3. Build the docker image: 47 | ```bash 48 | docker build -t aither-exists-check:latest . 49 | ``` 50 | 4. Run the docker image. Correct the paths below to map correct config file location and output directory. 51 | ```bash 52 | docker run --user 1000:1000 --name aither-exists --rm -it \ 53 | -v ./config/apiKey.py:/aither-exists-check/apiKey.py \ 54 | -v ./output:/output/ \ 55 | aither-exists-check:latest --radarr 56 | ``` 57 | 58 | ## Configuration 59 | 60 | 1. Create a file named `apiKey.py` in the project directory with the following contents - refer to apiKeySample.py: 61 | 62 | ```python 63 | aither_key = "" 64 | radarr_key = "" 65 | sonarr_key = "" 66 | radarr_url = "" 67 | sonarr_url = "" 68 | ``` 69 | 70 | 2. The first time you run the script, you'll be prompted to input your API keys and URLs. The script saves these to `apiKey.py` for future use. 71 | 72 | ## Usage 73 | 74 | To run the script, use one of the following commands: 75 | 76 | - To check the Radarr library: 77 | 78 | ```bash 79 | python main.py --radarr 80 | ``` 81 | 82 | - To check the Sonarr library: 83 | 84 | ```bash 85 | python main.py --sonarr 86 | ``` 87 | 88 | - To check both libraries (default): 89 | 90 | ```bash 91 | python main.py 92 | ``` 93 | 94 | ## Output 95 | 96 | The script generates two output files: 97 | 98 | - `not_found_radarr.txt`: Lists movies in Radarr not found in Aither. 99 | - `not_found_sonarr.txt`: Lists shows in Sonarr not found in Aither. 100 | 101 | ## Logging 102 | 103 | Detailed logs are stored in `script.log`, while concise output is displayed on the console. 104 | 105 | ## Contributors 106 | 107 | Special thanks to those who have contributed: 108 | 109 | - [@DefectiveDev](https://github.com/defectivedev) 110 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import apiKey 3 | import requests 4 | import time 5 | import argparse 6 | from guessit import guessit 7 | import logging 8 | 9 | # Just to sameline the logs while logging to file also 10 | class NoNewlineStreamHandler(logging.StreamHandler): 11 | def emit(self, record): 12 | try: 13 | msg = self.format(record) 14 | stream = self.stream 15 | if record.levelno == logging.INFO and msg.endswith("... "): 16 | stream.write(msg) 17 | else: 18 | stream.write(msg + "\n") 19 | self.flush() 20 | except Exception: 21 | self.handleError(record) 22 | 23 | 24 | # Configurable constants 25 | AITHER_URL = "https://aither.cc" 26 | RADARR_API_SUFFIX = "/api/v3/movie" 27 | SONARR_API_SUFFIX = "/api/v3/series" 28 | NOT_FOUND_FILE_RADARR = "not_found_radarr.txt" 29 | NOT_FOUND_FILE_SONARR = "not_found_sonarr.txt" 30 | 31 | # LOGIC CONSTANT - DO NOT TWEAK !!! 32 | # changing this may break resolution mapping for dvd in search_movie 33 | RESOLUTION_MAP = { 34 | "4320": 1, 35 | "2160": 2, 36 | "1080": 3, 37 | "1080p": 4, 38 | "720": 5, 39 | "576": 6, 40 | "576p": 7, 41 | "480": 8, 42 | "480p": 9, 43 | } 44 | 45 | CATEGORY_MAP = { 46 | "movie": 1, 47 | "tv": 2 48 | } 49 | 50 | TYPE_MAP = { 51 | "FULL DISC": 1, 52 | "REMUX": 2, 53 | "ENCODE": 3, 54 | "WEB-DL": 4, 55 | "WEBRIP": 5, 56 | "HDTV": 6, 57 | "OTHER": 7, 58 | "MOVIE PACK": 10, 59 | } 60 | 61 | # Setup logging 62 | logger = logging.getLogger("customLogger") 63 | logger.setLevel(logging.INFO) 64 | 65 | # Console handler with a simpler format 66 | console_handler = NoNewlineStreamHandler() 67 | console_formatter = logging.Formatter("%(message)s") 68 | console_handler.setFormatter(console_formatter) 69 | logger.addHandler(console_handler) 70 | 71 | # File handler with detailed format 72 | # file_handler = logging.FileHandler("script.log") 73 | # file_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") 74 | # file_handler.setFormatter(file_formatter) 75 | # logger.addHandler(file_handler) 76 | 77 | 78 | # Setup function to prompt user for missing API keys and URLs if critical for the selected mode(s) 79 | def setup(radarr_needed, sonarr_needed): 80 | missing = [] 81 | 82 | if not apiKey.aither_key: 83 | missing.append("Aither API key") 84 | apiKey.aither_key = input("Enter your Aither API key: ") 85 | 86 | if radarr_needed: 87 | if not apiKey.radarr_key: 88 | missing.append("Radarr API key") 89 | apiKey.radarr_key = input("Enter your Radarr API key: ") 90 | if not apiKey.radarr_url: 91 | missing.append("Radarr URL") 92 | apiKey.radarr_url = input( 93 | "Enter your Radarr URL (e.g., http://RADARR_URL:RADARR_PORT): " 94 | ) 95 | 96 | if sonarr_needed: 97 | if not apiKey.sonarr_key: 98 | missing.append("Sonarr API key") 99 | apiKey.sonarr_key = input("Enter your Sonarr API key: ") 100 | if not apiKey.sonarr_url: 101 | missing.append("Sonarr URL") 102 | apiKey.sonarr_url = input( 103 | "Enter your Sonarr URL (e.g., http://SONARR_URL:SONARR_PORT): " 104 | ) 105 | 106 | if missing: 107 | with open("apiKey.py", "w") as f: 108 | f.write(f'aither_key = "{apiKey.aither_key}"\n') 109 | f.write(f'radarr_key = "{apiKey.radarr_key}"\n') 110 | f.write(f'sonarr_key = "{apiKey.sonarr_key}"\n') 111 | f.write(f'radarr_url = "{apiKey.radarr_url}"\n') 112 | f.write(f'sonarr_url = "{apiKey.sonarr_url}"\n') 113 | 114 | # Alert the user about missing non-critical variables 115 | if not radarr_needed and (not apiKey.radarr_key or not apiKey.radarr_url): 116 | logger.warning( 117 | "Radarr API key or URL is missing. Radarr functionality will be limited." 118 | ) 119 | if not sonarr_needed and (not apiKey.sonarr_key or not apiKey.sonarr_url): 120 | logger.warning( 121 | "Sonarr API key or URL is missing. Sonarr functionality will be limited." 122 | ) 123 | 124 | 125 | # Function to get all movies from Radarr 126 | def get_all_movies(session): 127 | radarr_url = apiKey.radarr_url + RADARR_API_SUFFIX 128 | response = session.get(radarr_url, headers={"X-Api-Key": apiKey.radarr_key}) 129 | response.raise_for_status() # Ensure we handle request errors properly 130 | movies = response.json() 131 | return movies 132 | 133 | 134 | # Function to get all shows from Sonarr 135 | def get_all_shows(session): 136 | sonarr_url = apiKey.sonarr_url + SONARR_API_SUFFIX 137 | response = session.get(sonarr_url, headers={"X-Api-Key": apiKey.sonarr_key}) 138 | response.raise_for_status() # Ensure we handle request errors properly 139 | shows = response.json() 140 | return shows 141 | 142 | 143 | # Function to search for a movie in Aither using its TMDB ID + resolution if found 144 | def search_movie(session, movie, movie_resolution, movie_type): 145 | tmdb_id = movie["tmdbId"] 146 | 147 | # build the search url 148 | if movie_resolution is not None: 149 | url = f"{AITHER_URL}/api/torrents/filter?categories[0]={CATEGORY_MAP['movie']}&tmdbId={tmdb_id}&resolutions[0]={movie_resolution}&api_token={apiKey.aither_key}" 150 | else: 151 | url = f"{AITHER_URL}/api/torrents/filter?categories[0]={CATEGORY_MAP['movie']}&tmdbId={tmdb_id}&api_token={apiKey.aither_key}" 152 | 153 | if movie_type: 154 | url += f"&types[0]={movie_type}" 155 | 156 | while True: 157 | response = session.get(url) 158 | if response.status_code == 429: 159 | logger.warning(f"Rate limit exceeded.") 160 | else: 161 | response.raise_for_status() # Raise an exception if the request failed 162 | torrents = response.json()["data"] 163 | return torrents 164 | 165 | 166 | # Function to search for a show in Aither using its TVDB ID 167 | def search_show(session, show): 168 | tvdb_id = show["tvdbId"] 169 | show_resolution = None 170 | # build the search url 171 | if show_resolution is not None: 172 | url = f"{AITHER_URL}/api/torrents/filter?categories[0]={CATEGORY_MAP['tv']}&tvdbId={tvdb_id}&api_token={apiKey.aither_key}" 173 | # url = f"{AITHER_URL}/api/torrents/filter?categories[0]={CATEGORY_MAP['tv']}&tmdbId={tmdb_id}&resolutions[0]={movie_resolution}&api_token={apiKey.aither_key}" 174 | else: 175 | url = f"{AITHER_URL}/api/torrents/filter?categories[0]={CATEGORY_MAP['tv']}&tvdbId={tvdb_id}&api_token={apiKey.aither_key}" 176 | # url = f"{AITHER_URL}/api/torrents/filter?categories[0]={CATEGORY_MAP['tv']}&tmdbId={tmdb_id}&api_token={apiKey.aither_key}" 177 | 178 | # if show_type: 179 | # url += f"&types[0]={show_type}" 180 | 181 | while True: 182 | response = session.get(url) 183 | if response.status_code == 429: 184 | logger.warning(f"Rate limit exceeded.") 185 | else: 186 | response.raise_for_status() # Raise an exception if the request failed 187 | torrents = response.json()["data"] 188 | return torrents 189 | 190 | def get_movie_resolution(movie): 191 | # get resolution from radarr if missing try pull from media info 192 | try: 193 | movie_resolution = movie.get("movieFile").get("quality").get("quality").get("resolution") 194 | # if no resolution like with dvd quality. try parse from mediainfo instead 195 | if not movie_resolution: 196 | mediainfo_resolution = movie.get("movieFile").get("mediaInfo").get("resolution") 197 | width, height = mediainfo_resolution.split("x") 198 | movie_resolution = height 199 | except KeyError: 200 | movie_resolution = None 201 | return movie_resolution 202 | 203 | def get_video_type(source, modifier): 204 | source = (source or '').lower() 205 | modifier = (modifier or '').lower() 206 | 207 | if source == 'bluray': 208 | if modifier == 'remux': 209 | return 'REMUX' 210 | elif modifier == 'full': 211 | return 'FULL DISC' 212 | else: 213 | return 'ENCODE' 214 | elif source == 'dvd': 215 | if modifier == 'remux': 216 | return 'REMUX' 217 | elif modifier == 'full': 218 | return 'FULL DISC' 219 | else: 220 | return 'ENCODE' 221 | elif source in ['webdl', 'web-dl']: 222 | return 'WEB-DL' 223 | elif source in ['webrip', 'web-rip']: 224 | return 'WEBRIP' 225 | elif source == 'hdtv': 226 | return 'HDTV' 227 | else: 228 | return 'OTHER' 229 | 230 | 231 | # Function to process each movie 232 | def process_movie(session, movie, not_found_file, banned_groups): 233 | title = movie["title"] 234 | logger.info(f"Checking {title}... ") 235 | 236 | # verify radarr actually has a file entry if not skip check and save api call 237 | if not "movieFile" in movie: 238 | logger.info( 239 | f"[Skipped: local]. No file found in radarr for {title}" 240 | ) 241 | return 242 | 243 | # skip check if group is banned. 244 | banned_names = [d['name'] for d in banned_groups] 245 | if "releaseGroup" in movie["movieFile"] and \ 246 | movie["movieFile"]["releaseGroup"].casefold() in map(str.casefold, banned_names): 247 | logger.info( 248 | f"[Banned: local] group ({movie['movieFile']['releaseGroup']}) for {title}" 249 | ) 250 | return 251 | 252 | try: 253 | quality_info = movie.get("movieFile").get("quality").get("quality") 254 | source = quality_info.get("source") 255 | modifier = quality_info.get("modifier") 256 | if modifier == "none" and source == "dvd": 257 | release_info = guessit(movie.get("movieFile").get("relativePath")) 258 | modifier = release_info.get("other") 259 | video_type = get_video_type(source, modifier) 260 | aither_type = TYPE_MAP.get(video_type.upper()) 261 | movie_resolution = get_movie_resolution(movie) 262 | aither_resolution = RESOLUTION_MAP.get(str(movie_resolution)) 263 | torrents = search_movie(session, movie, aither_resolution, aither_type) 264 | except Exception as e: 265 | if "429" in str(e): 266 | logger.warning(f"Rate limit exceeded while checking {title}.") 267 | else: 268 | logger.error(f"Error: {str(e)}") 269 | not_found_file.write(f"{title} - Error: {str(e)}\n") 270 | else: 271 | if len(torrents) == 0: 272 | try: 273 | movie_file = movie["movieFile"]["path"] 274 | if movie_file: 275 | logger.info( 276 | f"[{movie_resolution} {video_type}] not found on AITHER" 277 | ) 278 | not_found_file.write(f"{movie_file}\n") 279 | else: 280 | logger.info( 281 | f"[{movie_resolution} {video_type}] not found on AITHER (No media file)" 282 | ) 283 | except KeyError: 284 | logger.info( 285 | f"[{movie_resolution} {video_type}] not found on AITHER (No media file)" 286 | ) 287 | else: 288 | release_info = guessit(torrents[0].get("attributes").get("name")) 289 | if "release_group" in release_info \ 290 | and release_info["release_group"].casefold() in map(str.casefold, banned_names): 291 | logger.info( 292 | f"[Trumpable: Banned] group for {title} [{movie_resolution} {video_type} {release_info['release_group']}] on AITHER" 293 | ) 294 | else : 295 | logger.info( 296 | f"[{movie_resolution} {video_type}] already exists on AITHER" 297 | ) 298 | 299 | 300 | # Function to process each show 301 | def process_show(session, show, not_found_file, banned_groups): 302 | title = show["title"] 303 | tvdb_id = show["tvdbId"] 304 | logger.info(f"Checking {title}... ") 305 | try: 306 | torrents = search_show(session, show) 307 | except Exception as e: 308 | if "429" in str(e): 309 | logger.warning(f"Rate limit exceeded while checking {title}.") 310 | else: 311 | logger.error(f"Error: {str(e)}") 312 | not_found_file.write(f"{title} - Error: {str(e)}\n") 313 | else: 314 | if len(torrents) == 0: 315 | logger.info("Not found in Aither") 316 | not_found_file.write(f"{title} not found in AITHER\n") 317 | else: 318 | logger.info("Found in AITHER") 319 | 320 | # pull banned groups from aither api 321 | def get_banned_groups(session): 322 | logger.info("Fetching banned groups") 323 | 324 | url = f"{AITHER_URL}/api/blacklists/releasegroups?api_token={apiKey.aither_key}" 325 | while True: 326 | response = session.get(url) 327 | if response.status_code == 429: 328 | logger.warning(f"Rate limit exceeded.") 329 | else: 330 | response.raise_for_status() # Raise an exception if the request failed 331 | groups = response.json()["data"] 332 | return groups 333 | 334 | # Main function to handle both Radarr and Sonarr 335 | def main(): 336 | parser = argparse.ArgumentParser( 337 | description="Check Radarr or Sonarr library against Aither" 338 | ) 339 | parser.add_argument("--radarr", action="store_true", help="Check Radarr library") 340 | parser.add_argument("--sonarr", action="store_true", help="Check Sonarr library") 341 | parser.add_argument("-o", "--output-path", required=False, help="Output file path") 342 | parser.add_argument("-s", "--sleep-timer", type=int, default=10, help="Sleep time between calls") 343 | 344 | args = parser.parse_args() 345 | 346 | script_log = "script.log" 347 | if args.output_path is not None: 348 | script_log = os.path.join(os.path.expanduser(args.output_path), script_log) 349 | file_handler = logging.FileHandler(f"{script_log}") 350 | file_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") 351 | file_handler.setFormatter(file_formatter) 352 | logger.addHandler(file_handler) 353 | 354 | radarr_needed = args.radarr or (not args.sonarr and not args.radarr) 355 | sonarr_needed = args.sonarr or (not args.sonarr and not args.radarr) 356 | setup( 357 | radarr_needed=radarr_needed, sonarr_needed=sonarr_needed 358 | ) # Ensure API keys and URLs are set 359 | 360 | if not args.radarr and not args.sonarr: 361 | logger.info("No arguments specified. Running both Radarr and Sonarr checks.\n") 362 | 363 | try: 364 | with requests.Session() as session: 365 | banned_groups = get_banned_groups(session) 366 | if args.radarr or (not args.sonarr and not args.radarr): 367 | if apiKey.radarr_key and apiKey.radarr_url: 368 | movies = get_all_movies(session) 369 | out_radarr = NOT_FOUND_FILE_RADARR 370 | if args.output_path is not None: 371 | out_radarr = os.path.join(os.path.expanduser(args.output_path), NOT_FOUND_FILE_RADARR) 372 | with open( 373 | out_radarr, "w", encoding="utf-8", buffering=1 374 | ) as not_found_file: 375 | for movie in movies: 376 | process_movie(session, movie, not_found_file, banned_groups) 377 | time.sleep(args.sleep_timer) # Respectful delay 378 | else: 379 | logger.warning( 380 | "Skipping Radarr check: Radarr API key or URL is missing.\n" 381 | ) 382 | 383 | if args.sonarr or (not args.sonarr and not args.radarr): 384 | if apiKey.sonarr_key and apiKey.sonarr_url: 385 | shows = get_all_shows(session) 386 | out_sonarr = NOT_FOUND_FILE_SONARR 387 | if args.output_path is not None: 388 | out_sonarr = os.path.join(os.path.expanduser(args.output_path), NOT_FOUND_FILE_SONARR) 389 | with open( 390 | out_sonarr, "w", encoding="utf-8", buffering=1 391 | ) as not_found_file: 392 | for show in shows: 393 | process_show(session, show, not_found_file, banned_groups) 394 | time.sleep(args.sleep_timer) # Respectful delay 395 | else: 396 | logger.warning( 397 | "Skipping Sonarr check: Sonarr API key or URL is missing.\n" 398 | ) 399 | except KeyboardInterrupt: 400 | logger.info("\nProcess interrupted by user. Exiting.\n") 401 | 402 | 403 | if __name__ == "__main__": 404 | main() 405 | --------------------------------------------------------------------------------