From 5513e0c7a899a6d4a4a0144e7890bd54ef71ad81 Mon Sep 17 00:00:00 2001 From: westsurname <155189104+westsurname@users.noreply.github.com> Date: Sun, 22 Dec 2024 16:05:44 -0500 Subject: [PATCH 1/6] Re-search season packs on failure --- blackhole.py | 22 ++++++++++++++++++---- shared/arr.py | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/blackhole.py b/blackhole.py index ec7cb88..c47f990 100644 --- a/blackhole.py +++ b/blackhole.py @@ -295,7 +295,7 @@ async def is_accessible(path, timeout=10): results = await asyncio.gather(*(processTorrent(torrent, file, arr) for torrent in torrents)) if not any(results): - await asyncio.gather(*(fail(torrent, arr) for torrent in torrents)) + await asyncio.gather(*(fail(torrent, arr, isRadarr) for torrent in torrents)) else: for i, constructor in enumerate(torrentConstructors): isLast = (i == len(torrentConstructors) - 1) @@ -304,7 +304,7 @@ async def is_accessible(path, timeout=10): if await processTorrent(torrent, file, arr): break elif isLast: - await fail(torrent, arr) + await fail(torrent, arr, isRadarr) os.remove(file.fileInfo.filePathProcessing) except: @@ -315,25 +315,39 @@ async def is_accessible(path, timeout=10): discordError(f"Error processing {file.fileInfo.filenameWithoutExt}", e) -async def fail(torrent: TorrentBase, arr: Arr): +def isSeasonPack(filename): + # Match patterns like 'S01' or 'Season 1' but not 'S01E01' + return bool(re.search(r'(?:S|Season\s*)(\d{1,2})(?!\s*E\d{2})', filename, re.IGNORECASE)) + +async def fail(torrent: TorrentBase, arr: Arr, isRadarr): _print = globals()['print'] def print(*values: object): _print(f"[{torrent.__class__.__name__}] [{torrent.file.fileInfo.filenameWithoutExt}]", *values) print(f"Failing") + + isSeasonPack = isSeasonPack(torrent.file.fileInfo.filename) torrentHash = torrent.getHash() history = await asyncio.to_thread(arr.getHistory, blackhole['historyPageSize']) items = [item for item in history if (item.torrentInfoHash and item.torrentInfoHash.casefold() == torrentHash.casefold()) or cleanFileName(item.sourceTitle.casefold()) == torrent.file.fileInfo.filenameWithoutExt.casefold()] + if not items: message = "No history items found to mark as failed. Arr will not attempt to grab an alternative." print(message) discordError(message, torrent.file.fileInfo.filenameWithoutExt) else: - # TODO: See if we can fail without blacklisting as cached items constantly changes + items = [item[0]] if not isRadarr and isSeasonPack else items + failTasks = [asyncio.to_thread(arr.failHistoryItem, item.id) for item in items] await asyncio.gather(*failTasks) + + if not isRadarr and isSeasonPack: + for item in items: + series = await asyncio.to_thread(arr.get, item.grandparentId) + await asyncio.to_thread(arr.automaticSearch, series, item.parentId) + print(f"Failed") def getFiles(isRadarr): diff --git a/shared/arr.py b/shared/arr.py index 91c89dc..9bcdf82 100644 --- a/shared/arr.py +++ b/shared/arr.py @@ -217,6 +217,12 @@ def torrentInfoHash(self): def parentId(self): pass + @property + @abstractmethod + def grandparentId(self): + """Get the top-level ID (series ID for episodes, same as parentId for movies).""" + pass + @property @abstractmethod def isFileDeletedEvent(self): @@ -227,6 +233,11 @@ class MovieHistory(MediaHistory): def parentId(self): return self.json['movieId'] + @property + def grandparentId(self): + """For movies, grandparent ID is the same as parent ID.""" + return self.parentId + @property def isFileDeletedEvent(self): return self.eventType == 'movieFileDeleted' @@ -237,6 +248,11 @@ class EpisodeHistory(MediaHistory): def parentId(self): return self.json['episode']['seasonNumber'] + @property + def grandparentId(self): + """Get the series ID from the history item.""" + return self.json['episode']['seriesId'] + @property def isFileDeletedEvent(self): return self.eventType == 'episodeFileDeleted' From 8eb34b276badb5234b45783579573742cb06fb77 Mon Sep 17 00:00:00 2001 From: westsurname <155189104+westsurname@users.noreply.github.com> Date: Sun, 22 Dec 2024 17:39:05 -0500 Subject: [PATCH 2/6] misc fixes --- blackhole.py | 43 ++++++++++++++++++++++++++++++++----------- repair.py | 14 ++++++++++++-- shared/arr.py | 5 +++++ shared/debrid.py | 39 +++++---------------------------------- 4 files changed, 54 insertions(+), 47 deletions(-) diff --git a/blackhole.py b/blackhole.py index c47f990..d925eba 100644 --- a/blackhole.py +++ b/blackhole.py @@ -48,12 +48,29 @@ def __init__(self, isTorrentOrMagnet, isDotTorrentFile) -> None: def __init__(self, filename, isRadarr) -> None: print('filename:', filename) baseBath = getPath(isRadarr) - uniqueId = str(uuid.uuid4())[:8] # Generate a unique identifier + uniqueId = str(uuid.uuid4())[:8] isDotTorrentFile = filename.casefold().endswith('.torrent') isTorrentOrMagnet = isDotTorrentFile or filename.casefold().endswith('.magnet') filenameWithoutExt, ext = os.path.splitext(filename) filePath = os.path.join(baseBath, filename) - filePathProcessing = os.path.join(baseBath, 'processing', f"{filenameWithoutExt}_{uniqueId}{ext}") + + # Get the maximum filename length for the target directory + try: + maxNameBytes = os.pathconf(baseBath, 'PC_NAME_MAX') + except (AttributeError, ValueError, OSError): + maxNameBytes = 255 + + # Calculate space needed for uniqueId, separator, and extension + extraBytes = len(f"_{uniqueId}{ext}".encode()) + + # Truncate the filename if needed + if len(filenameWithoutExt.encode()) > maxNameBytes - extraBytes: + processingName = truncateBytes(filenameWithoutExt, maxNameBytes - extraBytes) + print(f"Truncated filename from {len(filenameWithoutExt.encode())} to {len(processingName.encode())} bytes") + else: + processingName = filenameWithoutExt + + filePathProcessing = os.path.join(baseBath, 'processing', f"{processingName}_{uniqueId}{ext}") folderPathCompleted = os.path.join(baseBath, 'completed', filenameWithoutExt) self.fileInfo = self.FileInfo(filename, filenameWithoutExt, filePath, filePathProcessing, folderPathCompleted) @@ -85,6 +102,11 @@ def cleanFileName(name): refreshingTask = None +def truncateBytes(text: str, maxBytes: int) -> str: + """Truncate a string to a maximum number of bytes in UTF-8 encoding.""" + encoded = text.encode() + return encoded[:maxBytes].decode(errors='ignore') + async def refreshArr(arr: Arr, count=60): # TODO: Change to refresh until found/imported async def refresh(): @@ -165,8 +187,7 @@ def print(*values: object): # Send progress to arr progress = info['progress'] print(f"Progress: {progress:.2f}%") - if torrent.incompatibleHashSize and torrent.failIfNotCached: - print("Non-cached incompatible hash sized torrent") + if torrent.skipAvailabilityCheck and torrent.failIfNotCached: torrent.delete() return False await asyncio.sleep(1) @@ -315,10 +336,6 @@ async def is_accessible(path, timeout=10): discordError(f"Error processing {file.fileInfo.filenameWithoutExt}", e) -def isSeasonPack(filename): - # Match patterns like 'S01' or 'Season 1' but not 'S01E01' - return bool(re.search(r'(?:S|Season\s*)(\d{1,2})(?!\s*E\d{2})', filename, re.IGNORECASE)) - async def fail(torrent: TorrentBase, arr: Arr, isRadarr): _print = globals()['print'] @@ -326,8 +343,6 @@ def print(*values: object): _print(f"[{torrent.__class__.__name__}] [{torrent.file.fileInfo.filenameWithoutExt}]", *values) print(f"Failing") - - isSeasonPack = isSeasonPack(torrent.file.fileInfo.filename) torrentHash = torrent.getHash() history = await asyncio.to_thread(arr.getHistory, blackhole['historyPageSize']) @@ -338,11 +353,17 @@ def print(*values: object): print(message) discordError(message, torrent.file.fileInfo.filenameWithoutExt) else: - items = [item[0]] if not isRadarr and isSeasonPack else items + firstItem = items[0] + isSeasonPack = firstItem.releaseType == 'SeasonPack' + + # For season packs, we only need to fail one episode and trigger one search + items = [firstItem] if not isRadarr and isSeasonPack else items + # Mark items as failed failTasks = [asyncio.to_thread(arr.failHistoryItem, item.id) for item in items] await asyncio.gather(*failTasks) + # For season packs in Sonarr, trigger a new search if not isRadarr and isSeasonPack: for item in items: series = await asyncio.to_thread(arr.get, item.grandparentId) diff --git a/repair.py b/repair.py index bcba055..ff9c112 100644 --- a/repair.py +++ b/repair.py @@ -29,6 +29,8 @@ def parseInterval(intervalStr): 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('--soft-repair', action='store_true', help='Only search for missing files, do not delete or re-grab. This is always enabled in file mode.') parser.add_argument('--include-unmonitored', action='store_true', help='Include unmonitored media in the repair process') args = parser.parse_args() @@ -102,7 +104,7 @@ def main(): 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': + if args.mode == 'symlink' and not args.soft_repair: print("Deleting files:") [print(item.path) for item in childItems] results = arr.deleteFiles(childItems) @@ -127,9 +129,17 @@ def main(): if childId in media.fullyAvailableChildrenIds and len(parentFolders) > 1: print("Title:", media.title) print("Movie ID/Season Number:", childId) - print("Inconsistent folders:") + 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() diff --git a/shared/arr.py b/shared/arr.py index 9bcdf82..0266673 100644 --- a/shared/arr.py +++ b/shared/arr.py @@ -212,6 +212,11 @@ def sourceTitle(self): def torrentInfoHash(self): return self.json['data'].get('torrentInfoHash') + @property + def releaseType(self): + """Get the release type from the history item data.""" + return self.json['data'].get('releaseType') + @property @abstractmethod def parentId(self): diff --git a/shared/debrid.py b/shared/debrid.py index 20cb95d..d095abb 100644 --- a/shared/debrid.py +++ b/shared/debrid.py @@ -108,7 +108,7 @@ def __init__(self, f, fileData, file, failIfNotCached, onlyLargestFile) -> None: self.file = file self.failIfNotCached = failIfNotCached self.onlyLargestFile = onlyLargestFile - self.incompatibleHashSize = False + self.skipAvailabilityCheck = False self.id = None self._info = None self._hash = None @@ -173,31 +173,11 @@ def submitTorrent(self): return not not self.addTorrent() def _getInstantAvailability(self, refresh=False): - if refresh or not self._instantAvailability: - torrentHash = self.getHash() - self.print('hash:', torrentHash) - - if len(torrentHash) != 40 or True: - self.incompatibleHashSize = True - return True - - instantAvailabilityRequest = retryRequest( - lambda: requests.get(urljoin(realdebrid['host'], f"torrents/instantAvailability/{torrentHash}"), headers=self.headers), - print=self.print - ) - if instantAvailabilityRequest is None: - return None + torrentHash = self.getHash() + self.print('hash:', torrentHash) + self.skipAvailabilityCheck = True - instantAvailabilities = instantAvailabilityRequest.json() - self.print('instantAvailabilities:', instantAvailabilities) - if not instantAvailabilities: return - - instantAvailabilityHosters = next(iter(instantAvailabilities.values())) - if not instantAvailabilityHosters: return - - self._instantAvailability = next(iter(instantAvailabilityHosters.values())) - - return self._instantAvailability + return True def _getAvailableHost(self): availableHostsRequest = retryRequest( @@ -248,15 +228,6 @@ async def selectFiles(self): largestMediaFileId = str(largestMediaFile['id']) self.print('only largest file:', self.onlyLargestFile) self.print('largest file:', largestMediaFile) - - if self.failIfNotCached and not self.incompatibleHashSize: - targetFileIds = {largestMediaFileId} if self.onlyLargestFile else mediaFileIds - if not any(set(fileGroup.keys()) == targetFileIds for fileGroup in self._instantAvailability): - extraFilesGroup = next((fileGroup for fileGroup in self._instantAvailability if largestMediaFileId in fileGroup.keys()), None) - if self.onlyLargestFile and extraFilesGroup: - self.print('extra files required for cache:', extraFilesGroup) - discordUpdate('Extra files required for cache:', extraFilesGroup) - return False if self.onlyLargestFile and len(mediaFiles) > 1: discordUpdate('largest file:', largestMediaFile['path']) From 4e9f30759ff01070a9aed9430b88f2f619a72686 Mon Sep 17 00:00:00 2001 From: westsurname <155189104+westsurname@users.noreply.github.com> Date: Sun, 22 Dec 2024 17:44:50 -0500 Subject: [PATCH 3/6] No need to specify not radarr --- blackhole.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/blackhole.py b/blackhole.py index d925eba..12057a8 100644 --- a/blackhole.py +++ b/blackhole.py @@ -357,14 +357,14 @@ def print(*values: object): isSeasonPack = firstItem.releaseType == 'SeasonPack' # For season packs, we only need to fail one episode and trigger one search - items = [firstItem] if not isRadarr and isSeasonPack else items + items = [firstItem] if isSeasonPack else items # Mark items as failed failTasks = [asyncio.to_thread(arr.failHistoryItem, item.id) for item in items] await asyncio.gather(*failTasks) - # For season packs in Sonarr, trigger a new search - if not isRadarr and isSeasonPack: + # For season packs, trigger a new search + if isSeasonPack: for item in items: series = await asyncio.to_thread(arr.get, item.grandparentId) await asyncio.to_thread(arr.automaticSearch, series, item.parentId) From e427f51000bd31f7a69b62a30592f2b22dbb2b2e Mon Sep 17 00:00:00 2001 From: westsurname <155189104+westsurname@users.noreply.github.com> Date: Sun, 22 Dec 2024 17:49:37 -0500 Subject: [PATCH 4/6] revert stupid soft-repair attempt --- repair.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/repair.py b/repair.py index ff9c112..81dfd4c 100644 --- a/repair.py +++ b/repair.py @@ -30,7 +30,6 @@ def parseInterval(intervalStr): 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('--soft-repair', action='store_true', help='Only search for missing files, do not delete or re-grab. This is always enabled in file mode.') parser.add_argument('--include-unmonitored', action='store_true', help='Include unmonitored media in the repair process') args = parser.parse_args() @@ -104,7 +103,7 @@ def main(): 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' and not args.soft_repair: + if args.mode == 'symlink': print("Deleting files:") [print(item.path) for item in childItems] results = arr.deleteFiles(childItems) From 1d24d5c79b3bd81a6450dec18bb70c8e856454f4 Mon Sep 17 00:00:00 2001 From: westsurname <155189104+westsurname@users.noreply.github.com> Date: Sun, 22 Dec 2024 17:53:57 -0500 Subject: [PATCH 5/6] add repair season packs flag to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cc6d793..29f9d37 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ The script accepts the following arguments: - `--repair-interval`: Optional interval in smart format (e.g., '1h2m3s') to wait between repairing each media file. - `--run-interval`: Optional interval in smart format (e.g., '1w2d3h4m5s') to run the repair process. - `--mode`: Choose repair mode: `symlink` or `file`. `symlink` to repair broken symlinks and `file` to repair missing files. (default: 'symlink'). +- `--season-packs`: Upgrade to season-packs when a non-season-pack is found. Only applicable in symlink mode. - `--include-unmonitored`: Include unmonitored media in the repair process. ### Warning From a872db06da1626606bd0a1a9708c86a68747a791 Mon Sep 17 00:00:00 2001 From: westsurname <155189104+westsurname@users.noreply.github.com> Date: Sun, 22 Dec 2024 18:44:37 -0500 Subject: [PATCH 6/6] fixes --- blackhole.py | 2 +- shared/arr.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/blackhole.py b/blackhole.py index 12057a8..847ca67 100644 --- a/blackhole.py +++ b/blackhole.py @@ -345,7 +345,7 @@ def print(*values: object): print(f"Failing") torrentHash = torrent.getHash() - history = await asyncio.to_thread(arr.getHistory, blackhole['historyPageSize']) + history = await asyncio.to_thread(arr.getHistory, blackhole['historyPageSize'], includeGrandchildDetails=True) items = [item for item in history if (item.torrentInfoHash and item.torrentInfoHash.casefold() == torrentHash.casefold()) or cleanFileName(item.sourceTitle.casefold()) == torrent.file.fileInfo.filenameWithoutExt.casefold()] if not items: diff --git a/shared/arr.py b/shared/arr.py index 0266673..a63418e 100644 --- a/shared/arr.py +++ b/shared/arr.py @@ -254,6 +254,7 @@ def parentId(self): return self.json['episode']['seasonNumber'] @property + # Requires includeGrandchildDetails to be true def grandparentId(self): """Get the series ID from the history item.""" return self.json['episode']['seriesId']