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