From 532da9ca085d1d344a0c6f66021c6233bd8c7bc2 Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Wed, 18 Jun 2025 08:14:38 +0200 Subject: [PATCH 01/10] Refactor repair.py for improved logging, error handling, and messaging. Add automatic search status check in Arr. --- repair.py | 416 ++++++++++++++++++++++++++++++-------------------- shared/arr.py | 6 + 2 files changed, 254 insertions(+), 168 deletions(-) diff --git a/repair.py b/repair.py index 81dfd4c..62756e8 100644 --- a/repair.py +++ b/repair.py @@ -1,168 +1,248 @@ -import os -import argparse -import time -import traceback -from shared.debrid import validateRealdebridMountTorrentsPath, validateTorboxMountTorrentsPath -from shared.arr import Sonarr, Radarr -from shared.discord import discordUpdate, discordError -from shared.shared import repair, realdebrid, torbox, intersperse, ensureTuple -from datetime import datetime - -def parseInterval(intervalStr): - """Parse a smart interval string (e.g., '1w2d3h4m5s') into seconds.""" - if not intervalStr: - return 0 - totalSeconds = 0 - timeDict = {'w': 604800, 'd': 86400, 'h': 3600, 'm': 60, 's': 1} - currentNumber = '' - for char in intervalStr: - if char.isdigit(): - currentNumber += char - elif char in timeDict and currentNumber: - totalSeconds += int(currentNumber) * timeDict[char] - currentNumber = '' - return totalSeconds -# Parse arguments for dry run, no confirm options, and optional intervals -parser = argparse.ArgumentParser(description='Repair broken symlinks or missing files.') -parser.add_argument('--dry-run', action='store_true', help='Perform a dry run without making any changes.') -parser.add_argument('--no-confirm', action='store_true', help='Execute without confirmation prompts.') -parser.add_argument('--repair-interval', type=str, default=repair['repairInterval'], help='Optional interval in smart format (e.g. 1h2m3s) to wait between repairing each media file.') -parser.add_argument('--run-interval', type=str, default=repair['runInterval'], help='Optional interval in smart format (e.g. 1w2d3h4m5s) to run the repair process.') -parser.add_argument('--mode', type=str, choices=['symlink', 'file'], default='symlink', help='Choose repair mode: `symlink` or `file`. `symlink` to repair broken symlinks and `file` to repair missing files.') -parser.add_argument('--season-packs', action='store_true', help='Upgrade to season-packs when a non-season-pack is found. Only applicable in symlink mode.') -parser.add_argument('--include-unmonitored', action='store_true', help='Include unmonitored media in the repair process') -args = parser.parse_args() - -_print = print - -def print(*values: object): - _print(f"[{datetime.now()}] [{args.mode}]", *values) - -if not args.repair_interval and not args.run_interval: - print("Running repair once") -else: - print(f"Running repair{' once every ' + args.run_interval if args.run_interval else ''}{', and waiting ' + args.repair_interval + ' between each repair.' if args.repair_interval else '.'}") - -try: - repairIntervalSeconds = parseInterval(args.repair_interval) -except Exception as e: - print(f"Invalid interval format for repair interval: {args.repair_interval}") - exit(1) - -try: - runIntervalSeconds = parseInterval(args.run_interval) -except Exception as e: - print(f"Invalid interval format for run interval: {args.run_interval}") - exit(1) - -def main(): - if unsafe(): - print("One or both debrid services are not working properly. Skipping repair.") - discordError(f"[{args.mode}] One or both debrid services are not working properly. Skipping repair.") - return - - print("Collecting media...") - sonarr = Sonarr() - radarr = Radarr() - sonarrMedia = [(sonarr, media) for media in sonarr.getAll() if args.include_unmonitored or media.anyMonitoredChildren] - radarrMedia = [(radarr, media) for media in radarr.getAll() if args.include_unmonitored or media.anyMonitoredChildren] - print("Finished collecting media.") - - for arr, media in intersperse(sonarrMedia, radarrMedia): - try: - if unsafe(): - print("One or both debrid services are not working properly. Skipping repair.") - discordError(f"[{args.mode}] One or both debrid services are not working properly. Skipping repair.") - return - - getItems = lambda media, childId: arr.getFiles(media=media, childId=childId) if args.mode == 'symlink' else arr.getHistory(media=media, childId=childId, includeGrandchildDetails=True) - childrenIds = media.childrenIds if args.include_unmonitored else media.monitoredChildrenIds - - for childId in childrenIds: - brokenItems = [] - childItems = list(getItems(media=media, childId=childId)) - - for item in childItems: - if args.mode == 'symlink': - fullPath = item.path - if os.path.islink(fullPath): - destinationPath = os.readlink(fullPath) - if ((realdebrid['enabled'] and destinationPath.startswith(realdebrid['mountTorrentsPath']) and not os.path.exists(destinationPath)) or - (torbox['enabled'] and destinationPath.startswith(torbox['mountTorrentsPath']) and not os.path.exists(os.path.realpath(fullPath)))): - brokenItems.append(os.path.realpath(fullPath)) - else: # file mode - if item.reason == 'MissingFromDisk' and item.parentId not in media.fullyAvailableChildrenIds: - brokenItems.append(item.sourceTitle) - - if brokenItems: - print("Title:", media.title) - print("Movie ID/Season Number:", childId) - print("Broken items:") - [print(item) for item in brokenItems] - print() - if args.dry_run or args.no_confirm or input("Do you want to delete and re-grab? (y/n): ").lower() == 'y': - if not args.dry_run: - discordUpdate(f"[{args.mode}] Repairing {media.title}: {childId}") - if args.mode == 'symlink': - print("Deleting files:") - [print(item.path) for item in childItems] - results = arr.deleteFiles(childItems) - print("Re-monitoring") - media = arr.get(media.id) - media.setChildMonitored(childId, False) - arr.put(media) - media.setChildMonitored(childId, True) - arr.put(media) - print("Searching for new files") - results = arr.automaticSearch(media, childId) - print(results) - - if repairIntervalSeconds > 0: - time.sleep(repairIntervalSeconds) - else: - print("Skipping") - print() - elif args.mode == 'symlink': - realPaths = [os.path.realpath(item.path) for item in childItems] - parentFolders = set(os.path.dirname(path) for path in realPaths) - if childId in media.fullyAvailableChildrenIds and len(parentFolders) > 1: - print("Title:", media.title) - print("Movie ID/Season Number:", childId) - print("Non-season-pack folders:") - [print(parentFolder) for parentFolder in parentFolders] - print() - if args.season_packs: - print("Searching for season-pack") - results = arr.automaticSearch(media, childId) - print(results) - - if repairIntervalSeconds > 0: - time.sleep(repairIntervalSeconds) - - except Exception: - e = traceback.format_exc() - - print(f"An error occurred while processing {media.title}: {e}") - discordError(f"[{args.mode}] An error occurred while processing {media.title}", e) - - print("Repair complete") - discordUpdate(f"[{args.mode}] Repair complete") - -def unsafe(): - return (args.mode == 'symlink' and - ((realdebrid['enabled'] and not ensureTuple(validateRealdebridMountTorrentsPath())[0]) or - (torbox['enabled'] and not ensureTuple(validateTorboxMountTorrentsPath())[0]))) - -if runIntervalSeconds > 0: - while True: - try: - main() - time.sleep(runIntervalSeconds) - except Exception: - e = traceback.format_exc() - - print(f"An error occurred in the main loop: {e}") - discordError(f"[{args.mode}] An error occurred in the main loop", e) - time.sleep(runIntervalSeconds) # Still wait before retrying -else: - main() +import os +import argparse +import asyncio +import time +import traceback +import threading +from shared.debrid import validateRealdebridMountTorrentsPath, validateTorboxMountTorrentsPath +from shared.arr import Sonarr, Radarr +from shared.discord import discordUpdate as _discordUpdate, discordError as _discordError +from shared.shared import repair, realdebrid, torbox, intersperse, ensureTuple +from datetime import datetime + +def parseInterval(intervalStr): + """Parse a smart interval string (e.g., '1w2d3h4m5s') into seconds.""" + if not intervalStr: + return 0 + totalSeconds = 0 + timeDict = {'w': 604800, 'd': 86400, 'h': 3600, 'm': 60, 's': 1} + currentNumber = '' + for char in intervalStr: + if char.isdigit(): + currentNumber += char + elif char in timeDict and currentNumber: + totalSeconds += int(currentNumber) * timeDict[char] + currentNumber = '' + return totalSeconds + +async def check_automatic_search_status(arr, command_id: int, media_title: str, wait_seconds: int = 30, max_attempts: int = 3): + """ + Check the automatic search status up to max_attempts, waiting wait_seconds between each check. + Stops early if search_successful is no longer None. + """ + for attempt in range(0, max_attempts): + await asyncio.sleep(wait_seconds) + search_successful, message, exception = arr.checkAutomaticSearchStatus(command_id) + + if search_successful is True: + success_msg = f"Search for {media_title} succeeded: {message}" + print(success_msg, level="SUCCESS") + discordUpdate(success_msg) + return + elif search_successful is False: + error_msg = f"Search for {media_title} failed: {message} {exception}" + print(error_msg, level="ERROR") + discordError(error_msg) + return + # If we exit the loop, the status was still None after max_attempts + print(f"Search status for {media_title} still unknown after {max_attempts*wait_seconds} seconds. Not checking anymore.", level="WARNING") + +def run_async_in_thread(coro): + """ + Run an async coroutine in a new thread with its own event loop. + """ + def thread_target(): + # Each thread needs its own event loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(coro) + loop.close() + + thread = threading.Thread(target=thread_target) + thread.daemon = True # Optional: thread won’t block program exit + thread.start() + return thread + +# Parse arguments for dry run, no confirm options, and optional intervals +parser = argparse.ArgumentParser(description='Repair broken symlinks or missing files.') +parser.add_argument('--dry-run', action='store_true', help='Perform a dry run without making any changes.') +parser.add_argument('--no-confirm', action='store_true', help='Execute without confirmation prompts.') +parser.add_argument('--repair-interval', type=str, default=repair['repairInterval'], help='Optional interval in smart format (e.g. 1h2m3s) to wait between repairing each media file.') +parser.add_argument('--run-interval', type=str, default=repair['runInterval'], help='Optional interval in smart format (e.g. 1w2d3h4m5s) to run the repair process.') +parser.add_argument('--mode', type=str, choices=['symlink', 'file'], default='symlink', help='Choose repair mode: `symlink` or `file`. `symlink` to repair broken symlinks and `file` to repair missing files.') +parser.add_argument('--season-packs', action='store_true', help='Upgrade to season-packs when a non-season-pack is found. Only applicable in symlink mode.') +parser.add_argument('--include-unmonitored', action='store_true', help='Include unmonitored media in the repair process') +args = parser.parse_args() + +_print = print + +def print(*values: object, level: str = "INFO"): + prefix = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] [{args.mode}] [{level}] " + _print(prefix, *values) + +def print_section(title: str, char: str = "="): + """Print a section header.""" + line = char * (len(title) + 4) + print() + print(line) + print(f" {title.upper()}") + print(line) + print() + +def discordUpdate(title: str, message: str = None): + return _discordUpdate(f"[{args.mode}] {title}", message) + +def discordError(title: str, message: str = None): + return _discordError(f"[{args.mode}] {title}", message) + +if not args.repair_interval and not args.run_interval: + print("Running repair once") +else: + print(f"Running repair{' once every ' + args.run_interval if args.run_interval else ''}{', and waiting ' + args.repair_interval + ' between each repair.' if args.repair_interval else '.'}") + +try: + repairIntervalSeconds = parseInterval(args.repair_interval) +except Exception as e: + print(f"Invalid interval format for repair interval: {args.repair_interval}") + exit(1) + +try: + runIntervalSeconds = parseInterval(args.run_interval) +except Exception as e: + print(f"Invalid interval format for run interval: {args.run_interval}") + exit(1) + +def main(): + print_section("Starting Repair Process") + if args.dry_run: + print("DRY RUN: No changes will be made", level="WARNING") + if unsafe(): + error_msg = "One or both debrid services are not working properly. Skipping repair." + print(error_msg, level="ERROR") + discordError(error_msg) + return + + print("Collecting media from Sonarr and Radarr...") + sonarr = Sonarr() + radarr = Radarr() + sonarrMedia = [(sonarr, media) for media in sonarr.getAll() if args.include_unmonitored or media.anyMonitoredChildren] + radarrMedia = [(radarr, media) for media in radarr.getAll() if args.include_unmonitored or media.anyMonitoredChildren] + print(f"✓ Collected {len(sonarrMedia)} Sonarr items and {len(radarrMedia)} Radarr items", level="SUCCESS") + + season_pack_pending_messages = [] + fixed_broken_items = False + + for arr, media in intersperse(sonarrMedia, radarrMedia): + try: + if unsafe(): + error_msg = "One or more debrid services are not working properly. Aborting repair." + print(error_msg, level="ERROR") + discordError(error_msg) + return + + getItems = lambda media, childId: arr.getFiles(media=media, childId=childId) if args.mode == 'symlink' else arr.getHistory(media=media, childId=childId, includeGrandchildDetails=True) + childrenIds = media.childrenIds if args.include_unmonitored else media.monitoredChildrenIds + + for childId in childrenIds: + brokenItems = [] + childItems = list(getItems(media=media, childId=childId)) + + for item in childItems: + if args.mode == 'symlink': + fullPath = item.path + if os.path.islink(fullPath): + destinationPath = os.readlink(fullPath) + if ((realdebrid['enabled'] and destinationPath.startswith(realdebrid['mountTorrentsPath']) and not os.path.exists(destinationPath)) or + (torbox['enabled'] and destinationPath.startswith(torbox['mountTorrentsPath']) and not os.path.exists(os.path.realpath(fullPath)))): + brokenItems.append(os.path.realpath(fullPath)) + else: # file mode + if item.reason == 'MissingFromDisk' and item.parentId not in media.fullyAvailableChildrenIds: + brokenItems.append(item.sourceTitle) + + if brokenItems: + fixed_broken_items = True + msg = f"Repairing {media.title} (ID: {childId})" + msg2 = f"Found {len(brokenItems)} broken items:" + print_section(msg, "-") + discordUpdate(msg, msg2) + print(msg2) + [print(item) for item in brokenItems] + print() + if args.dry_run or args.no_confirm or input("Do you want to delete and re-grab? (y/n): ").lower() == 'y': + if not args.dry_run: + if args.mode == 'symlink': + print("Deleting broken symlinks...") + [print(item.path) for item in childItems] + results = arr.deleteFiles(childItems) + print("Re-monitoring") + media = arr.get(media.id) + media.setChildMonitored(childId, False) + arr.put(media) + media.setChildMonitored(childId, True) + arr.put(media) + print(f"Searching for replacement files for {media.title}") + results = arr.automaticSearch(media, childId) + run_async_in_thread(check_automatic_search_status(arr, results['id'], media.title)) + + if repairIntervalSeconds > 0: + print(f"Waiting {args.repair_interval} before next repair...") + time.sleep(repairIntervalSeconds) + else: + print("Skipping") + print() + elif args.mode == 'symlink': + realPaths = [os.path.realpath(item.path) for item in childItems] + parentFolders = set(os.path.dirname(path) for path in realPaths) + if childId in media.fullyAvailableChildrenIds and len(parentFolders) > 1: + msg = f"{media.title} has {len(parentFolders)} non-season-pack folders.") + if args.season_packs: + print(msg) + else: + season_pack_pending_messages.append(msg)) + if args.season_packs: + print("Searching for season-pack") + results = arr.automaticSearch(media, childId) + run_async_in_thread(check_automatic_search_status(arr, results['id'], media.title)) + + if repairIntervalSeconds > 0: + print(f"Waiting {args.repair_interval} before next repair...") + time.sleep(repairIntervalSeconds) + + except Exception: + e = traceback.format_exc() + error_msg = f"An error occurred while processing {media.title}: " + print(error_msg + e) + discordError(error_msg, e) + + if not args.season_packs and season_pack_pending_messages: + print_section("Season Packs Start") + print("The following media has non season-pack") + print("Run the script with --season-packs arguemnt to upgrade to season-pack") + for message in season_pack_pending_messages: + print(message) + print_section("Season Packs End") + + msg = "Repair complete" + (" with no broken items found" if not fixed_broken_items else "") + print_section(msg) + discordUpdate(msg) + +def unsafe(): + return (args.mode == 'symlink' and + ((realdebrid['enabled'] and not ensureTuple(validateRealdebridMountTorrentsPath())[0]) or + (torbox['enabled'] and not ensureTuple(validateTorboxMountTorrentsPath())[0]))) + +if runIntervalSeconds > 0: + while True: + try: + main() + print("Run Interval: Waiting for " + args.run_interval + " before next run...") + time.sleep(runIntervalSeconds) + except Exception: + e = traceback.format_exc() + + error_msg = "An error occurred in the main loop: " + print(error_msg + e) + discordError(error_msg, e) + time.sleep(runIntervalSeconds) # Still wait before retrying +else: + main() diff --git a/shared/arr.py b/shared/arr.py index a63418e..27c94ac 100644 --- a/shared/arr.py +++ b/shared/arr.py @@ -334,6 +334,12 @@ def automaticSearch(self, media: Media, childId: int): def _automaticSearchJson(self, media: Media, childId: int): pass + + def checkAutomaticSearchStatus(self, commandId: int): + response = retryRequest(lambda: requests.get(f"{self.host}/api/v3/command/{commandId}?apiKey={self.apiKey}")) + data = response.json() + success = True if (status := data.get("status")) == "completed" else False if status == "failed" else None + return success, data.get("message", ""), data.get("exception") class Sonarr(Arr): host = sonarr['host'] From ff62dfa91019f46d53dfaf986404bce758a761a0 Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Wed, 18 Jun 2025 23:14:18 +0200 Subject: [PATCH 02/10] Typo and consistency improvement from code review Co-authored-by: westsurname <155189104+westsurname@users.noreply.github.com> --- repair.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repair.py b/repair.py index 62756e8..c10b022 100644 --- a/repair.py +++ b/repair.py @@ -217,7 +217,7 @@ def main(): if not args.season_packs and season_pack_pending_messages: print_section("Season Packs Start") print("The following media has non season-pack") - print("Run the script with --season-packs arguemnt to upgrade to season-pack") + print("Run the script with --season-packs argument to upgrade to season-pack") for message in season_pack_pending_messages: print(message) print_section("Season Packs End") @@ -235,7 +235,7 @@ def unsafe(): while True: try: main() - print("Run Interval: Waiting for " + args.run_interval + " before next run...") + print(f"Waiting for {args.run_interval} before next run...") time.sleep(runIntervalSeconds) except Exception: e = traceback.format_exc() From 8dd13a5da7c680447c2a6792fcaa9d4e5da68461 Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Thu, 19 Jun 2025 10:29:54 +0200 Subject: [PATCH 03/10] Overall Changed to camelCase Other Minor changes Season Packs Optimized section Added empty prints for style Fixed dry run not applied checkAutomaticSearchStatus Removed discordUpdate on search succesful as it's misleading since it doesn't really tell if media was grabbed. Add 0 reports downloaded in message detection, if True send ErrorUpdate to discord. --- repair.py | 90 +++++++++++++++++++++++++++++---------------------- shared/arr.py | 8 +++-- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/repair.py b/repair.py index c10b022..e734283 100644 --- a/repair.py +++ b/repair.py @@ -4,6 +4,7 @@ import time import traceback import threading +from collections import defaultdict from shared.debrid import validateRealdebridMountTorrentsPath, validateTorboxMountTorrentsPath from shared.arr import Sonarr, Radarr from shared.discord import discordUpdate as _discordUpdate, discordError as _discordError @@ -25,40 +26,42 @@ def parseInterval(intervalStr): currentNumber = '' return totalSeconds -async def check_automatic_search_status(arr, command_id: int, media_title: str, wait_seconds: int = 30, max_attempts: int = 3): +async def checkAutomaticSearchStatus(arr, commandId: int, mediaTitle: str, seasonNumber: int, waitSeconds: int = 30, maxAttempts: int = 3): """ Check the automatic search status up to max_attempts, waiting wait_seconds between each check. Stops early if search_successful is no longer None. """ - for attempt in range(0, max_attempts): - await asyncio.sleep(wait_seconds) - search_successful, message, exception = arr.checkAutomaticSearchStatus(command_id) - - if search_successful is True: - success_msg = f"Search for {media_title} succeeded: {message}" - print(success_msg, level="SUCCESS") - discordUpdate(success_msg) + seasonNumber = f"(Season {seasonNumber})" if isinstance(arr, Sonarr) else "" + for attempt in range(0, maxAttempts): + await asyncio.sleep(waitSeconds) + searchSuccessful, message = arr.checkAutomaticSearchStatus(commandId) + + if searchSuccessful is True: + if "0 reports downloaded." in message: + return False, message + successMsg = f"Search for {mediaTitle} {seasonNumber} succeeded: {message}" + print(successMsg, level="SUCCESS") return - elif search_successful is False: - error_msg = f"Search for {media_title} failed: {message} {exception}" - print(error_msg, level="ERROR") - discordError(error_msg) + elif searchSuccessful is False: + errorMsg = f"Search for {mediaTitle} {seasonNumber} failed: {message}" + print(errorMsg, level="ERROR") + discordError(errorMsg) return # If we exit the loop, the status was still None after max_attempts - print(f"Search status for {media_title} still unknown after {max_attempts*wait_seconds} seconds. Not checking anymore.", level="WARNING") + print(f"Search status for {mediaTitle} {seasonNumber} still unknown after {maxAttempts*waitSeconds} seconds. Not checking anymore.", level="WARNING") -def run_async_in_thread(coro): +def runAsyncInThread(coro): """ Run an async coroutine in a new thread with its own event loop. """ - def thread_target(): + def threadTarget(): # Each thread needs its own event loop loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(coro) loop.close() - thread = threading.Thread(target=thread_target) + thread = threading.Thread(target=threadTarget) thread.daemon = True # Optional: thread won’t block program exit thread.start() return thread @@ -83,7 +86,6 @@ def print(*values: object, level: str = "INFO"): def print_section(title: str, char: str = "="): """Print a section header.""" line = char * (len(title) + 4) - print() print(line) print(f" {title.upper()}") print(line) @@ -99,6 +101,7 @@ def discordError(title: str, message: str = None): print("Running repair once") else: print(f"Running repair{' once every ' + args.run_interval if args.run_interval else ''}{', and waiting ' + args.repair_interval + ' between each repair.' if args.repair_interval else '.'}") +print() try: repairIntervalSeconds = parseInterval(args.repair_interval) @@ -128,9 +131,10 @@ def main(): sonarrMedia = [(sonarr, media) for media in sonarr.getAll() if args.include_unmonitored or media.anyMonitoredChildren] radarrMedia = [(radarr, media) for media in radarr.getAll() if args.include_unmonitored or media.anyMonitoredChildren] print(f"✓ Collected {len(sonarrMedia)} Sonarr items and {len(radarrMedia)} Radarr items", level="SUCCESS") + print() - season_pack_pending_messages = [] fixed_broken_items = False + season_pack_pending_messages = defaultdict(lambda: defaultdict(list)) for arr, media in intersperse(sonarrMedia, radarrMedia): try: @@ -161,17 +165,16 @@ def main(): if brokenItems: fixed_broken_items = True - msg = f"Repairing {media.title} (ID: {childId})" + msg = f"Starting repair for {media.title} (Movie ID / Season Number: {childId})" msg2 = f"Found {len(brokenItems)} broken items:" print_section(msg, "-") - discordUpdate(msg, msg2) print(msg2) [print(item) for item in brokenItems] - print() if args.dry_run or args.no_confirm or input("Do you want to delete and re-grab? (y/n): ").lower() == 'y': if not args.dry_run: + discordUpdate(msg, msg2) if args.mode == 'symlink': - print("Deleting broken symlinks...") + print("Deleting symlinks...") [print(item.path) for item in childItems] results = arr.deleteFiles(childItems) print("Re-monitoring") @@ -182,7 +185,7 @@ def main(): arr.put(media) print(f"Searching for replacement files for {media.title}") results = arr.automaticSearch(media, childId) - run_async_in_thread(check_automatic_search_status(arr, results['id'], media.title)) + runAsyncInThread(checkAutomaticSearchStatus(arr, results['id'], media.title, childId)) if repairIntervalSeconds > 0: print(f"Waiting {args.repair_interval} before next repair...") @@ -194,19 +197,21 @@ def main(): realPaths = [os.path.realpath(item.path) for item in childItems] parentFolders = set(os.path.dirname(path) for path in realPaths) if childId in media.fullyAvailableChildrenIds and len(parentFolders) > 1: - msg = f"{media.title} has {len(parentFolders)} non-season-pack folders.") - if args.season_packs: - print(msg) - else: - season_pack_pending_messages.append(msg)) - if args.season_packs: - print("Searching for season-pack") - results = arr.automaticSearch(media, childId) - run_async_in_thread(check_automatic_search_status(arr, results['id'], media.title)) + if not args.season_packs: + season_pack_pending_messages[media.title][childId].extend(realPaths) + elif args.season_packs: + [print(path) for path in realPaths] + print_section(f"Searching for season-pack for {media.title} (Season {childId})", "-") + if not args.dry_run: + results = arr.automaticSearch(media, childId) + runAsyncInThread(checkAutomaticSearchStatus(arr, results['id'], media.title, childId)) - if repairIntervalSeconds > 0: - print(f"Waiting {args.repair_interval} before next repair...") - time.sleep(repairIntervalSeconds) + if repairIntervalSeconds > 0: + print(f"Waiting {args.repair_interval} before next repair...") + time.sleep(repairIntervalSeconds) + else: + print("Skipping") + print() except Exception: e = traceback.format_exc() @@ -216,10 +221,17 @@ def main(): if not args.season_packs and season_pack_pending_messages: print_section("Season Packs Start") - print("The following media has non season-pack") + print("The following media has non season-pack folders.") print("Run the script with --season-packs argument to upgrade to season-pack") - for message in season_pack_pending_messages: - print(message) + print() + for title, childIdFolders in season_pack_pending_messages.items(): + print_section(f"Season Packs for {title}") + for childId, folders in childIdFolders.items(): + if folders: + print(f"Season {childId} Non-season-pack folders:") + print('/'.join(folders[0].split('/')[:-2]) + '/') + [print('/'.join(folder.split('/')[-2:])) for folder in folders] + print() print_section("Season Packs End") msg = "Repair complete" + (" with no broken items found" if not fixed_broken_items else "") diff --git a/shared/arr.py b/shared/arr.py index 27c94ac..a9762fd 100644 --- a/shared/arr.py +++ b/shared/arr.py @@ -338,9 +338,13 @@ def _automaticSearchJson(self, media: Media, childId: int): def checkAutomaticSearchStatus(self, commandId: int): response = retryRequest(lambda: requests.get(f"{self.host}/api/v3/command/{commandId}?apiKey={self.apiKey}")) data = response.json() + message = data.get("message", "") success = True if (status := data.get("status")) == "completed" else False if status == "failed" else None - return success, data.get("message", ""), data.get("exception") - + + if success is not None: + return success, message + return None, None + class Sonarr(Arr): host = sonarr['host'] apiKey = sonarr['apiKey'] From ff787f82496c70184f4c3180608c1aa78211cb9d Mon Sep 17 00:00:00 2001 From: westsurname <155189104+westsurname@users.noreply.github.com> Date: Tue, 17 Jun 2025 20:09:06 -0400 Subject: [PATCH 04/10] Fix environs requirement, Remove arm/v7 for compatibility reasons, Other minor changes --- .github/workflows/docker-build-push.yml | 2 +- repair.py | 4 ++-- requirements.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 57665a5..90d4b9d 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -62,7 +62,7 @@ jobs: with: context: . file: ${{ matrix.dockerfile }} - platforms: linux/amd64,linux/arm,linux/arm64 + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/repair.py b/repair.py index e734283..2c4c2c9 100644 --- a/repair.py +++ b/repair.py @@ -200,9 +200,9 @@ def main(): if not args.season_packs: season_pack_pending_messages[media.title][childId].extend(realPaths) elif args.season_packs: - [print(path) for path in realPaths] print_section(f"Searching for season-pack for {media.title} (Season {childId})", "-") - if not args.dry_run: + [print(path) for path in realPaths] + if not args.dry_run and (args.no_confirm or input("Do you want to initiate a search for a season-pack? (y/n): ").lower() == 'y'): results = arr.automaticSearch(media, childId) runAsyncInThread(checkAutomaticSearchStatus(arr, results['id'], media.title, childId)) diff --git a/requirements.txt b/requirements.txt index d877fce..3a5b997 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -environs==9.5.0 #all +environs==14.2.0 #all discord_webhook==1.3.0 #all requests==2.28.1 #all @@ -10,4 +10,4 @@ declxml==1.1.3 #plex_request Werkzeug==3.0.1 #plex_authentication, blackhole flask==3.0.2 #plex_authentication, plex_request -gunicorn==22.0.0 #plex_authentication, plex_request \ No newline at end of file +gunicorn==22.0.0 #plex_authentication, plex_request From f9d38b76f71f2c55b39658f5b386572ba1ecb824 Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Fri, 20 Jun 2025 17:38:08 +0200 Subject: [PATCH 05/10] Fixed bug where season pack would show folders for show episodes that were deleted in media folder but were still in mount --- repair.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/repair.py b/repair.py index 2c4c2c9..6d2426a 100644 --- a/repair.py +++ b/repair.py @@ -150,12 +150,14 @@ def main(): for childId in childrenIds: brokenItems = [] childItems = list(getItems(media=media, childId=childId)) + parentFolders = set() for item in childItems: if args.mode == 'symlink': fullPath = item.path if os.path.islink(fullPath): destinationPath = os.readlink(fullPath) + parentFolders.add(os.path.dirname(destinationPath)) if ((realdebrid['enabled'] and destinationPath.startswith(realdebrid['mountTorrentsPath']) and not os.path.exists(destinationPath)) or (torbox['enabled'] and destinationPath.startswith(torbox['mountTorrentsPath']) and not os.path.exists(os.path.realpath(fullPath)))): brokenItems.append(os.path.realpath(fullPath)) @@ -194,14 +196,13 @@ def main(): print("Skipping") print() elif args.mode == 'symlink': - realPaths = [os.path.realpath(item.path) for item in childItems] - parentFolders = set(os.path.dirname(path) for path in realPaths) if childId in media.fullyAvailableChildrenIds and len(parentFolders) > 1: if not args.season_packs: - season_pack_pending_messages[media.title][childId].extend(realPaths) + season_pack_pending_messages[media.title][childId].extend(parentFolders) elif args.season_packs: print_section(f"Searching for season-pack for {media.title} (Season {childId})", "-") - [print(path) for path in realPaths] + print("Non-season-pack folders:") + [print(path) for path in parentFolders] if not args.dry_run and (args.no_confirm or input("Do you want to initiate a search for a season-pack? (y/n): ").lower() == 'y'): results = arr.automaticSearch(media, childId) runAsyncInThread(checkAutomaticSearchStatus(arr, results['id'], media.title, childId)) @@ -220,19 +221,19 @@ def main(): discordError(error_msg, e) if not args.season_packs and season_pack_pending_messages: - print_section("Season Packs Start") + print_section("Non-season-pack folders") print("The following media has non season-pack folders.") print("Run the script with --season-packs argument to upgrade to season-pack") print() for title, childIdFolders in season_pack_pending_messages.items(): - print_section(f"Season Packs for {title}") + print_section(f"Non-season-pack folders for {title}", "-") for childId, folders in childIdFolders.items(): if folders: - print(f"Season {childId} Non-season-pack folders:") - print('/'.join(folders[0].split('/')[:-2]) + '/') - [print('/'.join(folder.split('/')[-2:])) for folder in folders] + print(f"Season {childId} folders:") + print("Inside",'/'.join(folders[0].split('/')[:-1]) + '/') + [print('/' + folder.split('/')[-1] + '/') for folder in folders] print() - print_section("Season Packs End") + print_section("Non-season-pack folders End") msg = "Repair complete" + (" with no broken items found" if not fixed_broken_items else "") print_section(msg) From 00384e53d91560dea1404f45cc49427a3352d0d3 Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Sat, 21 Jun 2025 02:04:50 +0200 Subject: [PATCH 06/10] camelCase changes from code review Co-authored-by: westsurname <155189104+westsurname@users.noreply.github.com> --- repair.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/repair.py b/repair.py index 6d2426a..61599e4 100644 --- a/repair.py +++ b/repair.py @@ -28,8 +28,8 @@ def parseInterval(intervalStr): async def checkAutomaticSearchStatus(arr, commandId: int, mediaTitle: str, seasonNumber: int, waitSeconds: int = 30, maxAttempts: int = 3): """ - Check the automatic search status up to max_attempts, waiting wait_seconds between each check. - Stops early if search_successful is no longer None. + Check the automatic search status up to maxAttempts, waiting waitSeconds between each check. + Stops early if searchSuccessful is no longer None. """ seasonNumber = f"(Season {seasonNumber})" if isinstance(arr, Sonarr) else "" for attempt in range(0, maxAttempts): @@ -47,7 +47,7 @@ async def checkAutomaticSearchStatus(arr, commandId: int, mediaTitle: str, seaso print(errorMsg, level="ERROR") discordError(errorMsg) return - # If we exit the loop, the status was still None after max_attempts + # If we exit the loop, the status was still None after maxAttempts print(f"Search status for {mediaTitle} {seasonNumber} still unknown after {maxAttempts*waitSeconds} seconds. Not checking anymore.", level="WARNING") def runAsyncInThread(coro): @@ -83,7 +83,7 @@ def print(*values: object, level: str = "INFO"): prefix = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] [{args.mode}] [{level}] " _print(prefix, *values) -def print_section(title: str, char: str = "="): +def printSection(title: str, char: str = "="): """Print a section header.""" line = char * (len(title) + 4) print(line) @@ -116,7 +116,7 @@ def discordError(title: str, message: str = None): exit(1) def main(): - print_section("Starting Repair Process") + printSection("Starting Repair Process") if args.dry_run: print("DRY RUN: No changes will be made", level="WARNING") if unsafe(): @@ -133,8 +133,8 @@ def main(): print(f"✓ Collected {len(sonarrMedia)} Sonarr items and {len(radarrMedia)} Radarr items", level="SUCCESS") print() - fixed_broken_items = False - season_pack_pending_messages = defaultdict(lambda: defaultdict(list)) + fixedBrokenItems = False + seasonPackPendingMessages = defaultdict(lambda: defaultdict(list)) for arr, media in intersperse(sonarrMedia, radarrMedia): try: @@ -166,10 +166,10 @@ def main(): brokenItems.append(item.sourceTitle) if brokenItems: - fixed_broken_items = True + fixedBrokenItems = True msg = f"Starting repair for {media.title} (Movie ID / Season Number: {childId})" msg2 = f"Found {len(brokenItems)} broken items:" - print_section(msg, "-") + printSection(msg, "-") print(msg2) [print(item) for item in brokenItems] if args.dry_run or args.no_confirm or input("Do you want to delete and re-grab? (y/n): ").lower() == 'y': @@ -198,9 +198,9 @@ def main(): elif args.mode == 'symlink': if childId in media.fullyAvailableChildrenIds and len(parentFolders) > 1: if not args.season_packs: - season_pack_pending_messages[media.title][childId].extend(parentFolders) - elif args.season_packs: - print_section(f"Searching for season-pack for {media.title} (Season {childId})", "-") + seasonPackPendingMessages[media.title][childId].extend(parentFolders) + else: + printSection(f"Searching for season-pack for {media.title} (Season {childId})", "-") print("Non-season-pack folders:") [print(path) for path in parentFolders] if not args.dry_run and (args.no_confirm or input("Do you want to initiate a search for a season-pack? (y/n): ").lower() == 'y'): @@ -220,23 +220,23 @@ def main(): print(error_msg + e) discordError(error_msg, e) - if not args.season_packs and season_pack_pending_messages: - print_section("Non-season-pack folders") + if not args.season_packs and seasonPackPendingMessages: + printSection("Non-season-pack folders") print("The following media has non season-pack folders.") print("Run the script with --season-packs argument to upgrade to season-pack") print() - for title, childIdFolders in season_pack_pending_messages.items(): - print_section(f"Non-season-pack folders for {title}", "-") + for title, childIdFolders in seasonPackPendingMessages.items(): + printSection(f"Non-season-pack folders for {title}", "-") for childId, folders in childIdFolders.items(): if folders: print(f"Season {childId} folders:") print("Inside",'/'.join(folders[0].split('/')[:-1]) + '/') [print('/' + folder.split('/')[-1] + '/') for folder in folders] print() - print_section("Non-season-pack folders End") + printSection("Non-season-pack folders End") - msg = "Repair complete" + (" with no broken items found" if not fixed_broken_items else "") - print_section(msg) + msg = "Repair complete" + (" with no broken items found" if not fixedBrokenItems else "") + printSection(msg) discordUpdate(msg) def unsafe(): From 53a2b95529e820949e1b5d1ecfd06505aa1783e4 Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Sat, 21 Jun 2025 02:10:40 +0200 Subject: [PATCH 07/10] Changed checkAutomaticSearchStatus into more generic getCommandResults --- repair.py | 7 ++++++- shared/arr.py | 10 ++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/repair.py b/repair.py index 61599e4..d5ebb7e 100644 --- a/repair.py +++ b/repair.py @@ -34,7 +34,12 @@ async def checkAutomaticSearchStatus(arr, commandId: int, mediaTitle: str, seaso seasonNumber = f"(Season {seasonNumber})" if isinstance(arr, Sonarr) else "" for attempt in range(0, maxAttempts): await asyncio.sleep(waitSeconds) - searchSuccessful, message = arr.checkAutomaticSearchStatus(commandId) + searchStatus = arr.getCommandResults(commandId) + + searchSuccessful = True if (status := searchStatus.get("status")) == "completed" else False if status == "failed" else None + if searchSuccessful is None: + continue + message = searchStatus.get("message", "") if searchSuccessful is True: if "0 reports downloaded." in message: diff --git a/shared/arr.py b/shared/arr.py index a9762fd..bcaa7f9 100644 --- a/shared/arr.py +++ b/shared/arr.py @@ -335,15 +335,9 @@ def automaticSearch(self, media: Media, childId: int): def _automaticSearchJson(self, media: Media, childId: int): pass - def checkAutomaticSearchStatus(self, commandId: int): + def getCommandResults(self, commandId: int): response = retryRequest(lambda: requests.get(f"{self.host}/api/v3/command/{commandId}?apiKey={self.apiKey}")) - data = response.json() - message = data.get("message", "") - success = True if (status := data.get("status")) == "completed" else False if status == "failed" else None - - if success is not None: - return success, message - return None, None + return response.json() class Sonarr(Arr): host = sonarr['host'] From caab2cf0021370190d767fa41741694fa52aef5c Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Sat, 21 Jun 2025 02:29:35 +0200 Subject: [PATCH 08/10] Reapplied changes from merge that were discarded, added cleaner solution for season id/movie id, changed parentFolders to use realpath --- repair.py | 53 ++++++++++++++++++++++++++--------------------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/repair.py b/repair.py index d5ebb7e..492bd53 100644 --- a/repair.py +++ b/repair.py @@ -26,12 +26,11 @@ def parseInterval(intervalStr): currentNumber = '' return totalSeconds -async def checkAutomaticSearchStatus(arr, commandId: int, mediaTitle: str, seasonNumber: int, waitSeconds: int = 30, maxAttempts: int = 3): +async def checkAutomaticSearchStatus(arr, commandId: int, mediaTitle: str, mediaDescriptor: str, waitSeconds: int = 30, maxAttempts: int = 3): """ Check the automatic search status up to maxAttempts, waiting waitSeconds between each check. Stops early if searchSuccessful is no longer None. """ - seasonNumber = f"(Season {seasonNumber})" if isinstance(arr, Sonarr) else "" for attempt in range(0, maxAttempts): await asyncio.sleep(waitSeconds) searchStatus = arr.getCommandResults(commandId) @@ -44,16 +43,16 @@ async def checkAutomaticSearchStatus(arr, commandId: int, mediaTitle: str, seaso if searchSuccessful is True: if "0 reports downloaded." in message: return False, message - successMsg = f"Search for {mediaTitle} {seasonNumber} succeeded: {message}" + successMsg = f"Search for {mediaTitle} {mediaDescriptor} succeeded: {message}" print(successMsg, level="SUCCESS") return elif searchSuccessful is False: - errorMsg = f"Search for {mediaTitle} {seasonNumber} failed: {message}" + errorMsg = f"Search for {mediaTitle} {mediaDescriptor} failed: {message}" print(errorMsg, level="ERROR") discordError(errorMsg) return # If we exit the loop, the status was still None after maxAttempts - print(f"Search status for {mediaTitle} {seasonNumber} still unknown after {maxAttempts*waitSeconds} seconds. Not checking anymore.", level="WARNING") + print(f"Search status for {mediaTitle} {mediaDescriptor} still unknown after {maxAttempts*waitSeconds} seconds. Not checking anymore.", level="WARNING") def runAsyncInThread(coro): """ @@ -156,13 +155,14 @@ def main(): brokenItems = [] childItems = list(getItems(media=media, childId=childId)) parentFolders = set() + mediaDescriptor = f"(Season {childId})" if isinstance(arr, Sonarr) else f"(Movie ID: {childId})" for item in childItems: if args.mode == 'symlink': fullPath = item.path if os.path.islink(fullPath): destinationPath = os.readlink(fullPath) - parentFolders.add(os.path.dirname(destinationPath)) + parentFolders.add(os.path.dirname(os.path.realpath(fullPath))) if ((realdebrid['enabled'] and destinationPath.startswith(realdebrid['mountTorrentsPath']) and not os.path.exists(destinationPath)) or (torbox['enabled'] and destinationPath.startswith(torbox['mountTorrentsPath']) and not os.path.exists(os.path.realpath(fullPath)))): brokenItems.append(os.path.realpath(fullPath)) @@ -172,31 +172,30 @@ def main(): if brokenItems: fixedBrokenItems = True - msg = f"Starting repair for {media.title} (Movie ID / Season Number: {childId})" + msg = f"Repairing {media.title} {mediaDescriptor}" msg2 = f"Found {len(brokenItems)} broken items:" printSection(msg, "-") print(msg2) [print(item) for item in brokenItems] - if args.dry_run or args.no_confirm or input("Do you want to delete and re-grab? (y/n): ").lower() == 'y': - if not args.dry_run: - discordUpdate(msg, msg2) - if args.mode == 'symlink': - print("Deleting symlinks...") - [print(item.path) for item in childItems] - results = arr.deleteFiles(childItems) - print("Re-monitoring") - media = arr.get(media.id) - media.setChildMonitored(childId, False) - arr.put(media) - media.setChildMonitored(childId, True) - arr.put(media) - print(f"Searching for replacement files for {media.title}") - results = arr.automaticSearch(media, childId) - runAsyncInThread(checkAutomaticSearchStatus(arr, results['id'], media.title, childId)) - - if repairIntervalSeconds > 0: - print(f"Waiting {args.repair_interval} before next repair...") - time.sleep(repairIntervalSeconds) + if not args.dry_run and (args.no_confirm or input("Do you want to delete and re-grab? (y/n): ").lower() == 'y'): + discordUpdate(msg, msg2) + if args.mode == 'symlink': + print("Deleting files:") + [print(item.path) for item in childItems] + results = arr.deleteFiles(childItems) + print("Re-monitoring") + media = arr.get(media.id) + media.setChildMonitored(childId, False) + arr.put(media) + media.setChildMonitored(childId, True) + arr.put(media) + print(f"Searching for replacement files for {media.title}") + results = arr.automaticSearch(media, childId) + runAsyncInThread(checkAutomaticSearchStatus(arr, results['id'], media.title, mediaDescriptor)) + + if repairIntervalSeconds > 0: + print(f"Waiting {args.repair_interval} before next repair...") + time.sleep(repairIntervalSeconds) else: print("Skipping") print() From 36dc8e26fd6448af5ffdf5d853b2830f1969bee3 Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Sat, 21 Jun 2025 02:36:50 +0200 Subject: [PATCH 09/10] Fixed bad logic in checkAutomaticSearchStatus --- repair.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/repair.py b/repair.py index 492bd53..3547c1e 100644 --- a/repair.py +++ b/repair.py @@ -40,13 +40,11 @@ async def checkAutomaticSearchStatus(arr, commandId: int, mediaTitle: str, media continue message = searchStatus.get("message", "") - if searchSuccessful is True: - if "0 reports downloaded." in message: - return False, message + if searchSuccessful and "0 reports downloaded." not in message: successMsg = f"Search for {mediaTitle} {mediaDescriptor} succeeded: {message}" print(successMsg, level="SUCCESS") return - elif searchSuccessful is False: + else: errorMsg = f"Search for {mediaTitle} {mediaDescriptor} failed: {message}" print(errorMsg, level="ERROR") discordError(errorMsg) From 2265d0af5dd0786c634093e62188836ca3b81df8 Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Sat, 21 Jun 2025 04:50:30 +0200 Subject: [PATCH 10/10] Minor changes --- repair.py | 62 +++++++++++++++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/repair.py b/repair.py index 3547c1e..8de6097 100644 --- a/repair.py +++ b/repair.py @@ -31,24 +31,24 @@ async def checkAutomaticSearchStatus(arr, commandId: int, mediaTitle: str, media Check the automatic search status up to maxAttempts, waiting waitSeconds between each check. Stops early if searchSuccessful is no longer None. """ - for attempt in range(0, maxAttempts): + for _ in range(maxAttempts): await asyncio.sleep(waitSeconds) - searchStatus = arr.getCommandResults(commandId) + result = arr.getCommandResults(commandId) - searchSuccessful = True if (status := searchStatus.get("status")) == "completed" else False if status == "failed" else None - if searchSuccessful is None: + status = result.get("status") + if status not in ["completed", "failed"]: continue - message = searchStatus.get("message", "") + message = result.get("message", "") - if searchSuccessful and "0 reports downloaded." not in message: - successMsg = f"Search for {mediaTitle} {mediaDescriptor} succeeded: {message}" - print(successMsg, level="SUCCESS") - return - else: + if status == "failed" or "0 reports downloaded." in message: errorMsg = f"Search for {mediaTitle} {mediaDescriptor} failed: {message}" print(errorMsg, level="ERROR") discordError(errorMsg) return + else: + successMsg = f"Search for {mediaTitle} {mediaDescriptor} succeeded: {message}" + print(successMsg, level="SUCCESS") + return # If we exit the loop, the status was still None after maxAttempts print(f"Search status for {mediaTitle} {mediaDescriptor} still unknown after {maxAttempts*waitSeconds} seconds. Not checking anymore.", level="WARNING") @@ -63,8 +63,7 @@ def threadTarget(): loop.run_until_complete(coro) loop.close() - thread = threading.Thread(target=threadTarget) - thread.daemon = True # Optional: thread won’t block program exit + thread = threading.Thread(target=threadTarget, daemon=True) thread.start() return thread @@ -82,9 +81,9 @@ def threadTarget(): _print = print def print(*values: object, level: str = "INFO"): - prefix = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] [{args.mode}] [{level}] " + prefix = f"[{datetime.now():%Y-%m-%d %H:%M:%S}] [{args.mode}] [{level}]" _print(prefix, *values) - + def printSection(title: str, char: str = "="): """Print a section header.""" line = char * (len(title) + 4) @@ -197,24 +196,23 @@ def main(): else: print("Skipping") print() - elif args.mode == 'symlink': - if childId in media.fullyAvailableChildrenIds and len(parentFolders) > 1: - if not args.season_packs: - seasonPackPendingMessages[media.title][childId].extend(parentFolders) - else: - printSection(f"Searching for season-pack for {media.title} (Season {childId})", "-") - print("Non-season-pack folders:") - [print(path) for path in parentFolders] - if not args.dry_run and (args.no_confirm or input("Do you want to initiate a search for a season-pack? (y/n): ").lower() == 'y'): - results = arr.automaticSearch(media, childId) - runAsyncInThread(checkAutomaticSearchStatus(arr, results['id'], media.title, childId)) + elif args.mode == 'symlink' and childId in media.fullyAvailableChildrenIds and len(parentFolders) > 1: + if not args.season_packs: + seasonPackPendingMessages[media.title][childId].extend(parentFolders) + else: + printSection(f"Searching for season-pack for {media.title} {mediaDescriptor}", "-") + print("Non-season-pack folders:") + [print(path) for path in parentFolders] + if not args.dry_run and (args.no_confirm or input("Do you want to initiate a search for a season-pack? (y/n): ").lower() == 'y'): + results = arr.automaticSearch(media, childId) + runAsyncInThread(checkAutomaticSearchStatus(arr, results['id'], media.title, mediaDescriptor)) - if repairIntervalSeconds > 0: - print(f"Waiting {args.repair_interval} before next repair...") - time.sleep(repairIntervalSeconds) - else: - print("Skipping") - print() + if repairIntervalSeconds > 0: + print(f"Waiting {args.repair_interval} before next repair...") + time.sleep(repairIntervalSeconds) + else: + print("Skipping") + print() except Exception: e = traceback.format_exc() @@ -260,4 +258,4 @@ def unsafe(): discordError(error_msg, e) time.sleep(runIntervalSeconds) # Still wait before retrying else: - main() + main() \ No newline at end of file