├── .gitignore ├── AutoXSeeder.py ├── LICENSE ├── README.md └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | /testfiles/** -------------------------------------------------------------------------------- /AutoXSeeder.py: -------------------------------------------------------------------------------- 1 | #!python3 2 | 3 | import argparse 4 | import json 5 | import os 6 | import re 7 | from rapidfuzz import fuzz 8 | import torrent_parser as tp 9 | 10 | parser = argparse.ArgumentParser(description='Creates symlinks for existing data given torrent file(s) as inputs') 11 | parser.add_argument('-i', metavar='INPUT_PATH', dest='INPUT_PATH', type=str, required=True, help='Torrent file or directory containing torrent files') 12 | parser.add_argument('-r', metavar='ROOT_PATH', dest='ROOT_PATH', type=str, required=True, help='Root folder (eg. your torrent client download directory) containing downloaded content which will be checked for cross-seedable files') 13 | parser.add_argument('-s', metavar='SAVE_PATH', dest='SAVE_PATH', type=str, required=True, help='Root folder (eg. your torrent client download directory) where symlinks will be created') 14 | args = parser.parse_args() 15 | 16 | DISC_FOLDERS = ['BDMV', 'CERTIFICATE', 'PLAYLIST', 'STREAM', 'VIDEO_TS', 'AUDIO_TS'] 17 | SEASON_EP_RE = r's(\d+)[ \.]?e(\d+)\b|\b(\d+) ?x ?(\d+)\b' 18 | 19 | DIR_DELIM = '\\' if os.name == 'nt' else '/' 20 | 21 | if os.name == 'nt': 22 | from ctypes import windll, wintypes 23 | FILE_ATTRIBUTE_REPARSE_POINT = 0x0400 24 | GetFileAttributes = windll.kernel32.GetFileAttributesW 25 | 26 | 27 | def main(): 28 | torrentFiles = [os.path.normpath(args.INPUT_PATH)] if args.INPUT_PATH.endswith('.torrent') else [os.path.join(args.INPUT_PATH, f) for f in os.listdir(args.INPUT_PATH)] 29 | 30 | for torrentPath in torrentFiles: 31 | torrent = os.path.basename(torrentPath) 32 | if not torrent.endswith('.torrent'): 33 | continue 34 | 35 | try: 36 | torrentData = tp.parse_torrent_file(torrentPath) 37 | except Exception: 38 | print(f'Error reading torrent file {torrentPath}') 39 | continue 40 | 41 | 42 | torrentDataRootName = torrentData['info']['name'] 43 | torrentDataFileList = torrentData['info'].get('files', None) 44 | 45 | if torrentDataFileList == None: 46 | filesize_torrent = torrentData['info']['length'] 47 | 48 | matchedFilepath = findMatchingDownloadedFile(torrentDataRootName, filesize_torrent, torrentDataRootName) 49 | if matchedFilepath == None: 50 | continue 51 | 52 | linkPath = os.path.join(args.SAVE_PATH, torrentDataRootName) 53 | targetPath = matchedFilepath 54 | 55 | if islink(targetPath): 56 | targetPath = os.readlink(targetPath) 57 | 58 | try: 59 | os.symlink(targetPath, linkPath) 60 | except FileExistsError: 61 | print(f'Skipping... Symlink already exists: "{linkPath}"\n') 62 | pass 63 | except OSError: 64 | print('Admin privileges not held. Cannot create symlink.') 65 | exit() 66 | 67 | # try: 68 | # os.rename(torrentPath, os.path.join(os.path.join(TORRENTS_LOCATION, 'matched'), torrent)) 69 | # except Exception: 70 | # pass 71 | else: 72 | failedTotalSize = 0 73 | matchedFiles = {} 74 | isDisc = isDiscTorrent(torrentDataFileList) 75 | isTV = isTVTorrent(torrentDataFileList) 76 | for torrentDataFile in torrentDataFileList: 77 | torrentDataFilePath = DIR_DELIM.join([torrentDataRootName] + torrentDataFile['path']) 78 | torrentDataListedFilePath = DIR_DELIM.join(torrentDataFile['path']) 79 | filename_torrent = torrentDataFile['path'][-1] 80 | matchedFilepath = findMatchingDownloadedFile(torrentDataRootName, torrentDataFile['length'], torrentDataListedFilePath, isDisc=isDisc, isTV=isTV) 81 | if matchedFilepath == None: 82 | failedTotalSize += torrentDataFile['length'] 83 | continue 84 | matchedFiles[torrentDataFilePath] = matchedFilepath 85 | # print(json.dumps(matchedFiles, indent=4)) 86 | # print(matchedFiles) 87 | 88 | for i, filepath in enumerate(matchedFiles): 89 | dirname = os.path.dirname(filepath) 90 | if dirname == '': 91 | continue 92 | 93 | dirPath = os.path.join(args.SAVE_PATH, dirname) 94 | try: 95 | os.makedirs(dirPath) 96 | except FileExistsError: 97 | pass 98 | 99 | targetPath = matchedFiles[filepath] 100 | if islink(targetPath): 101 | targetPath = os.readlink(targetPath) 102 | linkPath = os.path.join(args.SAVE_PATH, filepath) 103 | 104 | print(f'Symlinking {i + 1} of {len(matchedFiles)}: {linkPath}') 105 | try: 106 | os.symlink(targetPath, linkPath) 107 | except FileExistsError: 108 | print(f'Skipping... Symlink already exists: "{linkPath}"\n') 109 | pass 110 | except OSError: 111 | print('Admin privileges not held. Cannot create symlink.') 112 | exit() 113 | 114 | # if matchedFiles: 115 | # try: 116 | # os.rename(torrentPath, os.path.join(os.path.join(TORRENTS_LOCATION, 'matched'), torrent)) 117 | # except: 118 | # pass 119 | 120 | 121 | def findMatchingDownloadedFile(torrentDataRootName, torrentDataFilesize, torrentDataFilePath, isDisc=False, isTV=False): 122 | torrentDataFilename = os.path.basename(torrentDataFilePath) 123 | # maximum difference, in MB, the downloaded filesize and listed file size can be 124 | MAX_FILESIZE_DIFFERENCE = 2 * 1000000 125 | if isTV or torrentDataFilesize < 100 * 1000000: 126 | MAX_FILESIZE_DIFFERENCE = 0 127 | 128 | listings = os.listdir(args.ROOT_PATH) 129 | for listing in listings: 130 | listingPath = os.path.join(args.ROOT_PATH, listing) 131 | # if 'planet' in listing.lower(): 132 | # print(listing) 133 | # print(fuzz.token_set_ratio(listing, torrentDataFilename)) 134 | if os.path.isfile(listingPath) and fuzz.token_set_ratio(listing, torrentDataFilename, score_cutoff=80): 135 | localFilesize = get_file_size(listingPath) 136 | # print((localFilesize - torrentDataFilesize)/1000000) 137 | if localFilesize == None: 138 | return None 139 | if abs(localFilesize - torrentDataFilesize) <= MAX_FILESIZE_DIFFERENCE: 140 | return listingPath 141 | elif fuzz.token_set_ratio(listing, torrentDataRootName, score_cutoff=85): 142 | for root, dirs, filenames in os.walk(listingPath): 143 | for filename in filenames: 144 | localFilePath = os.path.join(root, filename) 145 | localFilesize = get_size(localFilePath) 146 | if localFilesize == None: 147 | continue 148 | 149 | if isDisc and areRootPathsSimilar(localFilePath, listingPath, torrentDataFilePath) and filename == torrentDataFilename: 150 | if abs(localFilesize - torrentDataFilesize) <= MAX_FILESIZE_DIFFERENCE: 151 | return localFilePath 152 | elif re.search(SEASON_EP_RE, torrentDataFilePath, re.IGNORECASE) and fuzz.token_set_ratio(filename, torrentDataFilename, score_cutoff=95): 153 | season_ep_str_torrent = getSeasonEpisodeStr(torrentDataFilePath) 154 | season_ep_str_filename = getSeasonEpisodeStr(filename) 155 | if season_ep_str_torrent == season_ep_str_filename and abs(localFilesize - torrentDataFilesize) <= MAX_FILESIZE_DIFFERENCE: 156 | return localFilePath 157 | elif fuzz.token_set_ratio(filename, torrentDataFilename, score_cutoff=95): 158 | if abs(localFilesize - torrentDataFilesize) <= MAX_FILESIZE_DIFFERENCE: 159 | return localFilePath 160 | return None 161 | 162 | 163 | def areRootPathsSimilar(localFilePath, localFileRootPath, torrentDataFilePath): 164 | localFilePath = localFilePath.replace(localFileRootPath + DIR_DELIM, '') 165 | # if fuzz.ratio(localFilePath, torrentDataFilePath) > 97: 166 | # return True 167 | if localFilePath == torrentDataFilePath: 168 | return True 169 | return False 170 | 171 | 172 | def isDiscTorrent(torrentDataFileList): 173 | for torrentDataFile in torrentDataFileList: 174 | for torrentDataFilePathPart in torrentDataFile['path']: 175 | if torrentDataFilePathPart in DISC_FOLDERS: 176 | return True 177 | return False 178 | 179 | def isTVTorrent(torrentDataFileList): 180 | for torrentDataFile in torrentDataFileList: 181 | if re.search(SEASON_EP_RE, torrentDataFile['path'][-1], re.IGNORECASE): 182 | return True 183 | return False 184 | 185 | def getSeasonEpisodeStr(filename): 186 | m = re.search(SEASON_EP_RE, filename, re.IGNORECASE) 187 | if m: 188 | season = m.group(1) 189 | episode = m.group(2) 190 | return f'S{season.zfill(3)}E{episode.zfill(3)}' 191 | return None 192 | 193 | 194 | def get_size(path): 195 | tempPath = path 196 | if os.path.isfile(path): 197 | return get_file_size(path) 198 | elif os.path.isdir(path): 199 | totalSize = 0 200 | for root, dirs, filenames in os.walk(path): 201 | for filename in filenames: 202 | filesize = get_file_size(os.path.join(root, filename)) 203 | if filesize == None: 204 | return None 205 | totalSize += filesize 206 | return totalSize 207 | return None 208 | 209 | def get_file_size(filepath): 210 | if islink(filepath): 211 | targetPath = os.readlink(filepath) 212 | if os.path.isfile(targetPath): 213 | return os.path.getsize(targetPath) 214 | else: 215 | return os.path.getsize(filepath) 216 | return None 217 | 218 | 219 | def islink(filepath): 220 | if os.name == 'nt': 221 | if GetFileAttributes(filepath) & FILE_ATTRIBUTE_REPARSE_POINT: 222 | return True 223 | else: 224 | return False 225 | else: 226 | return os.path.islink(filepath) 227 | 228 | 229 | def validatePath(filepath): 230 | path_filename, ext = os.path.splitext(filepath) 231 | n = 1 232 | 233 | if not os.path.isfile(filepath): 234 | return filepath 235 | 236 | filepath = f'{path_filename} ({n}){ext}' 237 | while os.path.isfile(filepath): 238 | n += 1 239 | filepath = f'{path_filename} ({n}){ext}' 240 | 241 | return filepath 242 | 243 | 244 | if __name__ == '__main__': 245 | main() 246 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 BreadedChickenator 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AutoXSeeder 2 | Parses torrent files and creates symlinks for matching Movies and TV local data 3 | 4 | This script is intended to be used in a Windows environment. Check out [Autotorrent](https://github.com/JohnDoee/autotorrent) for Linux/MacOS usage. 5 | 6 | # Setup 7 | 8 | Run `pip3 install -r requirements.txt` to install the required libraries 9 | 10 | # Usage 11 | 12 | usage: AutoXSeeder.py [-h] -i INPUT_PATH -r ROOT_PATH -s SAVE_PATH 13 | 14 | Creates symlinks for existing data given torrent file(s) as inputs 15 | 16 | optional arguments: 17 | -h, --help show this help message and exit 18 | -i INPUT_PATH Torrent file or directory containing torrent files 19 | -r ROOT_PATH Root folder (eg. your torrent client download directory) containing downloaded content which will be 20 | checked for cross-seedable files 21 | -s SAVE_PATH Root folder (eg. your torrent client download directory) where symlinks will be created 22 | 23 | Examples: 24 | 25 | py AutoXSeeder.py -i "D:\torrentfiles" -r "D:\TorrentClientDownloads\complete" -s "D:\TorrentClientDownloads\complete" 26 | 27 | py AutoXSeeder.py -i "D:\torrentfiles\MyTorrentFile.torrent" -r "D:\TorrentClientDownloads\complete" -s "D:\TorrentClientDownloads\complete" 28 | 29 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | rapidfuzz 2 | torrent_parser 3 | --------------------------------------------------------------------------------