1#!/usr/bin/env python 2 3"""Main tvnamer utility functionality 4""" 5 6import os 7import sys 8import logging 9import warnings 10 11try: 12 import readline 13except ImportError: 14 pass 15 16import json 17 18import tvdb_api 19from tvdb_api import Tvdb 20 21from tvnamer import cliarg_parser, __version__ 22from tvnamer.compat import PY2, raw_input 23from tvnamer.config_defaults import defaults 24 25from tvnamer.unicode_helper import p 26from tvnamer.utils import (Config, FileFinder, FileParser, Renamer, warn, 27applyCustomInputReplacements, formatEpisodeNumbers, makeValidFilename, 28DatedEpisodeInfo, NoSeasonEpisodeInfo) 29 30from tvnamer.tvnamer_exceptions import (ShowNotFound, SeasonNotFound, EpisodeNotFound, 31EpisodeNameNotFound, UserAbort, InvalidPath, NoValidFilesFoundError, SkipBehaviourAbort, 32InvalidFilename, DataRetrievalError) 33 34 35LOG = logging.getLogger(__name__) 36 37 38# Key for use in tvnamer only - other keys can easily be registered at https://thetvdb.com/api-information 39TVNAMER_API_KEY = "fb51f9b848ffac9750bada89ecba0225" 40 41 42def getMoveDestination(episode): 43 """Constructs the location to move/copy the file 44 """ 45 46 #TODO: Write functional test to ensure this valid'ifying works 47 def wrap_validfname(fname): 48 """Wrap the makeValidFilename function as it's called twice 49 and this is slightly long.. 50 """ 51 if Config['move_files_lowercase_destination']: 52 fname = fname.lower() 53 return makeValidFilename( 54 fname, 55 normalize_unicode = Config['normalize_unicode_filenames'], 56 windows_safe = Config['windows_safe_filenames'], 57 custom_blacklist = Config['custom_filename_character_blacklist'], 58 replace_with = Config['replace_invalid_characters_with']) 59 60 61 # Calls makeValidFilename on series name, as it must valid for a filename 62 if isinstance(episode, DatedEpisodeInfo): 63 destdir = Config['move_files_destination_date'] % { 64 'seriesname': makeValidFilename(episode.seriesname), 65 'year': episode.episodenumbers[0].year, 66 'month': episode.episodenumbers[0].month, 67 'day': episode.episodenumbers[0].day, 68 'originalfilename': episode.originalfilename, 69 } 70 elif isinstance(episode, NoSeasonEpisodeInfo): 71 destdir = Config['move_files_destination'] % { 72 'seriesname': wrap_validfname(episode.seriesname), 73 'episodenumbers': wrap_validfname(formatEpisodeNumbers(episode.episodenumbers)), 74 'originalfilename': episode.originalfilename, 75 } 76 else: 77 destdir = Config['move_files_destination'] % { 78 'seriesname': wrap_validfname(episode.seriesname), 79 'seasonnumber': episode.seasonnumber, 80 'episodenumbers': wrap_validfname(formatEpisodeNumbers(episode.episodenumbers)), 81 'originalfilename': episode.originalfilename, 82 } 83 return destdir 84 85 86def doRenameFile(cnamer, newName): 87 """Renames the file. cnamer should be Renamer instance, 88 newName should be string containing new filename. 89 """ 90 try: 91 cnamer.newPath(new_fullpath = newName, force = Config['overwrite_destination_on_rename'], leave_symlink = Config['leave_symlink']) 92 except OSError as e: 93 if Config['skip_behaviour'] == 'exit': 94 warn("Exiting due to error: %s" % e) 95 raise SkipBehaviourAbort() 96 warn("Skipping file due to error: %s" % e) 97 98 99def doMoveFile(cnamer, destDir = None, destFilepath = None, getPathPreview = False): 100 """Moves file to destDir, or to destFilepath 101 """ 102 103 if (destDir is None and destFilepath is None) or (destDir is not None and destFilepath is not None): 104 raise ValueError("Specify only destDir or destFilepath") 105 106 if not Config['move_files_enable']: 107 raise ValueError("move_files feature is disabled but doMoveFile was called") 108 109 if Config['move_files_destination'] is None: 110 raise ValueError("Config value for move_files_destination cannot be None if move_files_enabled is True") 111 112 try: 113 return cnamer.newPath( 114 new_path = destDir, 115 new_fullpath = destFilepath, 116 always_move = Config['always_move'], 117 leave_symlink = Config['leave_symlink'], 118 getPathPreview = getPathPreview, 119 force = Config['overwrite_destination_on_move']) 120 121 except OSError as e: 122 if Config['skip_behaviour'] == 'exit': 123 warn("Exiting due to error: %s" % e) 124 raise SkipBehaviourAbort() 125 warn("Skipping file due to error: %s" % e) 126 127 128def confirm(question, options, default = "y"): 129 """Takes a question (string), list of options and a default value (used 130 when user simply hits enter). 131 Asks until valid option is entered. 132 """ 133 # Highlight default option with [ ] 134 options_str = [] 135 for x in options: 136 if x == default: 137 x = "[%s]" % x 138 if x != '': 139 options_str.append(x) 140 options_str = "/".join(options_str) 141 142 while True: 143 p(question) 144 p("(%s) " % (options_str), end="") 145 try: 146 ans = raw_input().strip() 147 except KeyboardInterrupt as errormsg: 148 p("\n", errormsg) 149 raise UserAbort(errormsg) 150 151 if ans in options: 152 return ans 153 elif ans == '': 154 return default 155 156 157def processFile(tvdb_instance, episode): 158 """Gets episode name, prompts user for input 159 """ 160 p("#" * 20) 161 p("# Processing file: %s" % episode.fullfilename) 162 163 if len(Config['input_filename_replacements']) > 0: 164 replaced = applyCustomInputReplacements(episode.fullfilename) 165 p("# With custom replacements: %s" % (replaced)) 166 167 # Use force_name option. Done after input_filename_replacements so 168 # it can be used to skip the replacements easily 169 if Config['force_name'] is not None: 170 episode.seriesname = Config['force_name'] 171 172 p("# Detected series: %s (%s)" % (episode.seriesname, episode.number_string())) 173 174 try: 175 episode.populateFromTvdb(tvdb_instance, force_name=Config['force_name'], series_id=Config['series_id']) 176 except (DataRetrievalError, ShowNotFound) as errormsg: 177 if Config['always_rename'] and Config['skip_file_on_error'] is True: 178 if Config['skip_behaviour'] == 'exit': 179 warn("Exiting due to error: %s" % errormsg) 180 raise SkipBehaviourAbort() 181 warn("Skipping file due to error: %s" % errormsg) 182 return 183 else: 184 warn(errormsg) 185 except (SeasonNotFound, EpisodeNotFound, EpisodeNameNotFound) as errormsg: 186 # Show was found, so use corrected series name 187 if Config['always_rename'] and Config['skip_file_on_error']: 188 if Config['skip_behaviour'] == 'exit': 189 warn("Exiting due to error: %s" % errormsg) 190 raise SkipBehaviourAbort() 191 warn("Skipping file due to error: %s" % errormsg) 192 return 193 194 warn(errormsg) 195 196 cnamer = Renamer(episode.fullpath) 197 198 199 shouldRename = False 200 201 if Config["move_files_only"]: 202 203 newName = episode.fullfilename 204 shouldRename = True 205 206 else: 207 newName = episode.generateFilename() 208 if newName == episode.fullfilename: 209 p("#" * 20) 210 p("Existing filename is correct: %s" % episode.fullfilename) 211 p("#" * 20) 212 213 shouldRename = True 214 215 else: 216 p("#" * 20) 217 p("Old filename: %s" % episode.fullfilename) 218 219 if len(Config['output_filename_replacements']) > 0: 220 # Show filename without replacements 221 p("Before custom output replacements: %s" % (episode.generateFilename(preview_orig_filename = False))) 222 223 p("New filename: %s" % newName) 224 225 if Config['dry_run']: 226 p("%s will be renamed to %s" % (episode.fullfilename, newName)) 227 if Config['move_files_enable']: 228 p("%s will be moved to %s" % (newName, getMoveDestination(episode))) 229 return 230 elif Config['always_rename']: 231 doRenameFile(cnamer, newName) 232 if Config['move_files_enable']: 233 if Config['move_files_destination_is_filepath']: 234 doMoveFile(cnamer = cnamer, destFilepath = getMoveDestination(episode)) 235 else: 236 doMoveFile(cnamer = cnamer, destDir = getMoveDestination(episode)) 237 return 238 239 ans = confirm("Rename?", options = ['y', 'n', 'a', 'q'], default = 'y') 240 241 if ans == "a": 242 p("Always renaming") 243 Config['always_rename'] = True 244 shouldRename = True 245 elif ans == "q": 246 p("Quitting") 247 raise UserAbort("User exited with q") 248 elif ans == "y": 249 p("Renaming") 250 shouldRename = True 251 elif ans == "n": 252 p("Skipping") 253 else: 254 p("Invalid input, skipping") 255 256 if shouldRename: 257 doRenameFile(cnamer, newName) 258 259 if shouldRename and Config['move_files_enable']: 260 newPath = getMoveDestination(episode) 261 if Config['dry_run']: 262 p("%s will be moved to %s" % (newName, getMoveDestination(episode))) 263 return 264 265 if Config['move_files_destination_is_filepath']: 266 doMoveFile(cnamer = cnamer, destFilepath = newPath, getPathPreview = True) 267 else: 268 doMoveFile(cnamer = cnamer, destDir = newPath, getPathPreview = True) 269 270 if not Config['batch'] and Config['move_files_confirmation']: 271 ans = confirm("Move file?", options = ['y', 'n', 'q'], default = 'y') 272 else: 273 ans = 'y' 274 275 if ans == 'y': 276 p("Moving file") 277 doMoveFile(cnamer, newPath) 278 elif ans == 'q': 279 p("Quitting") 280 raise UserAbort("user exited with q") 281 282 283def findFiles(paths): 284 """Takes an array of paths, returns all files found 285 """ 286 valid_files = [] 287 288 for cfile in paths: 289 cur = FileFinder( 290 cfile, 291 with_extension = Config['valid_extensions'], 292 filename_blacklist = Config["filename_blacklist"], 293 recursive = Config['recursive']) 294 295 try: 296 valid_files.extend(cur.findFiles()) 297 except InvalidPath: 298 warn("Invalid path: %s" % cfile) 299 300 if len(valid_files) == 0: 301 raise NoValidFilesFoundError() 302 303 # Remove duplicate files (all paths from FileFinder are absolute) 304 valid_files = list(set(valid_files)) 305 306 return valid_files 307 308 309def tvnamer(paths): 310 """Main tvnamer function, takes an array of paths, does stuff. 311 """ 312 313 p("#" * 20) 314 p("# Starting tvnamer") 315 316 episodes_found = [] 317 318 for cfile in findFiles(paths): 319 parser = FileParser(cfile) 320 try: 321 episode = parser.parse() 322 except InvalidFilename as e: 323 warn("Invalid filename: %s" % e) 324 else: 325 if episode.seriesname is None and Config['force_name'] is None and Config['series_id'] is None: 326 warn("Parsed filename did not contain series name (and --name or --series-id not specified), skipping: %s" % cfile) 327 328 else: 329 episodes_found.append(episode) 330 331 if len(episodes_found) == 0: 332 raise NoValidFilesFoundError() 333 334 p("# Found %d episode" % len(episodes_found) + ("s" * (len(episodes_found) > 1))) 335 336 # Sort episodes by series name, season and episode number 337 episodes_found.sort(key = lambda x: x.sortable_info()) 338 339 # episode sort order 340 if Config['order'] == 'dvd': 341 dvdorder = True 342 else: 343 dvdorder = False 344 345 if not PY2 and os.getenv("TRAVIS", "false") == "true": 346 # Disable caching on Travis-CI because in Python 3 it errors with: 347 # 348 # Can't pickle <class 'http.cookiejar.DefaultCookiePolicy'>: it's not the same object as http.cookiejar.DefaultCookiePolicy 349 cache = False 350 else: 351 cache = True 352 353 if Config['tvdb_api_key'] is not None: 354 LOG.debug("Using custom API key from config") 355 api_key = Config['tvdb_api_key'] 356 else: 357 LOG.debug("Using tvnamer default API key") 358 api_key = TVNAMER_API_KEY 359 360 tvdb_instance = Tvdb( 361 interactive = not Config['select_first'], 362 search_all_languages = Config['search_all_languages'], 363 language = Config['language'], 364 dvdorder = dvdorder, 365 cache=cache, 366 apikey=api_key, 367 ) 368 369 for episode in episodes_found: 370 processFile(tvdb_instance, episode) 371 p('') 372 373 p("#" * 20) 374 p("# Done") 375 376 377def main(): 378 """Parses command line arguments, displays errors from tvnamer in terminal 379 """ 380 opter = cliarg_parser.getCommandlineParser(defaults) 381 382 opts, args = opter.parse_args() 383 384 if opts.show_version: 385 print("tvnamer version: %s" % (__version__,)) 386 print("tvdb_api version: %s" % (tvdb_api.__version__,)) 387 sys.exit(0) 388 389 if opts.verbose: 390 logging.basicConfig( 391 level = logging.DEBUG, 392 format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s") 393 else: 394 logging.basicConfig() 395 396 # If a config is specified, load it, update the defaults using the loaded 397 # values, then reparse the options with the updated defaults. 398 default_configuration = os.path.expanduser("~/.config/tvnamer/tvnamer.json") 399 old_default_configuration = os.path.expanduser("~/.tvnamer.json") 400 401 if opts.loadconfig is not None: 402 # Command line overrides loading ~/.config/tvnamer/tvnamer.json 403 configToLoad = opts.loadconfig 404 elif os.path.isfile(default_configuration): 405 # No --config arg, so load default config if it exists 406 configToLoad = default_configuration 407 elif os.path.isfile(old_default_configuration): 408 # No --config arg and neow defualt config so load old version if it exist 409 configToLoad = old_default_configuration 410 else: 411 # No arg, nothing at default config location, don't load anything 412 configToLoad = None 413 414 if configToLoad is not None: 415 p("Loading config: %s" % (configToLoad)) 416 if os.path.isfile(old_default_configuration): 417 p("WARNING: you have a config at deprecated ~/.tvnamer.json location.") 418 p("This must be moved to new location: ~/.config/tvnamer/tvnamer.json") 419 420 try: 421 loadedConfig = json.load(open(os.path.expanduser(configToLoad))) 422 except ValueError as e: 423 p("Error loading config: %s" % e) 424 opter.exit(1) 425 else: 426 # Config loaded, update optparser's defaults and reparse 427 defaults.update(loadedConfig) 428 opter = cliarg_parser.getCommandlineParser(defaults) 429 opts, args = opter.parse_args() 430 431 # Decode args using filesystem encoding (done after config loading 432 # as the args are reparsed when the config is loaded) 433 if PY2: 434 args = [x.decode(sys.getfilesystemencoding()) for x in args] 435 436 # Save config argument 437 if opts.saveconfig is not None: 438 p("Saving config: %s" % (opts.saveconfig)) 439 configToSave = dict(opts.__dict__) 440 del configToSave['saveconfig'] 441 del configToSave['loadconfig'] 442 del configToSave['showconfig'] 443 json.dump( 444 configToSave, 445 open(os.path.expanduser(opts.saveconfig), "w+"), 446 sort_keys=True, 447 indent=4) 448 449 opter.exit(0) 450 451 # Show config argument 452 if opts.showconfig: 453 print(json.dumps(opts.__dict__, sort_keys=True, indent=2)) 454 return 455 456 # Process values 457 if opts.batch: 458 opts.select_first = True 459 opts.always_rename = True 460 461 # Update global config object 462 Config.update(opts.__dict__) 463 464 if Config["move_files_only"] and not Config["move_files_enable"]: 465 opter.error("Parameter move_files_enable cannot be set to false while parameter move_only is set to true.") 466 467 if Config['titlecase_filename'] and Config['lowercase_filename']: 468 warnings.warn("Setting 'lowercase_filename' clobbers 'titlecase_filename' option") 469 470 if len(args) == 0: 471 opter.error("No filenames or directories supplied") 472 473 try: 474 tvnamer(paths = sorted(args)) 475 except NoValidFilesFoundError: 476 opter.error("No valid files were supplied") 477 except UserAbort as errormsg: 478 opter.error(errormsg) 479 except SkipBehaviourAbort as errormsg: 480 opter.error(errormsg) 481 482if __name__ == '__main__': 483 main() 484