diff --git a/Changelog b/Changelog new file mode 100644 index 0000000..7568a15 --- /dev/null +++ b/Changelog @@ -0,0 +1,14 @@ +3.0.0 +* added 'dry_run' config option +* removed 'always_rename' and 'select_first' config options, simply 'batch' +mode is enough (in interactive mode you can still select to autorename files +from now on) +* added support for logging into file ('log_file' config option) +* 'titlecase_filename' and 'move_files_lowercase_destination' options +replaced with 'titlecase_dynamic_parts' and 'lowercase_dynamic_parts' +respectively +* In 'move_files_destination' variable, %(episodenumbers)s format parameter +is replaced with %(episode)s (as is used in 'filename_*' variables) +* Removed 'move_files_only' config option (all files are moved actualy, not +renamed). For preserving old behavior use %(originalfilename)s in +'move_files_destination' and set 'move_files_destination_is_filepath' to True diff --git a/setup.py b/setup.py index a30c3fa..e06fbbc 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ from setuptools import setup setup( name = 'tvnamer', -version=".".join(str(x) for x in __version__), +version=__version__, author='dbr/Ben', description='Automatic TV episode namer', diff --git a/tests/functional_runner.py b/tests/functional_runner.py index bfbbc33..39e79d3 100644 --- a/tests/functional_runner.py +++ b/tests/functional_runner.py @@ -26,8 +26,10 @@ import shutil import tempfile import subprocess +import atexit from tvnamer.unicode_helper import p, unicodify +from tvnamer import __version__ try: @@ -113,13 +115,6 @@ def make_dummy_files(files, location): return dummies -def clear_temp_dir(location): - """Removes file or directory at specified location - """ - p("Clearing %s" % unicode(location)) - shutil.rmtree(location) - - def run_tvnamer(with_files, with_flags = None, with_input = "", with_config = None, run_on_directory = False): """Runs tvnamer on list of file-names in with_files. with_files is a list of strings. @@ -132,10 +127,16 @@ def run_tvnamer(with_files, with_flags = None, with_input = "", with_config = No # Create dummy files (config and episodes) tvnpath = get_tvnamer_path() episodes_location = make_temp_dir() + # register cleanup function + atexit.register(shutil.rmtree, episodes_location) dummy_files = make_dummy_files(with_files, episodes_location) if with_config is not None: + # insert correct version into config + with_config = with_config.replace("{", """{"__version__": "%s",\n""" % __version__, 1) configfname = make_temp_config(with_config) + # register cleanup function + atexit.register(os.unlink, configfname) conf_args = ['-c', configfname] else: conf_args = [] @@ -173,11 +174,6 @@ def run_tvnamer(with_files, with_flags = None, with_input = "", with_config = No created_files.extend(curlist) - # Clean up dummy files and config - clear_temp_dir(episodes_location) - if with_config is not None: - os.unlink(configfname) - return { 'output': output, 'files': created_files, diff --git a/tests/test_absolute_number_ambiguity.py b/tests/test_absolute_number_ambiguity.py index 21907e7..cfe5494 100644 --- a/tests/test_absolute_number_ambiguity.py +++ b/tests/test_absolute_number_ambiguity.py @@ -13,8 +13,7 @@ def test_ambiguity_fix(): """ conf = """ - {"always_rename": true, - "select_first": true} + {"batch": true} """ out_data = run_tvnamer( diff --git a/tests/test_anime_filenames.py b/tests/test_anime_filenames.py index 8756017..85b5a78 100644 --- a/tests/test_anime_filenames.py +++ b/tests/test_anime_filenames.py @@ -15,10 +15,9 @@ def test_group(): with_files = ['[Some Group] Scrubs - 01 [A1B2C3].avi'], with_config = """ { - "always_rename": true, - "select_first": true, + "batch": true, - "filename_anime_with_episode": "[%(group)s] %(seriesname)s - %(episodenumber)s - %(episodename)s [%(crc)s]%(ext)s" + "filename_anime_with_episode": "[%(group)s] %(seriesname)s - %(episode)s - %(episodename)s [%(crc)s]%(ext)s" } """) @@ -35,10 +34,9 @@ def test_group_no_epname(): with_files = ['[Some Group] Somefakeseries - 01 [A1B2C3].avi'], with_config = """ { - "always_rename": true, - "select_first": true, + "batch": true, - "filename_anime_without_episode": "[%(group)s] %(seriesname)s - %(episodenumber)s [%(crc)s]%(ext)s" + "filename_anime_without_episode": "[%(group)s] %(seriesname)s - %(episode)s [%(crc)s]%(ext)s" } """) diff --git a/tests/test_configfunctional.py b/tests/test_configfunctional.py index a0de22d..f65370f 100644 --- a/tests/test_configfunctional.py +++ b/tests/test_configfunctional.py @@ -5,7 +5,6 @@ from functional_runner import run_tvnamer, verify_out_data from nose.plugins.attrib import attr -from helpers import expected_failure @attr("functional") @@ -14,8 +13,7 @@ def test_batchconfig(): """ conf = """ - {"always_rename": true, - "select_first": true} + {"batch": true} """ out_data = run_tvnamer( @@ -35,7 +33,7 @@ def test_skip_file_on_error(): conf = """ {"skip_file_on_error": true, - "always_rename": true} + "batch": true} """ out_data = run_tvnamer( @@ -55,7 +53,7 @@ def test_do_not_skip_file_on_error(): conf = """ {"skip_file_on_error": false, - "always_rename": true} + "batch": true} """ out_data = run_tvnamer( @@ -75,8 +73,7 @@ def test_lowercase_names(): conf = """ {"lowercase_filename": true, - "always_rename": true, - "select_first": true} + "batch": true} """ out_data = run_tvnamer( @@ -97,8 +94,7 @@ def test_replace_with_underscore(): conf = """ {"custom_filename_character_blacklist": " ", "replace_blacklisted_characters_with": "_", - "always_rename": true, - "select_first": true} + "batch": true} """ out_data = run_tvnamer( @@ -111,38 +107,13 @@ def test_replace_with_underscore(): verify_out_data(out_data, expected_files) -@attr("functional") -@expected_failure -def test_abs_epnmber(): - """Ensure the absolute episode number is available for custom - filenames in config - """ - - - conf = """ - {"filename_with_episode": "%(seriesname)s - %(absoluteepisode)s%(ext)s", - "always_rename": true, - "select_first": true} - """ - - out_data = run_tvnamer( - with_files = ['scrubs.s01e01.avi'], - with_config = conf, - with_input = "") - - expected_files = ['Scrubs - 01.avi'] - - verify_out_data(out_data, expected_files) - - @attr("functional") def test_resolve_absoloute_episode(): """Test resolving by absolute episode number """ conf = """ - {"always_rename": true, - "select_first": true} + {"batch": true} """ out_data = run_tvnamer( @@ -171,8 +142,7 @@ def test_valid_extension_recursive(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "valid_extensions": ["avi","mp4","m4v","wmv","mkv","mov","srt"], "recursive": true} """ @@ -194,8 +164,7 @@ def test_replace_ands(): """ conf = r""" - {"always_rename": true, - "select_first": true, + {"batch": true, "input_filename_replacements": [ {"is_regex": true, "match": "(\\Wand\\W| & )", @@ -221,8 +190,7 @@ def test_replace_ands_in_output_also(): """ conf = r""" - {"always_rename": true, - "select_first": true, + {"batch": true, "input_filename_replacements": [ {"is_regex": true, "match": "(\\Wand\\W| & )", @@ -253,9 +221,8 @@ def test_force_overwrite_enabled(): """ conf = r""" - {"always_rename": true, - "select_first": true, - "overwrite_destination_on_rename": true + {"batch": true, + "overwrite_destination": true } """ @@ -276,9 +243,8 @@ def test_force_overwrite_disabled(): """ conf = r""" - {"always_rename": true, - "select_first": true, - "overwrite_destination_on_rename": false + {"batch": true, + "overwrite_destination": false } """ @@ -299,9 +265,7 @@ def test_force_overwrite_default(): """ conf = r""" - {"always_rename": true, - "select_first": true - } + {"batch": true} """ out_data = run_tvnamer( @@ -313,27 +277,3 @@ def test_force_overwrite_default(): expected_files = ['Scrubs - [01x01] - My First Day.avi', 'scrubs - [01x01].avi'] verify_out_data(out_data, expected_files) - - -@attr("functional") -def test_titlecase(): - """Tests Title Case Option To Make Episodes Like This - """ - - conf = r""" - {"always_rename": true, - "select_first": true, - "skip_file_on_error": false, - "titlecase_filename": true - } - """ - - out_data = run_tvnamer( - with_files = ['this.is.a.fake.episode.s01e01.avi'], - with_config = conf, - with_input = "", - run_on_directory = True) - - expected_files = ['This Is a Fake Episode - [01x01].avi'] - - verify_out_data(out_data, expected_files) diff --git a/tests/test_custom_replacement.py b/tests/test_custom_replacement.py index 369f664..9aeb21b 100644 --- a/tests/test_custom_replacement.py +++ b/tests/test_custom_replacement.py @@ -20,8 +20,7 @@ def test_simple_input_replacements(): "match": "uuuuuu", "replacement": "u"} ], - "always_rename": true, - "select_first": true + "batch": true } """) @@ -43,8 +42,7 @@ def test_simple_output_replacements(): "match": "u", "replacement": "v"} ], - "always_rename": true, - "select_first": true + "batch": true } """) @@ -66,8 +64,7 @@ def test_regex_input_replacements(): "match": "[u]+", "replacement": "u"} ], - "always_rename": true, - "select_first": true + "batch": true } """) @@ -89,8 +86,7 @@ def test_regex_output_replacements(): "match": "[ua]+", "replacement": "v"} ], - "always_rename": true, - "select_first": true + "batch": true } """) @@ -112,8 +108,7 @@ def test_replacing_spaces(): "match": "[ ]", "replacement": "."} ], - "always_rename": true, - "select_first": true + "batch": true } """) @@ -140,8 +135,7 @@ def test_replacing_ands(): "match": " & ", "replacement": " and "} ], - "always_rename": true, - "select_first": true + "batch": true } """) @@ -166,8 +160,7 @@ def test_multiple_replacements(): "match": "v", "replacement": "_"} ], - "always_rename": true, - "select_first": true + "batch": true } """) @@ -191,8 +184,7 @@ def test_fullpath_replacements(): "match": "Scr.*?s", "replacement": "A Test"} ], - "always_rename": true, - "select_first": true + "batch": true } """) @@ -214,8 +206,7 @@ def test_restoring_dot(): "match": "tosh.0", "replacement": "tosh0"} ], - "always_rename": true, - "select_first": true + "batch": true } """) @@ -237,12 +228,11 @@ def test_replacement_order(): "match": ":", "replacement": "-"} ], - "always_rename": true, - "select_first": true + "batch": true } """) - expected_files = ['24 - [03x02] - Day 3- 2-00 P.M.-3-00 P.M..avi'] + expected_files = ['24 - [03x02] - Day 3_ 2_00 P.M.-3_00 P.M..avi'] verify_out_data(out_data, expected_files) @@ -260,8 +250,7 @@ def test_replacement_preserve_extension(): "match": "avi", "replacement": "ohnobroken"} ], - "always_rename": true, - "select_first": true + "batch": true } """) @@ -284,8 +273,7 @@ def test_replacement_including_extension(): "match": "Day.avi", "replacement": "Day.nl.avi"} ], - "always_rename": true, - "select_first": true + "batch": true } """) diff --git a/tests/test_datestamp_episode.py b/tests/test_datestamp_episode.py index 8731621..3e68215 100644 --- a/tests/test_datestamp_episode.py +++ b/tests/test_datestamp_episode.py @@ -15,7 +15,6 @@ def test_issue_56_dated_episode(): conf = """ {"batch": true, - "select_first": true, "filename_with_episode": "%(seriesname)s %(date)s - %(episodename)s%(ext)s"} """ @@ -38,8 +37,7 @@ def test_date_in_s01e01_out(): conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_with_episode": "%(seriesname)s - [%(seasonnumber)02dx%(episode)s] - %(episodename)s%(ext)s"} """ diff --git a/tests/test_filename_blacklist.py b/tests/test_filename_blacklist.py index 1bca66a..eff4786 100644 --- a/tests/test_filename_blacklist.py +++ b/tests/test_filename_blacklist.py @@ -13,8 +13,7 @@ def test_no_blacklist(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_blacklist": []} """ @@ -35,8 +34,7 @@ def test_partial_blacklist_using_simple_match(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_blacklist": [ {"is_regex": false, "match": "s02e01"} @@ -62,8 +60,7 @@ def test_partial_blacklist_using_regex(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_blacklist": [ {"is_regex": true, "match": ".*s02e01.*"} @@ -89,8 +86,7 @@ def test_partial_blacklist_using_mix(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_blacklist": [ {"is_regex": true, "match": ".*s02e01.*"}, @@ -118,8 +114,7 @@ def test_full_blacklist(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_blacklist": [ {"is_regex": true, "match": ".*"} @@ -142,8 +137,7 @@ def test_dotfiles(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_blacklist": [ {"is_regex": true, "match": "^\\\\..*"} @@ -166,8 +160,7 @@ def test_blacklist_fullpath(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_blacklist": [ {"is_regex": true, "full_path": true, @@ -191,8 +184,7 @@ def test_blacklist_exclude_extension(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_blacklist": [ {"is_regex": true, "full_path": true, @@ -217,8 +209,7 @@ def test_simple_blacklist(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_blacklist": [ "scrubs.s02e01.avi" ] @@ -244,8 +235,7 @@ def test_simple_blacklist_mixed(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "filename_blacklist": [ "scrubs.s02e01.avi", {"is_regex": true, diff --git a/tests/test_fileparse_api.py b/tests/test_fileparse_api.py index a71a907..3dea2eb 100644 --- a/tests/test_fileparse_api.py +++ b/tests/test_fileparse_api.py @@ -3,7 +3,7 @@ """Tests the FileParser API """ -from tvnamer.utils import FileParser, EpisodeInfo, DatedEpisodeInfo, NoSeasonEpisodeInfo +from tvnamer.utils import FileParser from helpers import assertType, assertEquals @@ -11,28 +11,28 @@ def test_episodeinfo(): """Parsing a s01e01 episode should return EpisodeInfo class """ p = FileParser("scrubs.s01e01.avi").parse() - assertType(p, EpisodeInfo) + assertEquals(p.eptype, 'default') def test_datedepisodeinfo(): """Parsing a 2009.06.05 episode should return DatedEpisodeInfo class """ p = FileParser("scrubs.2009.06.05.avi").parse() - assertType(p, DatedEpisodeInfo) + assertEquals(p.eptype, 'dated') def test_noseasonepisodeinfo(): """Parsing a e23 episode should return NoSeasonEpisodeInfo class """ p = FileParser("scrubs - e23.avi").parse() - assertType(p, NoSeasonEpisodeInfo) + assertEquals(p.eptype, 'noseason') def test_episodeinfo_naming(): """Parsing a s01e01 episode should return EpisodeInfo class """ p = FileParser("scrubs.s01e01.avi").parse() - assertType(p, EpisodeInfo) + assertEquals(p.eptype, 'default') assertEquals(p.generateFilename(), "scrubs - [01x01].avi") @@ -40,7 +40,7 @@ def test_datedepisodeinfo_naming(): """Parsing a 2009.06.05 episode should return DatedEpisodeInfo class """ p = FileParser("scrubs.2009.06.05.avi").parse() - assertType(p, DatedEpisodeInfo) + assertEquals(p.eptype, 'dated') assertEquals(p.generateFilename(), "scrubs - [2009-06-05].avi") @@ -48,5 +48,5 @@ def test_noseasonepisodeinfo_naming(): """Parsing a e23 episode should return NoSeasonEpisodeInfo class """ p = FileParser("scrubs - e23.avi").parse() - assertType(p, NoSeasonEpisodeInfo) + assertEquals(p.eptype, 'noseason') assertEquals(p.generateFilename(), "scrubs - [23].avi") diff --git a/tests/test_files.py b/tests/test_files.py index cd677b9..dcbefc5 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -145,8 +145,8 @@ 'seasonnumber': None, 'episodenumbers': [11], 'episodenames': ['The Itchy Tag']}, - {'input': 'Total Access - [01x01]', - 'parsedseriesname': 'total access', + {'input': 'Total Access 247 - [01x01]', + 'parsedseriesname': 'total access 247', 'correctedseriesname': 'Total Access 24/7', 'seasonnumber': 1, 'episodenumbers': [1], 'episodenames': ['Episode #1']}, diff --git a/tests/test_force_series.py b/tests/test_force_series.py index eabc132..9ac6ea1 100644 --- a/tests/test_force_series.py +++ b/tests/test_force_series.py @@ -13,8 +13,7 @@ def test_series_id(): """ conf = """ - {"always_rename": true, - "select_first": true} + {"batch": true} """ out_data = run_tvnamer( @@ -41,7 +40,7 @@ def test_series_id_with_nameless_series(): out_data = run_tvnamer( with_files = ['s01e01.avi'], with_config = conf, - with_flags = ["--series-id", '76156'], + with_flags = ["--series-id", '76156', "--batch"], with_input = "") expected_files = ['Scrubs - [01x01] - My First Day.avi'] diff --git a/tests/test_functional.py b/tests/test_functional.py index 12c5ee2..4d90aa5 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -1,177 +1,12 @@ #!/usr/bin/env python +# TODO: move these tests somewhere else, delete this file + """Functional tests for tvnamer tests """ from functional_runner import run_tvnamer, verify_out_data from nose.plugins.attrib import attr -from helpers import expected_failure_travisci - - -@attr("functional") -def test_simple_single_file(): - """Test most simple usage - """ - - out_data = run_tvnamer( - with_files = ['scrubs.s01e01.avi'], - with_input = "1\ny\n") - - expected_files = ['Scrubs - [01x01] - My First Day.avi'] - - verify_out_data(out_data, expected_files) - - -@attr("functional") -def test_simple_multiple_files(): - """Tests simple interactive usage with multiple files - """ - - input_files = [ - 'scrubs.s01e01.hdtv.fake.avi', - 'my.name.is.earl.s01e01.fake.avi', - 'a.fake.show.s12e24.fake.avi', - 'total.access.s01e01.avi'] - - expected_files = [ - 'Scrubs - [01x01] - My First Day.avi', - 'My Name Is Earl - [01x01] - Pilot.avi', - 'a fake show - [12x24].avi', - 'Total Access 24_7 - [01x01] - Episode #1.avi'] - - out_data = run_tvnamer( - with_files = input_files, - with_input = "y\n1\ny\n1\ny\n1\ny\ny\n") - - verify_out_data(out_data, expected_files) - - -@attr("functional") -def test_simple_batch_functionality(): - """Tests renaming single files at a time, in batch mode - """ - - tests = [ - {'in':'scrubs.s01e01.hdtv.fake.avi', - 'expected':'Scrubs - [01x01] - My First Day.avi'}, - {'in':'my.name.is.earl.s01e01.fake.avi', - 'expected':'My Name Is Earl - [01x01] - Pilot.avi'}, - {'in':'a.fake.show.s12e24.fake.avi', - 'expected':'a.fake.show.s12e24.fake.avi'}, - {'in': 'total.access.s01e01.avi', - 'expected': 'Total Access 24_7 - [01x01] - Episode #1.avi'}, - ] - - for curtest in tests: - - def _the_test(): - out_data = run_tvnamer( - with_files = [curtest['in'], ], - with_flags = ['--batch'], - ) - verify_out_data(out_data, [curtest['expected'], ]) - - _the_test.description = "test_simple_functionality_%s" % curtest['in'] - yield _the_test - - -@attr("functional") -def test_interactive_always_option(): - """Tests the "a" always rename option in interactive UI - """ - - input_files = [ - 'scrubs.s01e01.hdtv.fake.avi', - 'my.name.is.earl.s01e01.fake.avi', - 'a.fake.show.s12e24.fake.avi', - 'total.access.s01e01.avi'] - - expected_files = [ - 'Scrubs - [01x01] - My First Day.avi', - 'My Name Is Earl - [01x01] - Pilot.avi', - 'a fake show - [12x24].avi', - 'Total Access 24_7 - [01x01] - Episode #1.avi'] - - out_data = run_tvnamer( - with_files = input_files, - with_flags = ["--selectfirst"], - with_input = "a\n") - - verify_out_data(out_data, expected_files) - - -@attr("functional") -@expected_failure_travisci -def test_unicode_in_inputname(): - """Tests parsing a file with unicode in the input filename - """ - input_files = [ - u'The Big Bang Theory - S02E07 - The Panty Pin\u0303ata Polarization.avi'] - - expected_files = [ - u'The Big Bang Theory - [02x07] - The Panty Pin\u0303ata Polarization.avi'] - - out_data = run_tvnamer( - with_files = input_files, - with_flags = ["--batch"]) - - verify_out_data(out_data, expected_files) - - -@attr("functional") -def test_unicode_in_search_results(): - """Show with unicode in search results - """ - input_files = [ - 'psych.s04e11.avi'] - - expected_files = [ - 'Psych - [04x11] - Thrill Seekers & Hell Raisers.avi'] - - out_data = run_tvnamer( - with_files = input_files, - with_input = '1\ny\n') - - verify_out_data(out_data, expected_files) - - -@attr("functional") -def test_renaming_always_doesnt_overwrite(): - """If trying to rename a file that exists, should not create new file - """ - input_files = [ - 'Scrubs.s01e01.avi', - 'Scrubs - [01x01] - My First Day.avi'] - - expected_files = [ - 'Scrubs.s01e01.avi', - 'Scrubs - [01x01] - My First Day.avi'] - - out_data = run_tvnamer( - with_files = input_files, - with_flags = ['--batch']) - - verify_out_data(out_data, expected_files) - - -@attr("functional") -def test_not_overwritting_unicode_filename(): - """Test no error occurs when warning about a unicode filename being overwritten - """ - return - input_files = [ - u'The Big Bang Theory - S02E07.avi', - u'The Big Bang Theory - [02x07] - The Panty Pin\u0303ata Polarization.avi'] - - expected_files = [ - u'The Big Bang Theory - S02E07.avi', - u'The Big Bang Theory - [02x07] - The Panty Pin\u0303ata Polarization.avi'] - - out_data = run_tvnamer( - with_files = input_files, - with_flags = ['--batch']) - - verify_out_data(out_data, expected_files) @attr("functional") @@ -194,34 +29,6 @@ def test_not_recursive(): verify_out_data(out_data, expected_files) -@attr("functional") -def test_correct_filename(): - """If the filename is already correct, don't prompt - """ - - out_data = run_tvnamer( - with_files = ['Scrubs - [01x01] - My First Day.avi'], - with_input = "1\ny\n") - - expected_files = ['Scrubs - [01x01] - My First Day.avi'] - - verify_out_data(out_data, expected_files) - - -@attr("functional") -def test_filename_already_exists(): - """If the filename is already correct, don't prompt - """ - - out_data = run_tvnamer( - with_files = ['Scrubs - [01x01] - My First Day.avi', 'scrubs.s01e01.avi'], - with_input = "1\ny\n") - - expected_files = ['Scrubs - [01x01] - My First Day.avi', 'scrubs.s01e01.avi'] - - verify_out_data(out_data, expected_files) - - @attr("functional") def test_no_seasonnumber(): """Test episode with no series number @@ -236,31 +43,3 @@ def test_no_seasonnumber(): verify_out_data(out_data, expected_files) -@attr("functional") -def test_skipping_after_replacements(): - """When custom-replacement is specified, should still skip file if name is correct - """ - - conf = """ - {"select_first": true, - "input_filename_replacements": [ - {"is_regex": false, - "match": "v", - "replacement": "u"} - ], - "output_filename_replacements": [ - {"is_regex": false, - "match": "u", - "replacement": "v"} - ] - } - """ - - out_data = run_tvnamer( - with_files = ['Scrvbs - [01x01] - My First Day.avi'], - with_config = conf, - with_input = "") - - expected_files = ['Scrvbs - [01x01] - My First Day.avi'] - - verify_out_data(out_data, expected_files) diff --git a/tests/test_interactive.py b/tests/test_interactive.py new file mode 100644 index 0000000..679568e --- /dev/null +++ b/tests/test_interactive.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python + +""" Tests interactive mode of tvnamer +""" + +from functional_runner import run_tvnamer, verify_out_data +from nose.plugins.attrib import attr + + +@attr("functional") +def test_simple_single_file(): + """Test simple interactive usage with single file + """ + + out_data = run_tvnamer( + with_files = ['scrubs.s01e01.avi'], + with_input = "1\ny\n") + + expected_files = ['Scrubs - [01x01] - My First Day.avi'] + + verify_out_data(out_data, expected_files) + + +@attr("functional") +def test_simple_multiple_files(): + """Tests simple interactive usage with multiple files + """ + + input_files = [ + 'scrubs.s01e01.hdtv.fake.avi', + 'my.name.is.earl.s01e01.fake.avi', + 'a.fake.show.s12e24.fake.avi', + 'total.access.247.s01e01.avi'] + + expected_files = [ + 'Scrubs - [01x01] - My First Day.avi', + 'My Name Is Earl - [01x01] - Pilot.avi', + 'a fake show - [12x24].avi', + 'Total Access 24_7 - [01x01] - Episode #1.avi'] + + out_data = run_tvnamer( + with_files = input_files, + with_input = "y\n1\ny\n1\ny\n1\ny\ny\n") + + verify_out_data(out_data, expected_files) + + +@attr("functional") +def test_simple_batch_functionality(): + """Tests simple batch mode, interactive mode explicitly disabled + """ + + tests = [ + {'in':'scrubs.s01e01.hdtv.fake.avi', + 'expected':'Scrubs - [01x01] - My First Day.avi'}, + {'in':'my.name.is.earl.s01e01.fake.avi', + 'expected':'My Name Is Earl - [01x01] - Pilot.avi'}, + {'in':'a.fake.show.s12e24.fake.avi', + 'expected':'a.fake.show.s12e24.fake.avi'}, + {'in': 'total.access.247.s01e01.avi', + 'expected': 'Total Access 24_7 - [01x01] - Episode #1.avi'}, + ] + + for curtest in tests: + + def _the_test(): + out_data = run_tvnamer( + with_files = [curtest['in'], ], + with_flags = ['--batch'], + ) + verify_out_data(out_data, [curtest['expected'], ]) + + _the_test.description = "test_simple_functionality_%s" % curtest['in'] + yield _the_test + + +@attr("functional") +def test_interactive_always_option(): + """Tests the "a" always rename option in interactive UI + """ + + input_files = [ + 'scrubs.s01e01.hdtv.fake.avi', + 'my.name.is.earl.s01e01.fake.avi', + 'a.fake.show.s12e24.fake.avi', + 'total.access.247.s01e01.avi'] + + expected_files = [ + 'Scrubs - [01x01] - My First Day.avi', + 'My Name Is Earl - [01x01] - Pilot.avi', + 'a fake show - [12x24].avi', + 'Total Access 24_7 - [01x01] - Episode #1.avi'] + + out_data = run_tvnamer( + with_files = input_files, + with_input = "1\na\n1\n1\n1\n") + + verify_out_data(out_data, expected_files) + + +@attr("functional") +def test_correct_filename(): + """If the filename is already correct, don't prompt + """ + + out_data = run_tvnamer( + with_files = ['Scrubs - [01x01] - My First Day.avi'], + with_input = "1\ny\n") + + expected_files = ['Scrubs - [01x01] - My First Day.avi'] + + verify_out_data(out_data, expected_files) + + +@attr("functional") +def test_filename_already_exists(): + """If the filename is already correct, don't prompt + """ + + out_data = run_tvnamer( + with_files = ['Scrubs - [01x01] - My First Day.avi', 'scrubs.s01e01.avi'], + with_input = "1\ny\n") + + expected_files = ['Scrubs - [01x01] - My First Day.avi', 'scrubs.s01e01.avi'] + + verify_out_data(out_data, expected_files) + + +@attr("functional") +def test_skipping_after_replacements(): + """When custom-replacement is specified, should still skip file if name is correct + """ + + conf = """ + {"batch": true, + "input_filename_replacements": [ + {"is_regex": false, + "match": "v", + "replacement": "u"} + ], + "output_filename_replacements": [ + {"is_regex": false, + "match": "u", + "replacement": "v"} + ] + } + """ + + out_data = run_tvnamer( + with_files = ['Scrvbs - [01x01] - My First Day.avi'], + with_config = conf, + with_input = "") + + expected_files = ['Scrvbs - [01x01] - My First Day.avi'] + + verify_out_data(out_data, expected_files) diff --git a/tests/test_limit_by_extension.py b/tests/test_limit_by_extension.py index 4058a8a..d0dfae5 100644 --- a/tests/test_limit_by_extension.py +++ b/tests/test_limit_by_extension.py @@ -13,8 +13,7 @@ def test_no_extensions(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "valid_extensions": []} """ @@ -35,8 +34,7 @@ def test_single_extensions(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "valid_extensions": ["mkv"]} """ @@ -57,8 +55,7 @@ def test_single_extension_with_subdirs(): """ conf = """ - {"always_rename": true, - "select_first": true, + {"batch": true, "valid_extensions": ["avi"], "recursive": true} """ diff --git a/tests/test_movingfiles.py b/tests/test_movingfiles.py index a6fcccd..004c96d 100644 --- a/tests/test_movingfiles.py +++ b/tests/test_movingfiles.py @@ -13,8 +13,7 @@ def test_simple_realtive_move(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "test/", + {"move_files_destination": "test/", "batch": true} """ @@ -34,8 +33,7 @@ def test_dynamic_destination(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "tv/%(seriesname)s/season %(seasonnumber)d/", + {"move_files_destination": "tv/%(seriesname)s/season %(seasonnumber)d/", "batch": true} """ @@ -55,7 +53,7 @@ def test_cli_destination(): out_data = run_tvnamer( with_files = ['scrubs.s01e01.avi'], - with_flags = ['--batch', '--move', '--movedestination=season %(seasonnumber)d/']) + with_flags = ['--batch', '--movedestination=season %(seasonnumber)d/']) expected_files = ['season 1/Scrubs - [01x01] - My First Day.avi'] @@ -68,15 +66,14 @@ def test_move_interactive_allyes(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "test", - "select_first": true} + {"move_files_destination": "test", + "batch": false} """ out_data = run_tvnamer( with_files = ['scrubs.s01e01.avi', 'scrubs.s01e02.avi'], with_config = conf, - with_input = "y\ny\ny\ny\n") + with_input = "1\ny\n1\ny\n") expected_files = ['test/Scrubs - [01x01] - My First Day.avi', 'test/Scrubs - [01x02] - My Mentor.avi'] @@ -90,44 +87,40 @@ def test_move_interactive_allno(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "test", - "select_first": true} + {"move_files_destination": "test", + "batch": false} """ out_data = run_tvnamer( with_files = ['scrubs.s01e01.avi', 'scrubs.s01e02.avi'], with_config = conf, - with_input = "y\nn\ny\nn\n") + with_input = "1\nn\n1\nn\n") - expected_files = ['Scrubs - [01x01] - My First Day.avi', - 'Scrubs - [01x02] - My Mentor.avi'] + expected_files = ['scrubs.s01e01.avi', 'scrubs.s01e02.avi'] verify_out_data(out_data, expected_files) @attr("functional") def test_move_interactive_somefiles(): - """Tests interactive UI allows not renaming some files, renaming/moving others + """Tests interactive UI allows not renaming some files Rename and move first file, don't rename second file (so no move), and rename but do not move last file (Input is: y/y, n, y/n) """ conf = """ - {"move_files_enable": true, - "move_files_destination": "test", - "select_first": true} + {"move_files_destination": "test", + "batch": false} """ out_data = run_tvnamer( - with_files = ['scrubs.s01e01.avi', 'scrubs.s01e02.avi', 'scrubs.s01e03.avi'], + with_files = ['scrubs.s01e01.avi', 'scrubs.s01e02.avi'], with_config = conf, - with_input = "y\ny\nn\ny\nn\n") + with_input = "1\ny\n1\nn\n") expected_files = ['test/Scrubs - [01x01] - My First Day.avi', - 'scrubs.s01e02.avi', - 'Scrubs - [01x03] - My Best Friend\'s Mistake.avi'] + 'scrubs.s01e02.avi'] verify_out_data(out_data, expected_files) @@ -138,8 +131,7 @@ def test_with_invalid_seriesname(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "%(seriesname)s", + {"move_files_destination": "%(seriesname)s", "batch": true, "windows_safe_filenames": true} """ @@ -159,8 +151,7 @@ def test_with_invalid_seriesname_test2(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "%(seriesname)s", + {"move_files_destination": "%(seriesname)s", "batch": true, "move_files_fullpath_replacements": [ {"is_regex": true, @@ -179,14 +170,34 @@ def test_with_invalid_seriesname_test2(): @attr("functional") -def test_move_files_lowercase_destination(): - """Test move_files_lowercase_destination configuration option. +def test_titlecase_dynamic_parts(): + """Test titlecase_dynamic_parts configuration option. + """ + + conf = """ + {"move_files_destination": "Test/This/%(seriesname)s/S%(seasonnumber)02d", + "titlecase_dynamic_parts": true, + "batch": true} + """ + + out_data = run_tvnamer( + with_files = ['scrubs.s01e01.This.Is.a.Test.avi'], + with_config = conf, + with_input = "") + + expected_files = ['Test/This/Scrubs/S01/Scrubs - [01x01] - My First Day.avi'] + + verify_out_data(out_data, expected_files) + + +@attr("functional") +def test_lowercase_dynamic_parts(): + """Test lowercase_dynamic_parts configuration option. """ conf = """ - {"move_files_enable": true, - "move_files_destination": "Test/This/%(seriesname)s/S%(seasonnumber)02d", - "move_files_lowercase_destination": true, + {"move_files_destination": "Test/This/%(seriesname)s/S%(seasonnumber)02d", + "lowercase_dynamic_parts": true, "batch": true} """ @@ -195,7 +206,29 @@ def test_move_files_lowercase_destination(): with_config = conf, with_input = "") - expected_files = ['Test/This/scrubs/S01/Scrubs - [01x01] - My First Day.avi'] + expected_files = ['Test/This/scrubs/S01/scrubs - [01x01] - my first day.avi'] + + verify_out_data(out_data, expected_files) + + +@attr("functional") +def test_lowercase_dynamic_parts_originalfilename(): + """Test lowercase_dynamic_parts does not change %(originalfilename) + """ + + conf = """ + {"move_files_destination": "Test/This/%(seriesname)s/S%(seasonnumber)02d/%(originalfilename)s", + "move_files_destination_is_filepath": true, + "lowercase_dynamic_parts": true, + "batch": true} + """ + + out_data = run_tvnamer( + with_files = ['scrubs.s01e01.This.Is.a.Test.avi'], + with_config = conf, + with_input = "") + + expected_files = ['Test/This/scrubs/S01/scrubs.s01e01.This.Is.a.Test.avi'] verify_out_data(out_data, expected_files) @@ -206,9 +239,8 @@ def test_move_date_based_episode(): """ conf = """ - {"move_files_enable": true, - "move_files_destination_date": "Test/%(seriesname)s/%(year)s/%(month)s/%(day)s", - "move_files_lowercase_destination": true, + {"move_files_destination_date": "Test/%(seriesname)s/%(year)s/%(month)s/%(day)s", + "lowercase_dynamic_parts": true, "batch": true} """ @@ -217,7 +249,7 @@ def test_move_date_based_episode(): with_config = conf, with_input = "") - expected_files = ['Test/The Colbert Report/2011/9/28/The Colbert Report - [2011-09-28] - Ken Burns.avi'] + expected_files = ['Test/the colbert report/2011/9/28/the colbert report - [2011-09-28] - ken burns.avi'] verify_out_data(out_data, expected_files) @@ -228,8 +260,7 @@ def test_move_files_full_filepath_simple(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "TestDir/%(seriesname)s/season %(seasonnumber)02d/%(episodenumbers)s/SpecificName.avi", + {"move_files_destination": "TestDir/%(seriesname)s/season %(seasonnumber)02d/%(episode)s/SpecificName.avi", "move_files_destination_is_filepath": true, "batch": true} """ @@ -250,8 +281,7 @@ def test_move_files_full_filepath_with_origfilename(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "TestDir/%(seriesname)s/season %(seasonnumber)02d/%(episodenumbers)s/%(originalfilename)s", + {"move_files_destination": "TestDir/%(seriesname)s/season %(seasonnumber)02d/%(episode)s/%(originalfilename)s", "move_files_destination_is_filepath": true, "batch": true} """ @@ -274,8 +304,7 @@ def test_move_with_correct_name(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "SubDir", + {"move_files_destination": "SubDir", "batch": true} """ @@ -295,8 +324,7 @@ def test_move_no_season(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "SubDir", + {"move_files_destination": "SubDir", "batch": true} """ @@ -310,37 +338,15 @@ def test_move_no_season(): verify_out_data(out_data, expected_files) -@attr("functional") -def test_move_files_only(): - """With parameter move_files_only set to true files should be moved and not renamed - """ - - conf = """ - {"move_files_only": true, - "move_files_enable": true, - "move_files_destination": "tv/%(seriesname)s/season %(seasonnumber)d/", - "batch": true} - """ - - out_data = run_tvnamer( - with_files = ['scrubs.s01e01.avi'], - with_config = conf) - - expected_files = ['tv/Scrubs/season 1/scrubs.s01e01.avi'] - - verify_out_data(out_data, expected_files) - - @attr("functional") def test_forcefully_moving_enabled(): """Forcefully moving files, overwriting destination """ conf = """ - {"move_files_enable": true, - "move_files_destination": "tv/%(seriesname)s/season %(seasonnumber)d/", + {"move_files_destination": "tv/%(seriesname)s/season %(seasonnumber)d/", "batch": true, - "overwrite_destination_on_move": true} + "overwrite_destination": true} """ out_data = run_tvnamer( @@ -358,19 +364,18 @@ def test_forcefully_moving_disabled(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "tv/%(seriesname)s/season %(seasonnumber)d/", + {"move_files_destination": ".", "batch": true, - "overwrite_destination_on_move": false} + "overwrite_destination": false} """ out_data = run_tvnamer( - with_files = ['scrubs.s01e01.avi', 'scrubs - [01x01].avi'], + with_files = ['scrubs.s01e01.avi', 'Scrubs - [01x01] - My First Day.avi'], with_config = conf) expected_files = [ - 'Scrubs - [01x01] - My First Day.avi', - 'tv/Scrubs/season 1/Scrubs - [01x01] - My First Day.avi'] + 'scrubs.s01e01.avi', + 'Scrubs - [01x01] - My First Day.avi'] verify_out_data(out_data, expected_files) @@ -381,17 +386,16 @@ def test_forcefully_moving_default(): """ conf = """ - {"move_files_enable": true, - "move_files_destination": "tv/%(seriesname)s/season %(seasonnumber)d/", + {"move_files_destination": ".", "batch": true} """ out_data = run_tvnamer( - with_files = ['scrubs.s01e01.avi', 'scrubs - [01x01].avi'], + with_files = ['scrubs.s01e01.avi', 'Scrubs - [01x01] - My First Day.avi'], with_config = conf) expected_files = [ - 'Scrubs - [01x01] - My First Day.avi', - 'tv/Scrubs/season 1/Scrubs - [01x01] - My First Day.avi'] + 'scrubs.s01e01.avi', + 'Scrubs - [01x01] - My First Day.avi'] verify_out_data(out_data, expected_files) diff --git a/tests/test_multiepisode_filenames.py b/tests/test_multiepisode_filenames.py index 1350aa6..6de2b00 100644 --- a/tests/test_multiepisode_filenames.py +++ b/tests/test_multiepisode_filenames.py @@ -30,7 +30,7 @@ def test_multiep_different_names(): with_config = conf, with_input = "") - expected_files = ['Star Trek - Enterprise - [01x03-04] - Fight or Flight, Strange New World.avi'] + expected_files = ['Star Trek_ Enterprise - [01x03-04] - Fight or Flight, Strange New World.avi'] verify_out_data(out_data, expected_files) @@ -56,7 +56,7 @@ def test_multiep_same_names(): with_config = conf, with_input = "") - expected_files = ['Star Trek - Enterprise - [01x01-02] - Broken Bow (1-2).avi'] + expected_files = ['Star Trek_ Enterprise - [01x01-02] - Broken Bow (1-2).avi'] verify_out_data(out_data, expected_files) @@ -83,6 +83,6 @@ def test_multiep_same_names_without_number(): with_config = conf, with_input = "") - expected_files = ['Star Trek - Deep Space Nine - [01x01-02] - Emissary (Parts 1-2).avi'] + expected_files = ['Star Trek_ Deep Space Nine - [01x01-02] - Emissary (Parts 1-2).avi'] verify_out_data(out_data, expected_files) diff --git a/tests/test_name_generation.py b/tests/test_name_generation.py index 33128b6..4c7365f 100644 --- a/tests/test_name_generation.py +++ b/tests/test_name_generation.py @@ -7,7 +7,7 @@ from helpers import assertEquals -from tvnamer.utils import (EpisodeInfo, DatedEpisodeInfo, NoSeasonEpisodeInfo) +from tvnamer.utils import EpisodeInfo from test_files import files from tvdb_api import Tvdb @@ -16,15 +16,21 @@ def verify_name_gen(curtest, tvdb_instance): if "seasonnumber" in curtest: ep = EpisodeInfo( + filename = curtest['input'], + eptype = 'default', seriesname = curtest['parsedseriesname'], seasonnumber = curtest['seasonnumber'], episodenumbers = curtest['episodenumbers']) elif any([isinstance(x, datetime.date) for x in curtest['episodenumbers']]): - ep = DatedEpisodeInfo( + ep = EpisodeInfo( + filename = curtest['input'], + eptype = 'dated', seriesname = curtest['parsedseriesname'], episodenumbers = curtest['episodenumbers']) else: - ep = NoSeasonEpisodeInfo( + ep = EpisodeInfo( + filename = curtest['input'], + eptype = 'noseason', seriesname = curtest['parsedseriesname'], episodenumbers = curtest['episodenumbers']) @@ -119,14 +125,14 @@ def test_multi_episodes_seperate(): def test_simple_no_ext(): - """Simple episode with out extension + """Simple episode without extension """ ep = EpisodeInfo( seriesname = 'Scrubs', seasonnumber = 1, episodenumbers = [2], episodename = 'My Mentor', - filename = None) + filename = '') assertEquals( ep.generateFilename(), @@ -140,7 +146,7 @@ def test_no_name(): seriesname = 'Scrubs', seasonnumber = 1, episodenumbers = [2], - episodename = None, + episodename = '', filename = 'scrubs.example.file.avi') assertEquals( @@ -155,8 +161,8 @@ def test_episode_no_name_no_ext(): seriesname = 'Scrubs', seasonnumber = 1, episodenumbers = [2], - episodename = None, - filename = None) + episodename = '', + filename = '') assertEquals( ep.generateFilename(), @@ -164,13 +170,14 @@ def test_episode_no_name_no_ext(): def test_noseason_no_name_no_ext(): - """NoSeasonEpisodeInfo with no name or extension + """NoSeason EpisodeInfo with no name or extension """ - ep = NoSeasonEpisodeInfo( + ep = EpisodeInfo( seriesname = 'Scrubs', + eptype = 'noseason', episodenumbers = [2], - episodename = None, - filename = None) + episodename = '', + filename = '') assertEquals( ep.generateFilename(), @@ -178,13 +185,14 @@ def test_noseason_no_name_no_ext(): def test_datedepisode_no_name_no_ext(): - """DatedEpisodeInfo with no name or extension + """Dated EpisodeInfo with no name or extension """ - ep = DatedEpisodeInfo( + ep = EpisodeInfo( seriesname = 'Scrubs', + eptype = 'dated', episodenumbers = [datetime.date(2010, 11, 23)], - episodename = None, - filename = None) + episodename = '', + filename = '') assertEquals( ep.generateFilename(), @@ -194,28 +202,13 @@ def test_datedepisode_no_name_no_ext(): def test_no_series_number(): """Episode without season number """ - ep = NoSeasonEpisodeInfo( + ep = EpisodeInfo( seriesname = 'Scrubs', + eptype = 'noseason', episodenumbers = [2], episodename = 'My Mentor', - filename = None) + filename = '') assertEquals( ep.generateFilename(), 'Scrubs - [02] - My Mentor') - - -def test_downcase(): - """Simple episode name, converted to lowercase - """ - - ep = EpisodeInfo( - seriesname = 'Scrubs', - seasonnumber = 1, - episodenumbers = [2], - episodename = 'My Mentor', - filename = 'scrubs.example.file.avi') - - assertEquals( - ep.generateFilename(lowercase = True), - 'scrubs - [01x02] - my mentor.avi') diff --git a/tests/test_no_series_in_filename.py b/tests/test_no_series_in_filename.py index f8a8878..13f14be 100644 --- a/tests/test_no_series_in_filename.py +++ b/tests/test_no_series_in_filename.py @@ -41,8 +41,7 @@ def test_name_arg_skips_replacements(): """ conf = r""" - {"always_rename": true, - "select_first": true, + {"batch": true, "force_name": "Scrubs", @@ -69,8 +68,7 @@ def test_replacements_applied_before_force_name(): """ conf = r""" - {"always_rename": true, - "select_first": true, + {"batch": true, "force_name": "Scrubs", diff --git a/tests/test_override_seriesname.py b/tests/test_override_seriesname.py index 9e116aa..8880775 100644 --- a/tests/test_override_seriesname.py +++ b/tests/test_override_seriesname.py @@ -13,8 +13,7 @@ def test_temp_override(): """ conf = """ - {"always_rename": true, - "select_first": true} + {"batch": true} """ out_data = run_tvnamer( diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 1dec619..8795a4e 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -5,7 +5,7 @@ from helpers import assertEquals -from tvnamer.utils import (FileParser, DatedEpisodeInfo, NoSeasonEpisodeInfo) +from tvnamer.utils import FileParser from test_files import files @@ -90,8 +90,8 @@ def cur_test(): assertEquals(p.episodenumbers, [name_data['epno']]) assertEquals(p.seriesname, name_data['seriesname']) # Only EpisodeInfo has seasonnumber - if not isinstance(p, (DatedEpisodeInfo, NoSeasonEpisodeInfo)): - assertEquals(p.seasonnumber, name_data['seasno']) + if p.eptype not in ['dated', 'noseason']: + assertEquals(int(p.seasonnumber), name_data['seasno']) #end cur_test cur_test.description = cdata['description'] @@ -112,8 +112,8 @@ def check_case(curtest): curtest['parsedseriesname'].lower()) assertEquals(theep.episodenumbers, curtest['episodenumbers']) - if not isinstance(theep, (DatedEpisodeInfo, NoSeasonEpisodeInfo)): - assertEquals(theep.seasonnumber, curtest['seasonnumber']) + if hasattr(curtest, 'seasonnumber'): + assertEquals(int(theep.seasonnumber), curtest['seasonnumber']) def test_parsing_generator(): diff --git a/tests/test_safefilename.py b/tests/test_safefilename.py index 4fac3e2..4b465bb 100644 --- a/tests/test_safefilename.py +++ b/tests/test_safefilename.py @@ -7,66 +7,66 @@ from helpers import assertEquals -from tvnamer.utils import makeValidFilename +from tvnamer.formatting import _makeValidFilename def test_basic(): - """Test makeValidFilename does not mess up simple filenames + """Test _makeValidFilename does not mess up simple filenames """ - assertEquals(makeValidFilename("test.avi"), "test.avi") - assertEquals(makeValidFilename("Test File.avi"), "Test File.avi") - assertEquals(makeValidFilename("Test"), "Test") + assertEquals(_makeValidFilename("test.avi"), "test.avi") + assertEquals(_makeValidFilename("Test File.avi"), "Test File.avi") + assertEquals(_makeValidFilename("Test"), "Test") def test_dirseperators(): - """Tests makeValidFilename removes directory separators + """Tests _makeValidFilename removes directory separators """ - assertEquals(makeValidFilename("Test/File.avi"), "Test_File.avi") - assertEquals(makeValidFilename("Test/File"), "Test_File") + assertEquals(_makeValidFilename("Test/File.avi"), "Test_File.avi") + assertEquals(_makeValidFilename("Test/File"), "Test_File") def test_windowsfilenames(): - """Tests makeValidFilename windows_safe flag makes Windows-safe filenames + """Tests _makeValidFilename windows_safe flag makes Windows-safe filenames """ - assertEquals(makeValidFilename("Test/File.avi", windows_safe = True), "Test_File.avi") - assertEquals(makeValidFilename("\\/:*?|\"", windows_safe = True), "______Evil___") - assertEquals(makeValidFilename("COM2.txt", windows_safe = True), "_COM2.txt") - assertEquals(makeValidFilename("COM2", windows_safe = True), "_COM2") + assertEquals(_makeValidFilename("Test/File.avi", windows_safe = True), "Test_File.avi") + assertEquals(_makeValidFilename("\\/:*?|\"", windows_safe = True), "______Evil___") + assertEquals(_makeValidFilename("COM2.txt", windows_safe = True), "_COM2.txt") + assertEquals(_makeValidFilename("COM2", windows_safe = True), "_COM2") def test_dotfilenames(): - """Tests makeValidFilename on filenames only consisting of . + """Tests _makeValidFilename on filenames only consisting of . """ - assertEquals(makeValidFilename("."), "_.") - assertEquals(makeValidFilename(".."), "_..") - assertEquals(makeValidFilename("..."), "_...") - assertEquals(makeValidFilename(".test.rc"), "_.test.rc") + assertEquals(_makeValidFilename("."), "_.") + assertEquals(_makeValidFilename(".."), "_..") + assertEquals(_makeValidFilename("..."), "_...") + assertEquals(_makeValidFilename(".test.rc"), "_.test.rc") def test_customblacklist(): - """Test makeValidFilename custom_blacklist feature + """Test _makeValidFilename custom_blacklist feature """ - assertEquals(makeValidFilename("Test.avi", custom_blacklist="e"), "T_st.avi") + assertEquals(_makeValidFilename("Test.avi", custom_blacklist="e"), "T_st.avi") def test_replacewith(): """Tests replacing blacklisted character with custom characters """ - assertEquals(makeValidFilename("My Test File.avi", custom_blacklist=" ", replace_with="."), "My.Test.File.avi") + assertEquals(_makeValidFilename("My Test File.avi", custom_blacklist=" ", replace_with="."), "My.Test.File.avi") def _test_truncation(max_len, windows_safe): """Tests truncation works correctly. Called with different parameters for both Windows and Darwin/Linux. """ - assertEquals(makeValidFilename("a" * 300, windows_safe = windows_safe), "a" * max_len) - assertEquals(makeValidFilename("a" * 255 + ".avi", windows_safe = windows_safe), "a" * (max_len-4) + ".avi") - assertEquals(makeValidFilename("a" * 251 + "b" * 10 + ".avi", windows_safe = windows_safe), "a" * (max_len-4) + ".avi") - assertEquals(makeValidFilename("test." + "a" * 255, windows_safe = windows_safe), "test." + "a" * (max_len-5)) + assertEquals(_makeValidFilename("a" * 300, windows_safe = windows_safe), "a" * max_len) + assertEquals(_makeValidFilename("a" * 255 + ".avi", windows_safe = windows_safe), "a" * (max_len-4) + ".avi") + assertEquals(_makeValidFilename("a" * 251 + "b" * 10 + ".avi", windows_safe = windows_safe), "a" * (max_len-4) + ".avi") + assertEquals(_makeValidFilename("test." + "a" * 255, windows_safe = windows_safe), "test." + "a" * (max_len-5)) def test_truncation_darwinlinux(): - """Tests makeValidFilename truncates filenames to valid length + """Tests _makeValidFilename truncates filenames to valid length """ if platform.system() not in ['Darwin', 'Linux']: diff --git a/tests/test_series_replacement.py b/tests/test_series_replacement.py index 32cca1f..b4f02f2 100644 --- a/tests/test_series_replacement.py +++ b/tests/test_series_replacement.py @@ -17,8 +17,7 @@ def test_replace_input(): { "input_series_replacements": { "scru*bs": "scrubs"}, - "always_rename": true, - "select_first": true + "batch": true } """) @@ -38,8 +37,7 @@ def test_replace_input_with_id(): { "input_series_replacements": { "seriesnamegoeshere": 76156}, - "always_rename": true, - "select_first": true + "batch": true } """) @@ -58,8 +56,7 @@ def test_replace_output(): { "output_series_replacements": { "Scrubs": "Replacement Series Name"}, - "always_rename": true, - "select_first": true + "batch": true } """) diff --git a/tests/test_unicode.py b/tests/test_unicode.py new file mode 100644 index 0000000..776537b --- /dev/null +++ b/tests/test_unicode.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python + +""" Test unicode support in tvnamer +""" + +from functional_runner import run_tvnamer, verify_out_data +from nose.plugins.attrib import attr + +import unicodedata + + +@attr("functional") +def test_unicode_in_inputname(): + """Tests parsing a file with unicode in the input filename + """ + input_files = [ + u'The Big Bang Theory - S02E07 - The Panty Pin\u0303ata Polarization.avi'] + + expected_files = [ + u'The Big Bang Theory - [02x07] - The Panty Pin\u0303ata Polarization.avi'] + + out_data = run_tvnamer( + with_files = input_files, + with_flags = ["--batch"]) + + verify_out_data(out_data, expected_files) + + +@attr("functional") +def test_unicode_in_search_results(): + """Show with unicode in search results + """ + input_files = [ + 'psych.s04e11.avi'] + + expected_files = [ + 'Psych - [04x11] - Thrill Seekers & Hell Raisers.avi'] + + out_data = run_tvnamer( + with_files = input_files, + with_input = '1\ny\n') + + verify_out_data(out_data, expected_files) + + +@attr("functional") +def test_not_overwritting_unicode_filename(): + """Test no error occurs when warning about a unicode filename being overwritten + """ + input_files = [ + u'The Big Bang Theory - S02E07.avi', + u'The Big Bang Theory - [02x07] - The Panty Pin\u0303ata Polarization.avi'] + + expected_files = [ + u'The Big Bang Theory - S02E07.avi', + u'The Big Bang Theory - [02x07] - The Panty Pin\u0303ata Polarization.avi'] + + out_data = run_tvnamer( + with_files = input_files, + with_flags = ['--batch']) + + verify_out_data(out_data, expected_files) + diff --git a/tvnamer/__init__.py b/tvnamer/__init__.py index 79cbb6b..e1ade24 100644 --- a/tvnamer/__init__.py +++ b/tvnamer/__init__.py @@ -6,5 +6,5 @@ "some.show.name.s01e01.blah.avi" to "Some Show Name - [01x01] - The First.avi" """ -__version__ = (2, 2, 1) +__version__ = "3.0.0" __author__ = "dbr/Ben" diff --git a/tvnamer/_titlecase.py b/tvnamer/_titlecase.py index 3b3b887..76e6394 100644 --- a/tvnamer/_titlecase.py +++ b/tvnamer/_titlecase.py @@ -69,7 +69,7 @@ def titlecase(text): match = MAC_MC.match(word) if match: tc_line.append("%s%s" % (match.group(1).capitalize(), - match.group(2).capitalize())) + match.group(2).capitalize())) continue if "/" in word and not "//" in word: diff --git a/tvnamer/cliarg_parser.py b/tvnamer/cliarg_parser.py index 2f1d94b..fb500ea 100644 --- a/tvnamer/cliarg_parser.py +++ b/tvnamer/cliarg_parser.py @@ -38,21 +38,16 @@ def getCommandlineParser(defaults): parser.set_defaults(**defaults) # Console output - with Group(parser, "Console output") as g: - g.add_option("-v", "--verbose", action="store_true", dest="verbose", help = "show debugging info") - g.add_option("-q", "--not-verbose", action="store_false", dest="verbose", help = "no verbose output (useful to override 'verbose':true in config file)") + with Group(parser, "Logging") as g: + g.add_option("-v", "--verbose", action = "store_true", dest = "verbose", help = "Show debugging info in console") + g.add_option("-q", "--not-verbose", action = "store_false", dest = "verbose", help = "No verbose output (useful to override 'verbose':true in config file)") + g.add_option("--log-file", action = "store", dest = "log_file", help = "Path to log file") # Batch options with Group(parser, "Batch options") as g: - g.add_option("-a", "--always", action="store_true", dest="always_rename", help = "Always renames files (but prompt for correct series)") - g.add_option("--not-always", action="store_true", dest="always_rename", help = "Overrides --always") - - g.add_option("-f", "--selectfirst", action="store_true", dest="select_first", help = "Select first series search result automatically") - g.add_option("--not-selectfirst", action="store_false", dest="select_first", help = "Overrides --selectfirst") - - g.add_option("-b", "--batch", action="store_true", dest = "batch", help = "Rename without human intervention, same as --always and --selectfirst combined") - g.add_option("--not-batch", action="store_false", dest = "batch", help = "Overrides --batch") + g.add_option("-b", "--batch", action = "store_true", dest = "batch", help = "Rename without human intervention") + g.add_option("--not-batch", action = "store_false", dest = "batch", help = "Overrides --batch") # Config options @@ -63,21 +58,17 @@ def getCommandlineParser(defaults): # Override values with Group(parser, "Override values") as g: - g.add_option("-n", "--name", action="store", dest = "force_name", help = "override the parsed series name with this (applies to all files)") - g.add_option("--series-id", action="store", dest = "series_id", help = "explicitly set the show id for TVdb to use (applies to all files)") + g.add_option("-n", "--name", action = "store", dest = "force_name", help = "override the parsed series name with this (applies to all files)") + g.add_option("--series-id", action = "store", dest = "series_id", help = "explicitly set the show id for TVdb to use (applies to all files)") # Misc with Group(parser, "Misc") as g: - g.add_option("-r", "--recursive", action="store_true", dest = "recursive", help = "Descend more than one level directories supplied as arguments") - g.add_option("--not-recursive", action="store_false", dest = "recursive", help = "Only descend one level into directories") - - g.add_option("-m", "--move", action="store_true", dest="move_files_enable", help = "Move files to destination specified in config or with --movedestination argument") - g.add_option("--not-move", action="store_false", dest="move_files_enable", help = "Files will remain in current directory") - - g.add_option("-d", "--movedestination", action="store", dest = "move_files_destination", help = "Destination to move files to. Variables: %(seriesname)s %(seasonnumber)d %(episodenumbers)s") + g.add_option("-r", "--recursive", action = "store_true", dest = "recursive", help = "Descend more than one level directories supplied as arguments") + g.add_option("--not-recursive", action = "store_false", dest = "recursive", help = "Only descend one level into directories") - g.add_option("-h", "--help", action="help", help = "show this help message and exit") + g.add_option("-d", "--movedestination", action = "store", dest = "move_files_destination", help = "Destination to move files to. Variables: %(seriesname)s %(seasonnumber)d %(episodenumbers)s") + g.add_option("-h", "--help", action = "help", help = "show this help message and exit") return parser diff --git a/tvnamer/config.py b/tvnamer/config.py index 3ac0dfc..51e0563 100644 --- a/tvnamer/config.py +++ b/tvnamer/config.py @@ -3,6 +3,4 @@ """Holds Config singleton """ -from config_defaults import defaults - -Config = dict(defaults) +from config_defaults import defaults as Config diff --git a/tvnamer/config_defaults.py b/tvnamer/config_defaults.py index a66cdb1..e27b933 100644 --- a/tvnamer/config_defaults.py +++ b/tvnamer/config_defaults.py @@ -3,28 +3,39 @@ """Holds default config values """ +from __init__ import __version__ + defaults = { - # Select first series search result - 'select_first': False, + # Config file version - must be greater or equal to program version. + # If lower, see https://github.com/dbr/tvnamer/blob/master/tvnamer/config_defaults.py and merge updates. + '__version__': __version__, - # Always rename files - 'always_rename': False, + # Do not perform any file operations, only preview changes + 'dry_run': True, - # Batch (same as select_first and always_rename) + # Operation mode - True for batch mode, False for interactive mode + # In batch mode first search result is selected, in interactive mode user is asked to select 'batch': False, - # Fail if error finding show data (thetvdb.com is down etc) - # Only functions when always_rename is True + # Skip files if finding show data failed (thetvdb.com is down etc) + # Applied only in batch mode 'skip_file_on_error': True, + # Exit tvnamer (with non-zero return code) if finding show data failed (thetvdb.com is down etc) + # Applied only in batch mode, overrides skip_file_on_error + 'exit_on_error': False, + # Forcefully overwrite existing files when renaming or # moving. This potentially destroys the old file. Default is False - 'overwrite_destination_on_rename': False, - 'overwrite_destination_on_move': False, + 'overwrite_destination': False, - # Verbose mode (debugging info) + # Increase verbosity level of console logging 'verbose': False, + # Path of log file - always stores all debug info, regardless of the 'verbose' option. + # If empty string, logging to file is disabled. + 'log_file': '', + # Recurse more than one level into folders. When False, only # desends one level. 'recursive': False, @@ -51,13 +62,20 @@ # Replace accented unicode characters with ASCII equivalents, # removing characters than can't be translated. + # Applied only when 'windows_safe_filenames' is True. 'normalize_unicode_filenames': False, - # Convert output filenames to lower case (applied after replacements) - 'lowercase_filename': False, + # Convert dynamic parts to lower case. Does not affect the static parts. + # For example, if move_files_destination is set to + # '/Foo/Bar/%(seriesname)s/Season %(seasonnumber)d' + # then only the series name will be converted to lower case. + 'lowercase_dynamic_parts': False, + + # Convert dynamic parts to 'Title Case' + 'titlecase_dynamic_parts': False, - # Convert output filenames to 'Title Case' (applied after replacements) - 'titlecase_filename': False, + # Convert output filenames to lower case, including static parts (applied after replacements) + 'lowercase_filename': False, # Extra characters to consider invalid in output filenames (which are # replaced by the character in replace_invalid_characters_with) @@ -93,27 +111,21 @@ # prompt. 'move_files_confirmation': True, - # If true, convert the variable/dynamic parts of the destination - # to lower case. Does not affect the static parts; for example, - # if move_files_destination is set to - # '/Foo/Bar/%(seriesname)s/Season %(seasonnumber)d' - # then only the series name will be converted to lower case. - 'move_files_lowercase_destination': False, - # If True, the destination path includes the destination filename, # for example: '/example/tv/%(seriesname)s/season %(seasonnumber)d/%(originalfilename)' 'move_files_destination_is_filepath': False, # Destination to move files to. Trailing slash is not necessary. # Use forward slashes, even on Windows. Realtive paths are realtive to - # the existing file's path (not current working dir). A value of '.' will - # not move the file anywhere. + # the existing file's path (not current working dir). + # Default value is '.', which will not move the file anywhere. # # Use Python's string formatting to add dynamic paths. Available variables: - # - %(seriesname)s - # - %(seasonnumber)d - # - %(episodenumbers)s (Note: this is a string, formatted with config - # variable episode_single and joined with episode_separator) + # - %(seriesname)s Name of the series + # - %(seasonnumber)d Number of season + # - %(episode)s String containing formatted episode number(s) + # - %(originalfilename)s Original filename, including extension + # - %(ext)s Extension of the file 'move_files_destination': '.', # Same as above, only for date-numbered episodes. The following @@ -132,13 +144,17 @@ # volume, after the copy has complete. 'always_move': False, + # Force the copy-files feature to always copy the file. + # + # If True, original file is left behind, even if destination is + # on the same partition. If False and destination is on the same partition + # as source file, file is moved. + 'always_copy': False, + # Whenever a file is moved leave a symlink to the new file behind, named # after the original file. 'leave_symlink': False, - # Allow user to copy files to specified move location without renaming files. - 'move_files_only': False, - # Patterns to parse input filenames with 'filename_patterns': [ # [group] Show - 01-02 [crc] @@ -342,9 +358,9 @@ (?:part|pt)?[\._ -] (?P[0-9]+) # Part 1 (?: - [ \._-](?:and|&|to) # and - [ \._-](?:part|pt)? # Part 2 - [ \._-](?:[0-9]+))* # (middle group, optional, repeating) + [ \._-](?:and|&|to) # and + [ \._-](?:part|pt)? # Part 2 + [ \._-](?:[0-9]+))* # (middle group, optional, repeating) [ \._-](?:and|&|to) # and [ \._-]?(?:part|pt)? # Part 3 [ \._-](?P[0-9]+) # last episode number, save it @@ -352,17 +368,18 @@ ''', # Show.Name.Part1 - '''^(?P.+?) # Show name\n - [ \\._\\-] # Padding\n - [Pp]art[ ](?P[0-9]+) # Part 1\n - [\\._ -][^\\/]*$ # More padding, then anything\n + '''^(?P.+?) # Show name + [ \\._\\-] # Padding + [Pp]art[ ](?P[0-9]+) # Part 1 + [\\._ -][^\\/]*$ # More padding, then anything ''', # show name Season 01 Episode 20 '''^(?P.+?)[ ]? # Show name [Ss]eason[ ]?(?P[0-9]+)[ ]? # Season 1 [Ee]pisode[ ]?(?P[0-9]+) # Episode 20 - [^\\/]*$''', # Anything + [^\\/]*$ # Anything + ''', # foo.103* '''^(?P.+)[ \._\-] @@ -393,7 +410,7 @@ # Seasonless filenames. 'filename_with_episode_no_season': - '%(seriesname)s - [%(episode)s] - %(episodename)s%(ext)s', + '%(seriesname)s - [%(episode)s] - %(episodename)s%(ext)s', 'filename_without_episode_no_season': '%(seriesname)s - [%(episode)s]%(ext)s', @@ -421,7 +438,6 @@ '[%(group)s] %(seriesname)s - %(episode)s%(ext)s', - # Used to join multiple episode names together (only when episode names are different) 'multiep_join_name_with': ', ', diff --git a/tvnamer/formatting.py b/tvnamer/formatting.py new file mode 100644 index 0000000..9846644 --- /dev/null +++ b/tvnamer/formatting.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python + +import os +import re +import logging +import platform +import datetime + +from unicode_helper import p +from config import Config + + +__all__ = ['makeValidFilename', 'formatEpisodeNames', 'formatEpisodeNumbers'] + + +def log(): + """Returns the logger for current file + """ + return logging.getLogger(__name__) + + +def _makeValidFilename(value, normalize_unicode=False, windows_safe=False, custom_blacklist=None, replace_with="_"): + """ + Takes a string and makes it into a valid filename. + + normalize_unicode replaces accented characters with ASCII equivalent, and + removes characters that cannot be converted sensibly to ASCII. + + windows_safe forces Windows-safe filenames, regardless of current platform + + custom_blacklist specifies additional characters that will removed. This + will not touch the extension separator: + + >>> makeValidFilename("T.est.avi", custom_blacklist=".") + 'T_est.avi' + """ + + if windows_safe: + # Allow user to make Windows-safe filenames, if they so choose + sysname = "Windows" + else: + sysname = platform.system() + + # If the filename starts with a . prepend it with an underscore, so it + # doesn't become hidden. + + # This is done before calling splitext to handle filename of ".", as + # splitext acts differently in python 2.5 and 2.6 - 2.5 returns ('', '.') + # and 2.6 returns ('.', ''), so rather than special case '.', this + # special-cases all files starting with "." equally (since dotfiles have + # no extension) + if value.startswith("."): + value = "_" + value + + # Treat extension seperatly + value, extension = os.path.splitext(value) + + # Remove any null bytes + value = value.replace("\0", "") + + # Blacklist of characters + if sysname == 'Darwin': + # : is technically allowed, but Finder will treat it as / and will + # generally cause weird behaviour, so treat it as invalid. + blacklist = r"/:" + elif sysname in ['Linux', 'FreeBSD']: + blacklist = r"/" + else: + # platform.system docs say it could also return "Windows" or "Java". + # Failsafe and use Windows sanitisation for Java, as it could be any + # operating system. + blacklist = r"\/:*?\"<>|" + + # Append custom blacklisted characters + if custom_blacklist is not None: + blacklist += custom_blacklist + + # Replace every blacklisted character with a underscore + value = re.sub("[%s]" % re.escape(blacklist), replace_with, value) + + # Remove any trailing whitespace + value = value.strip() + + # There are a bunch of filenames that are not allowed on Windows. + # As with character blacklist, treat non Darwin/Linux platforms as Windows + if sysname not in ['Darwin', 'Linux']: + invalid_filenames = [ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", + "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", + "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"] + if value in invalid_filenames: + value = "_" + value + + # Replace accented characters with ASCII equivalent + if normalize_unicode: + import unicodedata + value = unicode(value) # cast data to unicode + value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore') + + # Truncate filenames to valid/sane length. + # NTFS is limited to 255 characters, HFS+ and EXT3 don't seem to have + # limits, FAT32 is 254. I doubt anyone will take issue with losing that + # one possible character, and files over 254 are pointlessly unweidly + max_len = 254 + + if len(value + extension) > max_len: + if len(extension) > len(value): + # Truncate extension instead of filename, no extension should be + # this long.. + new_length = max_len - len(value) + extension = extension[:new_length] + else: + # File name is longer than extension, truncate filename. + new_length = max_len - len(extension) + value = value[:new_length] + + return value + extension + + +def makeValidFilename(fname): + """ Wraps the _makeValidFilename() function, loads arguments from config. + """ + return _makeValidFilename( + fname, + normalize_unicode=Config['normalize_unicode_filenames'], + windows_safe=Config['windows_safe_filenames'], + custom_blacklist=Config['custom_filename_character_blacklist'], + replace_with=Config['replace_invalid_characters_with']) + + +def formatEpisodeNames(names): + """ + Takes a list of episode names, formats them into a string. + If argument is not a list, it is returned as is. + + If two names are supplied, such as "Pilot (1)" and "Pilot (2)", the + returned string will be "Pilot (1-2)". Note that the first number + is not required, for example passing "Pilot" and "Pilot (2)" will + also result in returning "Pilot (1-2)". + + If two different episode names are found, such as "The first", and + "Something else" it will return "The first, Something else" + """ + + if not isinstance(names, list): + return names + if len(names) == 1: + return names[0] + + join_with = Config['multiep_join_name_with'] + multiep_format = Config['multiep_format'] + + found_name = "" + numbers = [] + for cname in names: + match = re.match("(.*) \(([0-9]+)\)$", cname) + if found_name != "" and not match: + # An episode didn't match + return join_with.join(names) + + if match: + epname, epno = match.group(1), match.group(2) + else: # assume that this is the first episode, without number + epname = cname + epno = 1 + found_name = epname + numbers.append(int(epno)) + + return multiep_format % {'epname': found_name, 'episodemin': min(numbers), 'episodemax': max(numbers)} + + +def formatEpisodeNumbers(episodenumbers): + """Format episode number(s) into string, using configured values + """ + if len(episodenumbers) == 1: + if isinstance(episodenumbers[0], datetime.date): + # format dated episode + epno = str(episodenumbers[0]) + else: + # format normal episode + epno = Config['episode_single'] % episodenumbers[0] + else: + epno = Config['episode_separator'].join( + Config['episode_single'] % x for x in episodenumbers) + + return epno diff --git a/tvnamer/main.py b/tvnamer/main.py index c156d82..63342b8 100644 --- a/tvnamer/main.py +++ b/tvnamer/main.py @@ -1,130 +1,45 @@ #!/usr/bin/env python -"""Main tvnamer utility functionality +""" Main tvnamer utility functionality """ import os import sys import logging -import warnings - -try: - import readline -except ImportError: - pass try: import json except ImportError: import simplejson as json +from __init__ import __version__ from tvdb_api import Tvdb import cliarg_parser -from config_defaults import defaults +from config import Config from unicode_helper import p -from utils import (Config, FileFinder, FileParser, Renamer, warn, -applyCustomInputReplacements, formatEpisodeNumbers, makeValidFilename, -DatedEpisodeInfo, NoSeasonEpisodeInfo) +from utils import FileFinder, FileParser, applyCustomInputReplacements -from tvnamer_exceptions import (ShowNotFound, SeasonNotFound, EpisodeNotFound, -EpisodeNameNotFound, UserAbort, InvalidPath, NoValidFilesFoundError, -InvalidFilename, DataRetrievalError) +from tvnamer_exceptions import (ConfigValueError, ShowNotFound, SeasonNotFound, EpisodeNotFound, + EpisodeNameNotFound, UserAbort, InvalidPath, NoValidFilesFoundError, + InvalidFilename, DataRetrievalError) + +from renamer import Renamer def log(): - """Returns the logger for current file + """ Returns the logger for current file """ return logging.getLogger(__name__) -def getMoveDestination(episode): - """Constructs the location to move/copy the file - """ - - #TODO: Write functional test to ensure this valid'ifying works - def wrap_validfname(fname): - """Wrap the makeValidFilename function as it's called twice - and this is slightly long.. - """ - if Config['move_files_lowercase_destination']: - fname = fname.lower() - return makeValidFilename( - fname, - normalize_unicode = Config['normalize_unicode_filenames'], - windows_safe = Config['windows_safe_filenames'], - custom_blacklist = Config['custom_filename_character_blacklist'], - replace_with = Config['replace_invalid_characters_with']) - - - # Calls makeValidFilename on series name, as it must valid for a filename - if isinstance(episode, DatedEpisodeInfo): - print Config['move_files_destination_date'] - destdir = Config['move_files_destination_date'] % { - 'seriesname': makeValidFilename(episode.seriesname), - 'year': episode.episodenumbers[0].year, - 'month': episode.episodenumbers[0].month, - 'day': episode.episodenumbers[0].day, - 'originalfilename': episode.originalfilename, - } - elif isinstance(episode, NoSeasonEpisodeInfo): - destdir = Config['move_files_destination'] % { - 'seriesname': wrap_validfname(episode.seriesname), - 'episodenumbers': wrap_validfname(formatEpisodeNumbers(episode.episodenumbers)), - 'originalfilename': episode.originalfilename, - } - else: - destdir = Config['move_files_destination'] % { - 'seriesname': wrap_validfname(episode.seriesname), - 'seasonnumber': episode.seasonnumber, - 'episodenumbers': wrap_validfname(formatEpisodeNumbers(episode.episodenumbers)), - 'originalfilename': episode.originalfilename, - } - return destdir - - -def doRenameFile(cnamer, newName): - """Renames the file. cnamer should be Renamer instance, - newName should be string containing new filename. - """ - try: - cnamer.newPath(new_fullpath = newName, force = Config['overwrite_destination_on_rename'], leave_symlink = Config['leave_symlink']) - except OSError, e: - warn(e) - - -def doMoveFile(cnamer, destDir = None, destFilepath = None, getPathPreview = False): - """Moves file to destDir, or to destFilepath +def confirm(question, options, default="y"): + """ Takes a question (string), list of options and a default value (used + when user simply hits enter). + Asks until valid option is entered. """ - if (destDir is None and destFilepath is None) or (destDir is not None and destFilepath is not None): - raise ValueError("Specify only destDir or destFilepath") - - if not Config['move_files_enable']: - raise ValueError("move_files feature is disabled but doMoveFile was called") - - if Config['move_files_destination'] is None: - raise ValueError("Config value for move_files_destination cannot be None if move_files_enabled is True") - - try: - return cnamer.newPath( - new_path = destDir, - new_fullpath = destFilepath, - always_move = Config['always_move'], - leave_symlink = Config['leave_symlink'], - getPathPreview = getPathPreview, - force = Config['overwrite_destination_on_move']) - - except OSError, e: - warn(e) - - -def confirm(question, options, default = "y"): - """Takes a question (string), list of options and a default value (used - when user simply hits enter). - Asks until valid option is entered. - """ # Highlight default option with [ ] options_str = [] for x in options: @@ -150,132 +65,89 @@ def confirm(question, options, default = "y"): def processFile(tvdb_instance, episode): - """Gets episode name, prompts user for input + """ Gets episode name, prompts user for input """ - p("#" * 20) - p("# Processing file: %s" % episode.fullfilename) - if len(Config['input_filename_replacements']) > 0: - replaced = applyCustomInputReplacements(episode.fullfilename) - p("# With custom replacements: %s" % (replaced)) + p("Processing file: '%s'" % episode.fullfilename) - # Use force_name option. Done after input_filename_replacements so - # it can be used to skip the replacements easily - if Config['force_name'] is not None: - episode.seriesname = Config['force_name'] + if len(Config['input_filename_replacements']) > 0: + p("After input replacements: '%s'" % applyCustomInputReplacements(episode.fullfilename)) - p("# Detected series: %s (%s)" % (episode.seriesname, episode.number_string())) + p("Detected series: %s (%s)" % (episode.seriesname, episode.number_string())) + p("") try: episode.populateFromTvdb(tvdb_instance, force_name=Config['force_name'], series_id=Config['series_id']) - except (DataRetrievalError, ShowNotFound), errormsg: - if Config['always_rename'] and Config['skip_file_on_error'] is True: - warn("Skipping file due to error: %s" % errormsg) - return - else: - warn(errormsg) - except (SeasonNotFound, EpisodeNotFound, EpisodeNameNotFound), errormsg: - # Show was found, so use corrected series name - if Config['always_rename'] and Config['skip_file_on_error']: - warn("Skipping file due to error: %s" % errormsg) + except (DataRetrievalError, ShowNotFound, SeasonNotFound, EpisodeNotFound, EpisodeNameNotFound), errormsg: + log().warn(errormsg) + if Config['batch'] and Config['exit_on_error']: + sys.exit(1) + if Config['batch'] and Config['skip_file_on_error']: + log().warn("Skipping file due to previous error.") return - warn(errormsg) - - cnamer = Renamer(episode.fullpath) - - - shouldRename = False - - if Config["move_files_only"]: - - newName = episode.fullfilename - shouldRename = True - - else: - newName = episode.generateFilename() - if newName == episode.fullfilename: - p("#" * 20) - p("Existing filename is correct: %s" % episode.fullfilename) - p("#" * 20) - - shouldRename = True - - else: - p("#" * 20) - p("Old filename: %s" % episode.fullfilename) - - if len(Config['output_filename_replacements']) > 0: - # Show filename without replacements - p("Before custom output replacements: %s" % (episode.generateFilename(preview_orig_filename = False))) - - p("New filename: %s" % newName) - - if Config['always_rename']: - doRenameFile(cnamer, newName) - if Config['move_files_enable']: - if Config['move_files_destination_is_filepath']: - doMoveFile(cnamer = cnamer, destFilepath = getMoveDestination(episode)) - else: - doMoveFile(cnamer = cnamer, destDir = getMoveDestination(episode)) - return - - ans = confirm("Rename?", options = ['y', 'n', 'a', 'q'], default = 'y') - - if ans == "a": - p("Always renaming") - Config['always_rename'] = True - shouldRename = True - elif ans == "q": - p("Quitting") - raise UserAbort("User exited with q") - elif ans == "y": - p("Renaming") - shouldRename = True - elif ans == "n": - p("Skipping") - else: - p("Invalid input, skipping") + newFullPath = episode.getNewFullPath() + p("") - if shouldRename: - doRenameFile(cnamer, newName) + p("Old directory: '%s'" % os.path.dirname(episode.fullpath)) + p("New directory: '%s'" % os.path.dirname(newFullPath)) + p("") + p("Old filename: '%s'" % episode.fullfilename) + p("New filename: '%s'" % os.path.split(newFullPath)[1]) + p("") - if shouldRename and Config['move_files_enable']: - newPath = getMoveDestination(episode) - if Config['move_files_destination_is_filepath']: - doMoveFile(cnamer = cnamer, destFilepath = newPath, getPathPreview = True) - else: - doMoveFile(cnamer = cnamer, destDir = newPath, getPathPreview = True) + # don't do anything if filename was not changed + if newFullPath == episode.fullpath: + p("Existing filename is correct: '%s'" % episode.fullpath) + return - if not Config['batch'] and Config['move_files_confirmation']: - ans = confirm("Move file?", options = ['y', 'n', 'q'], default = 'y') + if not Config['batch'] and Config['move_files_confirmation']: + ans = confirm("Move file?", options=['y', 'n', 'a', 'q'], default='y') + if ans == "a": + p("Always moving files") + Config['move_files_confirmation'] = False + elif ans == "q": + p("Quitting") + raise UserAbort("User exited with q") + elif ans == "y": + p("Renaming") + elif ans == "n": + p("Skipping") + return else: - ans = 'y' + p("Invalid input, skipping") + return - if ans == 'y': - p("Moving file") - doMoveFile(cnamer, newPath) - elif ans == 'q': - p("Quitting") - raise UserAbort("user exited with q") + # finally move file + cnamer = Renamer(episode.fullpath) + try: + cnamer.rename( + new_fullpath=newFullPath, + always_move=Config['always_move'], + always_copy=Config['always_copy'], + leave_symlink=Config['leave_symlink'], + force=Config['overwrite_destination']) + except OSError, e: + log().warn(e) def findFiles(paths): - """Takes an array of paths, returns all files found + """ Takes an array of paths, returns all files found """ + valid_files = [] for cfile in paths: cur = FileFinder( cfile, - with_extension = Config['valid_extensions'], - filename_blacklist = Config["filename_blacklist"], - recursive = Config['recursive']) + with_extension=Config['valid_extensions'], + filename_blacklist=Config["filename_blacklist"], + recursive=Config['recursive']) try: valid_files.extend(cur.findFiles()) except InvalidPath: - warn("Invalid path: %s" % cfile) + log().warn("Invalid path: %s" % cfile) if len(valid_files) == 0: raise NoValidFilesFoundError() @@ -287,12 +159,9 @@ def findFiles(paths): def tvnamer(paths): - """Main tvnamer function, takes an array of paths, does stuff. + """ Main tvnamer function, takes an array of paths, does stuff. """ - p("#" * 20) - p("# Starting tvnamer") - episodes_found = [] for cfile in findFiles(paths): @@ -300,51 +169,92 @@ def tvnamer(paths): try: episode = parser.parse() except InvalidFilename, e: - warn("Invalid filename: %s" % e) + log().warn("Invalid filename: %s" % e) else: if episode.seriesname is None and Config['force_name'] is None and Config['series_id'] is None: - warn("Parsed filename did not contain series name (and --name or --series-id not specified), skipping: %s" % cfile) - + log().warn("Parsed filename did not contain series name (and --name or --series-id not specified), skipping: %s" % cfile) else: episodes_found.append(episode) if len(episodes_found) == 0: raise NoValidFilesFoundError() - p("# Found %d episode" % len(episodes_found) + ("s" * (len(episodes_found) > 1))) + p("Found %d episode" % len(episodes_found) + ("s" * (len(episodes_found) > 1))) # Sort episodes by series name, season and episode number - episodes_found.sort(key = lambda x: x.sortable_info()) + episodes_found.sort(key=lambda x: x.sortable_info()) tvdb_instance = Tvdb( - interactive = not Config['select_first'], - search_all_languages = Config['search_all_languages'], - language = Config['language']) + interactive=not Config['batch'], + search_all_languages=Config['search_all_languages'], + language=Config['language']) for episode in episodes_found: + p("") + p("#" * 20) processFile(tvdb_instance, episode) - p('') + p("#" * 20) + + +class Logger: + """ Helper class holding logging handlers, formatters etc. so that + they can be added or removed at runtime. + """ + + def __init__(self): + self.consoleFormatter = logging.Formatter('%(levelname)s - %(message)s') + self.fileFormatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + self.rootLogger = logging.getLogger() + self.rootLogger.setLevel(logging.DEBUG) + self.consoleHandler = None + self.fileHandler = None + + def initLogging(self, verbose_console=False, filename=""): + """ Init logging to console and file specified by 'filename' argument. + Maximum log level of console can be configured by 'consoleLogLevel' argument, + log level of file is always DEBUG. + """ + + self.rootLogger.removeHandler(self.consoleHandler) + self.rootLogger.removeHandler(self.fileHandler) + + # create console handler with INFO log level + self.consoleHandler = logging.StreamHandler() + if verbose_console: + self.consoleHandler.setLevel(logging.DEBUG) + else: + self.consoleHandler.setLevel(logging.INFO) + self.consoleHandler.setFormatter(self.consoleFormatter) + self.rootLogger.addHandler(self.consoleHandler) + + if filename: + # create file handler with DEBUG log level + self.fileHandler = logging.FileHandler(filename) + self.fileHandler.setLevel(logging.DEBUG) + self.fileHandler.setFormatter(self.fileFormatter) + self.rootLogger.addHandler(self.fileHandler) - p("#" * 20) - p("# Done") + def __del__(self): + log().debug("tvnamer exited") + logging.shutdown() def main(): - """Parses command line arguments, displays errors from tvnamer in terminal + """ Parses command line arguments, displays errors from tvnamer in terminal """ - opter = cliarg_parser.getCommandlineParser(defaults) + logger = Logger() + logger.initLogging() + + opter = cliarg_parser.getCommandlineParser(Config) opts, args = opter.parse_args() - if opts.verbose: - logging.basicConfig( - level = logging.DEBUG, - format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s") - else: - logging.basicConfig() + logger.initLogging(verbose_console=opts.verbose, filename=opts.log_file) + log().debug("tvnamer started") - # If a config is specified, load it, update the defaults using the loaded - # values, then reparse the options with the updated defaults. + # If a config is specified, load it, update the Config using the loaded + # values, then reparse the options with the updated Config. default_configuration = os.path.expanduser("~/.tvnamer.json") if opts.loadconfig is not None: @@ -361,61 +271,73 @@ def main(): p("Loading config: %s" % (configToLoad)) try: loadedConfig = json.load(open(os.path.expanduser(configToLoad))) + config_version = loadedConfig.get("__version__") or "0" + if cmp(__version__, config_version): + msg = "Old config file detected, please see " + msg += "https://github.com/dbr/tvnamer/blob/master/tvnamer/config_defaults.py" + msg += " and/or " + msg += "https://github.com/dbr/tvnamer/blob/master/Changelog" + msg += " and merge updates.\nProgram version: %s\nConfig version: %s" % (__version__, config_version) + raise ConfigValueError(msg) except ValueError, e: p("Error loading config: %s" % e) opter.exit(1) + except ConfigValueError, e: + log().error("Error in config: " + e.message) + opter.exit(1) else: - # Config loaded, update optparser's defaults and reparse - defaults.update(loadedConfig) - opter = cliarg_parser.getCommandlineParser(defaults) + # Config loaded, update optparser's Config and reparse + Config.update(loadedConfig) + opter = cliarg_parser.getCommandlineParser(Config) opts, args = opter.parse_args() + # log file path may be specified in config + logger.initLogging(verbose_console=opts.verbose, filename=opts.log_file) # Decode args using filesystem encoding (done after config loading # as the args are reparsed when the config is loaded) args = [x.decode(sys.getfilesystemencoding()) for x in args] - # Save config argument - if opts.saveconfig is not None: - p("Saving config: %s" % (opts.saveconfig)) + # dump config into file or stdout + if opts.saveconfig or opts.showconfig: configToSave = dict(opts.__dict__) del configToSave['saveconfig'] del configToSave['loadconfig'] del configToSave['showconfig'] - json.dump( - configToSave, - open(os.path.expanduser(opts.saveconfig), "w+"), - sort_keys=True, - indent=4) - opter.exit(0) + # Save config argument + if opts.saveconfig: + p("Saving config: %s" % (opts.saveconfig)) + json.dump( + configToSave, + open(os.path.expanduser(opts.saveconfig), "w+"), + sort_keys=True, + indent=4) - # Show config argument - if opts.showconfig: - print json.dumps(opts.__dict__, sort_keys=True, indent=2) - return + # Show config argument + elif opts.showconfig: + p(json.dumps(opts.__dict__, sort_keys=True, indent=2)) - # Process values - if opts.batch: - opts.select_first = True - opts.always_rename = True + return # Update global config object Config.update(opts.__dict__) - if Config["move_files_only"] and not Config["move_files_enable"]: - p("#" * 20) - p("Parameter move_files_enable cannot be set to false while parameter move_only is set to true.") - p("#" * 20) - opter.exit(0) - - if Config['titlecase_filename'] and Config['lowercase_filename']: - warnings.warn("Setting 'lowercase_filename' clobbers 'titlecase_filename' option") + # TODO: write function to check all exclusive options + try: + if Config['always_copy'] and Config['always_move']: + raise ConfigValueError("Both always_copy and always_move cannot be specified.") + if Config['titlecase_dynamic_parts'] and Config['lowercase_dynamic_parts']: + raise ConfigValueError("Both 'lowercase_filename' and 'titlecase_filename' cannot be specified.") + except ConfigValueError, e: + log().error("Error in config: " + e.message) + opter.exit(1) if len(args) == 0: opter.error("No filenames or directories supplied") try: - tvnamer(paths = sorted(args)) + args.sort() + tvnamer(paths=args) except NoValidFilesFoundError: opter.error("No valid files were supplied") except UserAbort, errormsg: diff --git a/tvnamer/renamer.py b/tvnamer/renamer.py new file mode 100644 index 0000000..d7fd053 --- /dev/null +++ b/tvnamer/renamer.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python + +import os +import shutil +import logging + +from unicode_helper import p + + +__all__ = ["Renamer"] + + +def log(): + """Returns the logger for current file + """ + return logging.getLogger(__name__) + + +def same_partition(f1, f2): + """Returns True if both files or directories are on the same partition + """ + return os.stat(f1).st_dev == os.stat(f2).st_dev + + +def delete_file(fpath): + """On OS X: Trashes a path using the Finder, via OS X's Scripting Bridge. + On other platforms: unlinks file. + """ + + try: + from AppKit import NSURL + from ScriptingBridge import SBApplication + except ImportError: + p("Deleting %s" % fpath) + log().debug("Deleting %r" % fpath) + os.unlink(fpath) + else: + p("Trashing %s" % fpath) + log().debug("Trashing %r" % fpath) + targetfile = NSURL.fileURLWithPath_(fpath) + finder = SBApplication.applicationWithBundleIdentifier_("com.apple.Finder") + items = finder.items().objectAtLocation_(targetfile) + items.delete() + + +def rename_file(old, new): + """Rename 'old' file to 'new'. Both files must be on the same partition. + Preserves access and modification time. + """ + p("Renaming %s to %s" % (old, new)) + log().debug("Renaming %r to %r" % (old, new)) + stat = os.stat(old) + os.rename(old, new) + os.utime(new, (stat.st_atime, stat.st_mtime)) + + +def copy_file(old, new): + """Copy 'old' file to 'new'. + """ + p("Copying %s to %s" % (old, new)) + log().debug("Copying %r to %r" % (old, new)) + shutil.copyfile(old, new) + shutil.copystat(old, new) + + +def symlink_file(target, name): + """Create symbolic link named 'name' pointing to 'target'. + """ + p("Creating symlink %s to %s" % (name, target)) + log().debug("Creating symlink %r to %r" % (name, target)) + os.symlink(target, name) + + +class Renamer(object): + """Deals with renaming of files + """ + + def __init__(self, filename): + self.filename = os.path.abspath(filename) + + def rename(self, new_fullpath, force=False, always_copy=False, always_move=False, leave_symlink=False, create_dirs=True): + """Moves the file to a new path. + + If it is on the same partition, it will be moved (unless always_copy is True) + If it is on a different partition, it will be copied, and the original + only deleted if always_move is True. + If the target file already exists, it will raise OSError unless force is True. + If it was moved, a symlink will be left behind with the original name + pointing to the file's new destination if leave_symlink is True. + """ + + new_dir = os.path.dirname(new_fullpath) + + if create_dirs: + p("Creating directory %s" % new_dir) + try: + os.makedirs(new_dir) + except OSError, e: + if e.errno != 17: + raise + + if os.path.exists(new_fullpath): + # If the destination exists, raise exception unless force is True + if not force: + raise OSError("File %s already exists, not forcefully moving %s" % ( + new_fullpath, self.filename)) + + if same_partition(self.filename, new_dir): + if always_copy: + # Same partition, but forced to copy + copy_file(self.filename, new_fullpath) + else: + # Same partition, just rename the file to move it + rename_file(self.filename, new_fullpath) + + # Leave a symlink behind if configured to do so + if leave_symlink: + symlink_file(new_fullpath, self.filename) + else: + # File is on different partition (different disc), copy it + copy_file(self.filename, new_fullpath) + if always_move: + # Forced to move file, we just trash old file + delete_file(self.filename) + + # Leave a symlink behind if configured to do so + if leave_symlink: + symlink_file(new_fullpath, self.filename) + + self.filename = new_fullpath diff --git a/tvnamer/unicode_helper.py b/tvnamer/unicode_helper.py index b2bc375..ccf7949 100644 --- a/tvnamer/unicode_helper.py +++ b/tvnamer/unicode_helper.py @@ -6,7 +6,7 @@ import sys -def unicodify(obj, encoding = "utf-8"): +def unicodify(obj, encoding="utf-8"): if isinstance(obj, basestring): if not isinstance(obj, unicode): obj = unicode(obj, encoding) diff --git a/tvnamer/utils.py b/tvnamer/utils.py index 5f46fe1..56fc19c 100644 --- a/tvnamer/utils.py +++ b/tvnamer/utils.py @@ -1,52 +1,53 @@ #!/usr/bin/env python -"""Utilities for tvnamer, including filename parsing +""" Utilities for tvnamer, including filename parsing """ -import datetime import os import re -import sys -import shutil import logging -import platform +import datetime from tvdb_api import (tvdb_error, tvdb_shownotfound, tvdb_seasonnotfound, -tvdb_episodenotfound, tvdb_attributenotfound, tvdb_userabort) - -from unicode_helper import p + tvdb_episodenotfound, tvdb_attributenotfound, tvdb_userabort) from config import Config -from tvnamer_exceptions import (InvalidPath, InvalidFilename, -ShowNotFound, DataRetrievalError, SeasonNotFound, EpisodeNotFound, -EpisodeNameNotFound, ConfigValueError, UserAbort) +from _titlecase import titlecase +from tvnamer_exceptions import (InvalidPath, InvalidFilename, ShowNotFound, + DataRetrievalError, SeasonNotFound, EpisodeNotFound, + EpisodeNameNotFound, ConfigValueError, UserAbort, + BaseTvnamerException) + +from formatting import makeValidFilename, formatEpisodeNames, formatEpisodeNumbers +from unicode_helper import p def log(): - """Returns the logger for current file + """ Returns the logger for current file """ return logging.getLogger(__name__) -def warn(text): - """Displays message to sys.stderr - """ - p(text, file = sys.stderr) - - def split_extension(filename): + """ Splits the extension from the filename. Uses regular expression + specified in config under 'extension_pattern' key. + + By default the dot is included in the extension, so other functions + should use something like ' + full = basename + extension + or + full = u"%s%s" % (basename, extension) + to get full filename again. + """ base = re.sub(Config["extension_pattern"], "", filename) ext = filename.replace(base, "") return base, ext def _applyReplacements(cfile, replacements): - """Applies custom replacements. - - Argument cfile is string. - - Argument replacements is a list of dicts, with keys "match", - "replacement", and (optional) "is_regex" + """ Applies custom replacements. + @param str cfile + @param list replacements - list of dicts with keys "match", "replacement", and (optional) "is_regex" """ for rep in replacements: if not rep.get('with_extension', False): @@ -68,35 +69,36 @@ def _applyReplacements(cfile, replacements): def applyCustomInputReplacements(cfile): - """Applies custom input filename replacements, wraps _applyReplacements + """ Applies custom input filename replacements, wraps _applyReplacements """ return _applyReplacements(cfile, Config['input_filename_replacements']) def applyCustomOutputReplacements(cfile): - """Applies custom output filename replacements, wraps _applyReplacements + """ Applies custom output filename replacements, wraps _applyReplacements """ return _applyReplacements(cfile, Config['output_filename_replacements']) def applyCustomFullpathReplacements(cfile): - """Applies custom replacements to full path, wraps _applyReplacements + """ Applies custom replacements to full path, wraps _applyReplacements """ return _applyReplacements(cfile, Config['move_files_fullpath_replacements']) def cleanRegexedSeriesName(seriesname): - """Cleans up series name by removing any . and _ - characters, along with any trailing hyphens. + """ Cleans up series name by removing any . and _ + characters, along with any trailing hyphens. - Is basically equivalent to replacing all _ and . with a - space, but handles decimal numbers in string, for example: + Is basically equivalent to replacing all _ and . with a + space, but handles decimal numbers in string, for example: - >>> cleanRegexedSeriesName("an.example.1.0.test") - 'an example 1.0 test' - >>> cleanRegexedSeriesName("an_example_1.0_test") - 'an example 1.0 test' + >>> cleanRegexedSeriesName("an.example.1.0.test") + 'an example 1.0 test' + >>> cleanRegexedSeriesName("an_example_1.0_test") + 'an example 1.0 test' """ + # TODO: Could this be made to clean "Hawaii.Five-0.2010" into "Hawaii Five-0 2010"? seriesname = re.sub("(\D)[.](\D)", "\\1 \\2", seriesname) seriesname = re.sub("(\D)[.]", "\\1 ", seriesname) @@ -107,37 +109,34 @@ def cleanRegexedSeriesName(seriesname): def replaceInputSeriesName(seriesname): - """allow specified replacements of series names - - in cases where default filenames match the wrong series, - e.g. missing year gives wrong answer, or vice versa - - This helps the TVDB query get the right match. + """ Allow specified replacements of series names in cases where default + filenames match the wrong series, e.g. missing year gives wrong answer, + or vice versa. This helps the TVDB query get the right match. """ + for pat, replacement in Config['input_series_replacements'].iteritems(): - if re.match(pat, seriesname, re.IGNORECASE|re.UNICODE): + if re.match(pat, seriesname, re.IGNORECASE | re.UNICODE): return replacement return seriesname def replaceOutputSeriesName(seriesname): - """transform TVDB series names + """ Transform TVDB series names after matching from TVDB, transform the + series name for desired abbreviation, etc. - after matching from TVDB, transform the series name for desired abbreviation, etc. - - This affects the output filename. + This affects the output filename. """ return Config['output_series_replacements'].get(seriesname, seriesname) def handleYear(year): - """Handle two-digit years with heuristic-ish guessing + """ Handle two-digit years with heuristic guessing. - Assumes 50-99 becomes 1950-1999, and 0-49 becomes 2000-2049 + Assumes 50-99 becomes 1950-1999, and 0-49 becomes 2000-2049 - ..might need to rewrite this function in 2050, but that seems like - a reasonable limitation + ..might need to rewrite this function in 2050, but that seems like + a reasonable limitation """ year = int(year) @@ -153,21 +152,21 @@ def handleYear(year): class FileFinder(object): - """Given a file, it will verify it exists. Given a folder it will descend - one level into it and return a list of files, unless the recursive argument - is True, in which case it finds all files contained within the path. - - The with_extension argument is a list of valid extensions, without leading - spaces. If an empty list (or None) is supplied, no extension checking is - performed. - - The filename_blacklist argument is a list of regexp strings to match against - the filename (minus the extension). If a match is found, the file is skipped - (e.g. for filtering out "sample" files). If [] or None is supplied, no - filtering is done + """ Given a file, it will verify it exists. Given a folder it will descend + one level into it and return a list of files, unless the recursive argument + is True, in which case it finds all files contained within the path. + + The with_extension argument is a list of valid extensions, without leading + spaces. If an empty list (or None) is supplied, no extension checking is + performed. + + The filename_blacklist argument is a list of regexp strings to match against + the filename (minus the extension). If a match is found, the file is skipped + (e.g. for filtering out "sample" files). If [] or None is supplied, no + filtering is done """ - def __init__(self, path, with_extension = None, filename_blacklist = None, recursive = False): + def __init__(self, path, with_extension=None, filename_blacklist=None, recursive=False): self.path = path if with_extension is None: self.with_extension = [] @@ -180,7 +179,7 @@ def __init__(self, path, with_extension = None, filename_blacklist = None, recur self.recursive = recursive def findFiles(self): - """Returns list of files found at path + """ Returns list of files found at path """ if os.path.isfile(self.path): path = os.path.abspath(self.path) @@ -194,43 +193,41 @@ def findFiles(self): raise InvalidPath("%s is not a valid file/directory" % self.path) def _checkExtension(self, fname): - """Checks if the file extension is blacklisted in valid_extensions + """ Checks if the file extension is blacklisted in valid_extensions """ + if len(self.with_extension) == 0: return True # don't use split_extension here (otherwise valid_extensions is useless)! _, extension = os.path.splitext(fname) - for cext in self.with_extension: - cext = ".%s" % cext - if extension == cext: - return True - else: - return False + if extension in [".%s" % ext for ext in self.with_extension]: + return True + return False def _blacklistedFilename(self, filepath): - """Checks if the filename (optionally excluding extension) - matches filename_blacklist + """ Checks if the filename (optionally excluding extension) + matches filename_blacklist - self.with_blacklist should be a list of strings and/or dicts: + self.with_blacklist should be a list of strings and/or dicts: - a string, specifying an exact filename to ignore - "filename_blacklist": [".DS_Store", "Thumbs.db"], + a string, specifying an exact filename to ignore + "filename_blacklist": [".DS_Store", "Thumbs.db"], - a dictionary, where each dict contains: + a dictionary, where each dict contains: - Key 'match' - (if the filename matches the pattern, the filename - is blacklisted) + Key 'match' - (if the filename matches the pattern, the filename + is blacklisted) - Key 'is_regex' - if True, the pattern is treated as a - regex. If False, simple substring check is used (if - cur['match'] in filename). Default is False + Key 'is_regex' - if True, the pattern is treated as a + regex. If False, simple substring check is used (if + cur['match'] in filename). Default is False - Key 'full_path' - if True, full path is checked. If False, only - filename is checked. Default is False. + Key 'full_path' - if True, full path is checked. If False, only + filename is checked. Default is False. - Key 'exclude_extension' - if True, the extension is removed - from the file before checking. Default is False. + Key 'exclude_extension' - if True, the extension is removed + from the file before checking. Default is False. """ if len(self.with_blacklist) == 0: @@ -243,30 +240,24 @@ def _blacklistedFilename(self, filepath): if isinstance(fblacklist, basestring): if fullname == fblacklist: return True - else: - continue - - if "full_path" in fblacklist and fblacklist["full_path"]: - to_check = filepath else: - if fblacklist.get("exclude_extension", False): + if fblacklist.get("full_path"): + to_check = filepath + elif fblacklist.get("exclude_extension"): to_check = fname else: to_check = fullname - if fblacklist.get("is_regex", False): - m = re.match(fblacklist["match"], to_check) - if m is not None: - return True - else: - m = fblacklist["match"] in to_check - if m: + if fblacklist.get("is_regex"): + if re.match(fblacklist["match"], to_check): + return True + elif fblacklist["match"] in to_check: return True else: return False def _findFilesInPath(self, startpath): - """Finds files from startpath, could be called recursively + """ Finds files from startpath, could be called recursively """ allfiles = [] if not os.access(startpath, os.R_OK): @@ -277,23 +268,15 @@ def _findFilesInPath(self, startpath): newpath = os.path.join(startpath, subf) newpath = os.path.abspath(newpath) if os.path.isfile(newpath): - if not self._checkExtension(subf): - continue - elif self._blacklistedFilename(subf): - continue - else: + if self._checkExtension(subf) and not self._blacklistedFilename(subf): allfiles.append(newpath) - else: - if self.recursive: - allfiles.extend(self._findFilesInPath(newpath)) - #end if recursive - #end if isfile - #end for sf + elif self.recursive: + allfiles.extend(self._findFilesInPath(newpath)) return allfiles class FileParser(object): - """Deals with parsing of filenames + """ Deals with parsing of filenames """ def __init__(self, path): @@ -302,340 +285,246 @@ def __init__(self, path): self._compileRegexs() def _compileRegexs(self): - """Takes episode_patterns from config, compiles them all - into self.compiled_regexs + """ Compiles items from 'filename_patterns' list in config into re.RegexObject, + appends into self.compiled_regexs. Checks validity of each regex (must contain + certain matching groups). """ for cpattern in Config['filename_patterns']: try: cregex = re.compile(cpattern, re.VERBOSE) + groups = set(cregex.groupindex.keys()) + + # check regex validity + if 'seriesname' not in groups: + raise ConfigValueError( + "Regex must contain group 'seriesname'." + "Pattern was:\n" + cpattern) + + dateset = set(['year', 'month', 'day']) + intersection = groups.intersection(dateset) + if len(intersection) > 0 and intersection != dateset: + raise ConfigValueError( + "Date-based regex must contain groups 'year', 'month' and 'day'." + "Pattern was:\n" + cpattern) + + # check for episodenumber only in non-dated regex + elif len(intersection) == 0: + epnoset = set(['episodenumberstart', 'episodenumberend']) + intersection = groups.intersection(epnoset) + if len(intersection) > 0 and intersection != epnoset: + raise ConfigValueError( + "Regex must contain both (or none of) following groups:" + "'episodenumberstart', 'episodenumberend'" + "Pattern was:\n" + cpattern) + + epnoset.update(set(['episodenumber'] + ['episodenumber%s' % x for x in xrange(1, 10)])) + intersection = groups.intersection(epnoset) + if len(intersection) == 0: + raise ConfigValueError( + "Regex does not contain episode number group, should" + "contain episodenumber, episodenumber1-9, or" + "episodenumberstart and episodenumberend." + "Pattern was:\n" + cpattern) + except re.error, errormsg: - warn("WARNING: Invalid episode_pattern (error: %s)\nPattern:\n%s" % ( - errormsg, cpattern)) + log().warning("Invalid episode_pattern (error: %s)\nPattern:\n%s" % (errormsg, cpattern)) else: self.compiled_regexs.append(cregex) def parse(self): - """Runs path via configured regex, extracting data from groups. - Returns an EpisodeInfo instance containing extracted data. + """ Runs path via configured regex, extracting data from groups. + Returns an EpisodeInfo instance containing extracted data. """ - _, filename = os.path.split(self.path) + _, filename = os.path.split(self.path) filename = applyCustomInputReplacements(filename) for cmatcher in self.compiled_regexs: match = cmatcher.match(filename) - if match: - namedgroups = match.groupdict().keys() - - if 'episodenumber1' in namedgroups: - # Multiple episodes, have episodenumber1 or 2 etc - epnos = [] - for cur in namedgroups: - epnomatch = re.match('episodenumber(\d+)', cur) - if epnomatch: - epnos.append(int(match.group(cur))) - epnos.sort() - episodenumbers = epnos - - elif 'episodenumberstart' in namedgroups: - # Multiple episodes, regex specifies start and end number - start = int(match.group('episodenumberstart')) - end = int(match.group('episodenumberend')) - if end - start > 5: - warn("WARNING: %s episodes detected in file: %s, confused by numeric episode name, using first match: %s" %(end - start, filename, start)) - episodenumbers = [start] - elif start > end: - # Swap start and end - start, end = end, start - episodenumbers = range(start, end + 1) - else: - episodenumbers = range(start, end + 1) - - elif 'episodenumber' in namedgroups: - episodenumbers = [int(match.group('episodenumber')), ] - - elif 'year' in namedgroups or 'month' in namedgroups or 'day' in namedgroups: - if not all(['year' in namedgroups, 'month' in namedgroups, 'day' in namedgroups]): - raise ConfigValueError( - "Date-based regex must contain groups 'year', 'month' and 'day'") - match.group('year') + if not match: + continue - year = handleYear(match.group('year')) + parsed = match.groupdict() - episodenumbers = [datetime.date(year, - int(match.group('month')), - int(match.group('day')))] + episode_type = self.getEpType(parsed) + episode_numbers = self.getEpNumbers(parsed, episode_type, filename) - else: - raise ConfigValueError( - "Regex does not contain episode number group, should" - "contain episodenumber, episodenumber1-9, or" - "episodenumberstart and episodenumberend\n\nPattern" - "was:\n" + cmatcher.pattern) + if parsed.get('seriesname'): + parsed['seriesname'] = cleanRegexedSeriesName(parsed['seriesname']) + parsed['seriesname'] = replaceInputSeriesName(parsed['seriesname']) - if 'seriesname' in namedgroups: - seriesname = match.group('seriesname') - else: - raise ConfigValueError( - "Regex must contain seriesname. Pattern was:\n" + cmatcher.pattern) - - if seriesname != None: - seriesname = cleanRegexedSeriesName(seriesname) - seriesname = replaceInputSeriesName(seriesname) - - extra_values = match.groupdict() - - if 'seasonnumber' in namedgroups: - seasonnumber = int(match.group('seasonnumber')) - - episode = EpisodeInfo( - seriesname = seriesname, - seasonnumber = seasonnumber, - episodenumbers = episodenumbers, - filename = self.path, - extra = extra_values) - elif 'year' in namedgroups and 'month' in namedgroups and 'day' in namedgroups: - episode = DatedEpisodeInfo( - seriesname = seriesname, - episodenumbers = episodenumbers, - filename = self.path, - extra = extra_values) - elif 'group' in namedgroups: - episode = AnimeEpisodeInfo( - seriesname = seriesname, - episodenumbers = episodenumbers, - filename = self.path, - extra = extra_values) - else: - # No season number specified, usually for Anime - episode = NoSeasonEpisodeInfo( - seriesname = seriesname, - episodenumbers = episodenumbers, - filename = self.path, - extra = extra_values) - - return episode + return EpisodeInfo(filename=self.path, eptype=episode_type, episodenumbers=episode_numbers, extra=parsed) + + # body of for loop didn't return, which means no regex matches the filename else: emsg = "Cannot parse %r" % self.path if len(Config['input_filename_replacements']) > 0: emsg += " with replacements: %r" % filename raise InvalidFilename(emsg) + def getEpType(self, parsed): + """ Determines correct episode type from parsed values + """ -def formatEpisodeName(names, join_with, multiep_format): - """ - Takes a list of episode names, formats them into a string. - - If two names are supplied, such as "Pilot (1)" and "Pilot (2)", the - returned string will be "Pilot (1-2)". Note that the first number - is not required, for example passing "Pilot" and "Pilot (2)" will - also result in returning "Pilot (1-2)". - - If two different episode names are found, such as "The first", and - "Something else" it will return "The first, Something else" - """ - if len(names) == 1: - return names[0] - - found_name = "" - numbers = [] - for cname in names: - match = re.match("(.*) \(([0-9]+)\)$", cname) - if found_name != "" and (not match or epname != found_name): - # An episode didn't match - return join_with.join(names) - - if match: - epname, epno = match.group(1), match.group(2) - else: # assume that this is the first episode, without number - epname = cname - epno = 1 - found_name = epname - numbers.append(int(epno)) - - return multiep_format % {'epname': found_name, 'episodemin': min(numbers), 'episodemax': max(numbers)} - - -def makeValidFilename(value, normalize_unicode = False, windows_safe = False, custom_blacklist = None, replace_with = "_"): - """ - Takes a string and makes it into a valid filename. - - normalize_unicode replaces accented characters with ASCII equivalent, and - removes characters that cannot be converted sensibly to ASCII. - - windows_safe forces Windows-safe filenames, regardless of current platform - - custom_blacklist specifies additional characters that will removed. This - will not touch the extension separator: + if parsed.get('group'): + if parsed.get('crc'): + return 'anime_crc' + return 'anime' + if 'year' in parsed.keys(): + return 'dated' + if not parsed.get('seasonnumber'): + return 'noseason' + return 'default' + + def getEpNumbers(self, parsed, eptype, filename): + """ Returns parsed episode numbers, updates 'parsed' dict (removes auxiliary items, + adds 'year', 'month', 'day' items for dated episodes. + """ - >>> makeValidFilename("T.est.avi", custom_blacklist=".") - 'T_est.avi' - """ + if eptype == 'dated': + episodenumbers = [datetime.date(handleYear(parsed.get('year')), + int(parsed.get('month')), + int(parsed.get('day')))] + parsed["year"] = episodenumbers[0].year + parsed["month"] = episodenumbers[0].month + parsed["day"] = episodenumbers[0].day + + elif 'episodenumberstart' in parsed.keys(): + # Multiple episodes, regex specifies start and end number + start = int(parsed.get('episodenumberstart')) + end = int(parsed.get('episodenumberend')) + if start > end: + # Swap start and end + start, end = end, start + episodenumbers = range(start, end + 1) + if end - start > 5: + log().warning("WARNING: %s episodes detected in file: %s, confused by numeric episode name, using first match: %s" % (end - start, filename, start)) + episodenumbers = [start] + del parsed["episodenumberstart"] # delete auxiliary key from parsed + del parsed["episodenumberend"] # delete auxiliary key from parsed + + elif 'episodenumber' in parsed.keys(): + episodenumbers = [int(parsed.get('episodenumber')), ] + del parsed["episodenumber"] # delete auxiliary key from parsed + + elif 'episodenumber1' in parsed.keys(): + # TODO: isn't episodenumber[1-9] useless??? + # Multiple episodes, have episodenumber1 or 2 etc + epnos = [] + for cur in parsed.keys(): + epnomatch = re.match('episodenumber(\d+)', cur) + if epnomatch: + epnos.append(int(parsed.get(cur))) + del parsed[cur] # delete auxiliary key from parsed + epnos.sort() + episodenumbers = epnos - if windows_safe: - # Allow user to make Windows-safe filenames, if they so choose - sysname = "Windows" - else: - sysname = platform.system() - - # If the filename starts with a . prepend it with an underscore, so it - # doesn't become hidden. - - # This is done before calling splitext to handle filename of ".", as - # splitext acts differently in python 2.5 and 2.6 - 2.5 returns ('', '.') - # and 2.6 returns ('.', ''), so rather than special case '.', this - # special-cases all files starting with "." equally (since dotfiles have - # no extension) - if value.startswith("."): - value = "_" + value - - # Treat extension seperatly - value, extension = split_extension(value) - - # Remove any null bytes - value = value.replace("\0", "") - - # Blacklist of characters - if sysname == 'Darwin': - # : is technically allowed, but Finder will treat it as / and will - # generally cause weird behaviour, so treat it as invalid. - blacklist = r"/:" - elif sysname in ['Linux', 'FreeBSD']: - blacklist = r"/" - else: - # platform.system docs say it could also return "Windows" or "Java". - # Failsafe and use Windows sanitisation for Java, as it could be any - # operating system. - blacklist = r"\/:*?\"<>|" - - # Append custom blacklisted characters - if custom_blacklist is not None: - blacklist += custom_blacklist - - # Replace every blacklisted character with a underscore - value = re.sub("[%s]" % re.escape(blacklist), replace_with, value) - - # Remove any trailing whitespace - value = value.strip() - - # There are a bunch of filenames that are not allowed on Windows. - # As with character blacklist, treat non Darwin/Linux platforms as Windows - if sysname not in ['Darwin', 'Linux']: - invalid_filenames = ["CON", "PRN", "AUX", "NUL", "COM1", "COM2", - "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", - "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"] - if value in invalid_filenames: - value = "_" + value - - # Replace accented characters with ASCII equivalent - if normalize_unicode: - import unicodedata - value = unicode(value) # cast data to unicode - value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore') - - # Truncate filenames to valid/sane length. - # NTFS is limited to 255 characters, HFS+ and EXT3 don't seem to have - # limits, FAT32 is 254. I doubt anyone will take issue with losing that - # one possible character, and files over 254 are pointlessly unweidly - max_len = 254 - - if len(value + extension) > max_len: - if len(extension) > len(value): - # Truncate extension instead of filename, no extension should be - # this long.. - new_length = max_len - len(value) - extension = extension[:new_length] else: - # File name is longer than extension, truncate filename. - new_length = max_len - len(extension) - value = value[:new_length] + # should never happen + raise BaseTvnamerException("Unable to parse episodenumbers") - return value + extension - - -def formatEpisodeNumbers(episodenumbers): - """Format episode number(s) into string, using configured values - """ - if len(episodenumbers) == 1: - epno = Config['episode_single'] % episodenumbers[0] - else: - epno = Config['episode_separator'].join( - Config['episode_single'] % x for x in episodenumbers) - - return epno + return episodenumbers class EpisodeInfo(object): - """Stores information (season, episode number, episode name), and contains - logic to generate new name + """ Stores information (season, episode number, episode name), and contains + logic to generate new name """ - CFG_KEY_WITH_EP = "filename_with_episode" - CFG_KEY_WITHOUT_EP = "filename_without_episode" - - def __init__(self, - seriesname, - seasonnumber, - episodenumbers, - episodename = None, - filename = None, - extra = None): + _fullpath = str + filename = str + extension = str - self.seriesname = seriesname - self.seasonnumber = seasonnumber - self.episodenumbers = episodenumbers - self.episodename = episodename + def __init__(self, filename, episodenumbers, eptype='default', extra=None, **kwargs): self.fullpath = filename - if filename is not None: - # Remains untouched, for use when renaming file - self.originalfilename = os.path.basename(filename) - else: - self.originalfilename = None + self.eptype = eptype + self.episodenumbers = episodenumbers if extra is None: - extra = {} - self.extra = extra + self.extra = {} + else: + self.extra = extra + self.extra.update(kwargs) - def fullpath_get(self): - return self._fullpath + def __getattr__(self, key): + """ Expose values of self.extra as attributes of EpisodeInfo. + First check if self already has attribute 'key', otherwise look into self.extra. + """ - def fullpath_set(self, value): - self._fullpath = value - if value is None: - self.filename, self.extension = None, None - else: - self.filepath, self.filename = os.path.split(value) + if key in self.__dict__: + return self.__dict__[key] + elif key in self.extra: + return self.extra[key] + raise AttributeError("'%s' object has no attribute '%s'" % (self.__class__.__name__, key)) + + def __hasattr__(self, key): + return key in self.__dict__ or key in self.extra + + # TODO: this might not be necessary as self.fullpath is not updated after rename + def fullpath(): + def fget(self): + return self._fullpath + + def fset(self, value): + self._fullpath = value + self.filename = os.path.split(value)[1] self.filename, self.extension = split_extension(self.filename) + return locals() - fullpath = property(fullpath_get, fullpath_set) + fullpath = property(**fullpath()) @property def fullfilename(self): return u"%s%s" % (self.filename, self.extension) def sortable_info(self): - """Returns a tuple of sortable information + """ Returns a list of sortable information """ - return (self.seriesname, self.seasonnumber, self.episodenumbers) + info = [] + info.append(self.extra['seriesname']) + if hasattr(self, 'seasonnumber'): + info.append(int(self.seasonnumber)) + info.append(self.episodenumbers) + return info def number_string(self): - """Used in UI + """ Used in UI """ - return "season: %s, episode: %s" % ( - self.seasonnumber, - ", ".join([str(x) for x in self.episodenumbers])) + string = "" + if hasattr(self, 'seasonnumber'): + string += "season: %s, " % self.seasonnumber + string += "episode: %s" % ", ".join([str(x) for x in self.episodenumbers]) + return string + + def getFormatString(self): + """ Returns format string from Config for current episode type. + """ + + epname = self.extra.get('episodename') + if self.eptype == 'anime': + return Config['filename_anime_with_episode_without_crc'] if epname else Config['filename_anime_without_episode_without_crc'] + elif self.eptype == 'anime_crc': + return Config['filename_anime_with_episode'] if epname else Config['filename_anime_without_episode'] + elif self.eptype == 'dated': + return Config['filename_with_date_and_episode'] if epname else Config['filename_with_date_without_episode'] + elif self.eptype == 'noseason': + return Config['filename_with_episode_no_season'] if epname else Config['filename_without_episode_no_season'] + else: + return Config['filename_with_episode'] if epname else Config['filename_without_episode'] def populateFromTvdb(self, tvdb_instance, force_name=None, series_id=None): - """Queries the tvdb_api.Tvdb instance for episode name and corrected - series name. - If series cannot be found, it will warn the user. If the episode is not - found, it will use the corrected show name and not set an episode name. - If the site is unreachable, it will warn the user. If the user aborts - it will catch tvdb_api's user abort error and raise tvnamer's + """ Queries the tvdb_api.Tvdb instance for episode name and corrected series name. + + If series cannot be found, it will warn the user. If the episode is not + found, it will use the corrected show name and not set an episode name. + + If the site is unreachable, it will warn the user. If the user aborts + it will catch tvdb_api's user abort error and raise tvnamer's """ + try: if series_id is None: - show = tvdb_instance[force_name or self.seriesname] + show = tvdb_instance[force_name or self.extra['seriesname']] else: series_id = int(series_id) tvdb_instance._getShowData(series_id, Config['language']) @@ -644,36 +533,29 @@ def populateFromTvdb(self, tvdb_instance, force_name=None, series_id=None): raise DataRetrievalError("Error with www.thetvdb.com: %s" % errormsg) except tvdb_shownotfound: # No such series found. - raise ShowNotFound("Show %s not found on www.thetvdb.com" % self.seriesname) + raise ShowNotFound("Show '%s' not found on www.thetvdb.com" % self.extra['seriesname']) except tvdb_userabort, error: raise UserAbort(unicode(error)) else: # Series was found, use corrected series name - self.seriesname = replaceOutputSeriesName(show['seriesname']) + self.extra['seriesname'] = replaceOutputSeriesName(show['seriesname']) - if isinstance(self, DatedEpisodeInfo): + if self.eptype == 'dated': # Date-based episode epnames = [] for cepno in self.episodenumbers: try: sr = show.airedOn(cepno) if len(sr) > 1: - raise EpisodeNotFound( - "Ambigious air date %s, there were %s episodes on that day" % ( - cepno, len(sr))) + raise EpisodeNotFound("Ambigious air date %s, there were %s episodes on that day" % (cepno, len(sr))) epnames.append(sr[0]['episodename']) except tvdb_episodenotfound: - raise EpisodeNotFound( - "Episode that aired on %s could not be found" % ( - cepno)) - self.episodename = epnames + raise EpisodeNotFound("Episode that aired on %s could not be found" % cepno) + self.extra['episodename'] = epnames return - if not hasattr(self, "seasonnumber") or self.seasonnumber is None: - # Series without concept of seasons have all episodes in season 1 - seasonnumber = 1 - else: - seasonnumber = self.seasonnumber + # Default to 1, series without concept of seasons have all episodes in season 1 + seasonnumber = int(self.extra.get('seasonnumber') or 1) epnames = [] for cepno in self.episodenumbers: @@ -681,10 +563,7 @@ def populateFromTvdb(self, tvdb_instance, force_name=None, series_id=None): episodeinfo = show[seasonnumber][cepno] except tvdb_seasonnotfound: - raise SeasonNotFound( - "Season %s of show %s could not be found" % ( - seasonnumber, - self.seriesname)) + raise SeasonNotFound("Season %s of show '%s' could not be found" % (seasonnumber, self.extra['seriesname'])) except tvdb_episodenotfound: # Try to search by absolute_number @@ -698,406 +577,95 @@ def populateFromTvdb(self, tvdb_instance, force_name=None, series_id=None): unsure = False # If unsure error out if unsure: - raise EpisodeNotFound( - "No episode actually matches %s, found %s results instead" % (cepno, len(sr))) + raise EpisodeNotFound("No episode actually matches %s, found %s results instead" % (cepno, len(sr))) elif len(sr) == 1: epnames.append(sr[0]['episodename']) else: raise EpisodeNotFound( - "Episode %s of show %s, season %s could not be found (also tried searching by absolute episode number)" % ( - cepno, - self.seriesname, - seasonnumber)) + "Episode %s of show '%s', season %s could not be found (also tried searching by absolute episode number)" % ( + cepno, self.extra['seriesname'], seasonnumber)) except tvdb_attributenotfound: - raise EpisodeNameNotFound( - "Could not find episode name for %s" % cepno) + raise EpisodeNameNotFound("Could not find episode name for '%s'" % cepno) else: epnames.append(episodeinfo['episodename']) - self.episodename = epnames + self.extra['episodename'] = epnames def getepdata(self): + """ Return dict of formatted data available to config'd output file format """ - Uses the following config options: - filename_with_episode # Filename when episode name is found - filename_without_episode # Filename when no episode can be found - episode_single # formatting for a single episode number - episode_separator # used to join multiple episode numbers - """ - # Format episode number into string, or a list - epno = formatEpisodeNumbers(self.episodenumbers) - # Data made available to config'd output file format - if self.extension is None: - prep_extension = '' - else: - prep_extension = self.extension - - epdata = { - 'seriesname': self.seriesname, - 'seasonno': self.seasonnumber, # TODO: deprecated attribute, make this warn somehow - 'seasonnumber': self.seasonnumber, - 'episode': epno, - 'episodename': self.episodename, - 'ext': prep_extension} + epdata = self.extra.copy() + epdata.update({ + 'originalfilename': self.fullfilename, + 'ext': self.extension, + 'episode': formatEpisodeNumbers(self.episodenumbers), + }) + + # format dynamic parts according to config + for key in epdata.keys(): + if key == 'originalfilename': + continue + if key == 'episodename': + epdata[key] = formatEpisodeNames(epdata[key]) + if key in ('seriesname', 'episodename'): + if Config['lowercase_dynamic_parts']: + epdata[key] = epdata[key].lower() + elif Config['titlecase_dynamic_parts']: + epdata[key] = titlecase(epdata[key]) + epdata[key] = makeValidFilename(epdata[key]) + + # TODO: would be better to expose only strings? (%s can accept int, but %d cannot accept str, + # so using %s in format strings is less error-prone) + if key == 'seasonnumber': + epdata[key] = int(epdata[key]) return epdata - def generateFilename(self, lowercase = False, preview_orig_filename = False): - epdata = self.getepdata() - - # Add in extra dict keys, without clobbering existing values in epdata - extra = self.extra.copy() - extra.update(epdata) - epdata = extra - - if self.episodename is None: - fname = Config[self.CFG_KEY_WITHOUT_EP] % epdata - else: - if isinstance(self.episodename, list): - epdata['episodename'] = formatEpisodeName( - self.episodename, - join_with = Config['multiep_join_name_with'], - multiep_format = Config['multiep_format']) - fname = Config[self.CFG_KEY_WITH_EP] % epdata - - if Config['titlecase_filename']: - from _titlecase import titlecase - fname = titlecase(fname) - - if lowercase or Config['lowercase_filename']: - fname = fname.lower() - - if preview_orig_filename: - # Return filename without custom replacements or filesystem-validness - return fname - - if len(Config['output_filename_replacements']) > 0: - fname = applyCustomOutputReplacements(fname) - - return makeValidFilename( - fname, - normalize_unicode = Config['normalize_unicode_filenames'], - windows_safe = Config['windows_safe_filenames'], - custom_blacklist = Config['custom_filename_character_blacklist'], - replace_with = Config['replace_invalid_characters_with']) - - def __repr__(self): - return u"<%s: %r>" % ( - self.__class__.__name__, - self.generateFilename()) - - -class DatedEpisodeInfo(EpisodeInfo): - CFG_KEY_WITH_EP = "filename_with_date_and_episode" - CFG_KEY_WITHOUT_EP = "filename_with_date_without_episode" - - def __init__(self, - seriesname, - episodenumbers, - episodename = None, - filename = None, - extra = None): - - self.seriesname = seriesname - self.episodenumbers = episodenumbers - self.episodename = episodename - self.fullpath = filename - - if filename is not None: - # Remains untouched, for use when renaming file - self.originalfilename = os.path.basename(filename) - else: - self.originalfilename = None - - if filename is not None: - # Remains untouched, for use when renaming file - self.originalfilename = os.path.basename(filename) - else: - self.originalfilename = None - - if extra is None: - extra = {} - self.extra = extra - - def sortable_info(self): - """Returns a tuple of sortable information - """ - return (self.seriesname, self.episodenumbers) - - def number_string(self): - """Used in UI + def getNewFullPath(self): + """ Generates final fullPath, with all replacements, formatting etc. + It's ready to pass it to Renamer.rename(). """ - return "episode: %s" % ( - ", ".join([str(x) for x in self.episodenumbers])) - - def getepdata(self): - # Format episode number into string, or a list - dates = str(self.episodenumbers[0]) - if isinstance(self.episodename, list): - prep_episodename = formatEpisodeName( - self.episodename, - join_with = Config['multiep_join_name_with'], - multiep_format = Config['multiep_format']) - else: - prep_episodename = self.episodename - - # Data made available to config'd output file format - if self.extension is None: - prep_extension = '' - else: - prep_extension = self.extension - - epdata = { - 'seriesname': self.seriesname, - 'episode': dates, - 'episodename': prep_episodename, - 'ext': prep_extension} - return epdata - - -class NoSeasonEpisodeInfo(EpisodeInfo): - CFG_KEY_WITH_EP = "filename_with_episode_no_season" - CFG_KEY_WITHOUT_EP = "filename_without_episode_no_season" - - def __init__(self, - seriesname, - episodenumbers, - episodename = None, - filename = None, - extra = None): - - self.seriesname = seriesname - self.episodenumbers = episodenumbers - self.episodename = episodename - self.fullpath = filename - - if filename is not None: - # Remains untouched, for use when renaming file - self.originalfilename = os.path.basename(filename) - else: - self.originalfilename = None - - if extra is None: - extra = {} - self.extra = extra - - def sortable_info(self): - """Returns a tuple of sortable information - """ - return (self.seriesname, self.episodenumbers) - - def number_string(self): - """Used in UI - """ - return "episode: %s" % ( - ", ".join([str(x) for x in self.episodenumbers])) - - def getepdata(self): - epno = formatEpisodeNumbers(self.episodenumbers) - - # Data made available to config'd output file format - if self.extension is None: - prep_extension = '' - else: - prep_extension = self.extension - - epdata = { - 'seriesname': self.seriesname, - 'episode': epno, - 'episodename': self.episodename, - 'ext': prep_extension} - - return epdata - - -class AnimeEpisodeInfo(NoSeasonEpisodeInfo): - CFG_KEY_WITH_EP = "filename_anime_with_episode" - CFG_KEY_WITHOUT_EP = "filename_anime_without_episode" - - CFG_KEY_WITH_EP_NO_CRC = "filename_anime_with_episode_without_crc" - CFG_KEY_WITHOUT_EP_NO_CRC = "filename_anime_without_episode_without_crc" - - def generateFilename(self, lowercase = False, preview_orig_filename = False): epdata = self.getepdata() - - # Add in extra dict keys, without clobbering existing values in epdata - extra = self.extra.copy() - extra.update(epdata) - epdata = extra - - # Get appropriate config key, depending on if episode name was - # found, and if crc value was found - if self.episodename is None: - if self.extra.get('crc') is None: - cfgkey = self.CFG_KEY_WITHOUT_EP_NO_CRC - else: - # Have crc, but no ep name - cfgkey = self.CFG_KEY_WITHOUT_EP - else: - if self.extra.get('crc') is None: - cfgkey = self.CFG_KEY_WITH_EP_NO_CRC - else: - cfgkey = self.CFG_KEY_WITH_EP - - if self.episodename is not None: - if isinstance(self.episodename, list): - epdata['episodename'] = formatEpisodeName( - self.episodename, - join_with = Config['multiep_join_name_with'], - multiep_format = Config['multiep_format']) - - fname = Config[cfgkey] % epdata - - - if lowercase or Config['lowercase_filename']: - fname = fname.lower() - - if preview_orig_filename: - # Return filename without custom replacements or filesystem-validness - return fname + newName = self.getFormatString() % epdata if len(Config['output_filename_replacements']) > 0: - fname = applyCustomOutputReplacements(fname) - - return makeValidFilename( - fname, - normalize_unicode = Config['normalize_unicode_filenames'], - windows_safe = Config['windows_safe_filenames'], - custom_blacklist = Config['custom_filename_character_blacklist'], - replace_with = Config['replace_invalid_characters_with']) - - -def same_partition(f1, f2): - """Returns True if both files or directories are on the same partition - """ - return os.stat(f1).st_dev == os.stat(f2).st_dev - - -def delete_file(fpath): - """On OS X: Trashes a path using the Finder, via OS X's Scripting Bridge. - - On other platforms: unlinks file. - """ - - try: - from AppKit import NSURL - from ScriptingBridge import SBApplication - except ImportError: - log().debug("Deleting %r" % fpath) - os.unlink(fpath) - else: - log().debug("Trashing %r" % fpath) - targetfile = NSURL.fileURLWithPath_(fpath) - finder = SBApplication.applicationWithBundleIdentifier_("com.apple.Finder") - items = finder.items().objectAtLocation_(targetfile) - items.delete() - - -def rename_file(old, new): - p("rename %s to %s" % (old, new)) - stat = os.stat(old) - os.rename(old, new) - os.utime(new, (stat.st_atime, stat.st_mtime)) - - -def copy_file(old, new): - p("copy %s to %s" % (old, new)) - shutil.copyfile(old, new) - shutil.copystat(old, new) - - -def symlink_file(target, name): - p("symlink %s to %s" % (name, target)) - os.symlink(target, name) - - -class Renamer(object): - """Deals with renaming of files - """ - - def __init__(self, filename): - self.filename = os.path.abspath(filename) - - def newPath(self, new_path = None, new_fullpath = None, force = False, always_copy = False, always_move = False, leave_symlink = False, create_dirs = True, getPathPreview = False): - """Moves the file to a new path. - - If it is on the same partition, it will be moved (unless always_copy is True) - If it is on a different partition, it will be copied, and the original - only deleted if always_move is True. - If the target file already exists, it will raise OSError unless force is True. - If it was moved, a symlink will be left behind with the original name - pointing to the file's new destination if leave_symlink is True. - """ - - if always_copy and always_move: - raise ValueError("Both always_copy and always_move cannot be specified") - - if (new_path is None and new_fullpath is None) or (new_path is not None and new_fullpath is not None): - raise ValueError("Specify only new_dir or new_fullpath") - - old_dir, old_filename = os.path.split(self.filename) - if new_path is not None: - # Join new filepath to old one (to handle realtive dirs) - new_dir = os.path.abspath(os.path.join(old_dir, new_path)) - - # Join new filename onto new filepath - new_fullpath = os.path.join(new_dir, old_filename) + p("Before custom output replacements: '%s'" % newName) + newName = applyCustomOutputReplacements(newName) + p("After custom output replacements: '%s'" % newName) + if self.eptype == 'dated': + newPath = Config['move_files_destination_date'] % epdata else: - # Join new filepath to old one (to handle realtive dirs) - new_fullpath = os.path.abspath(os.path.join(old_dir, new_fullpath)) + newPath = Config['move_files_destination'] % epdata + if Config['move_files_destination_is_filepath']: + newPath, newName = os.path.split(newPath) - new_dir = os.path.dirname(new_fullpath) + # make newName lowercase if specified in config + if Config['lowercase_filename']: + newName = newName.lower() + # make sure the filename is valid + newName = makeValidFilename(newName) - if len(Config['move_files_fullpath_replacements']) > 0: - p("Before custom full path replacements: %s" % (new_fullpath)) - new_fullpath = applyCustomFullpathReplacements(new_fullpath) - new_dir = os.path.dirname(new_fullpath) + # Join new filepath to old one (to handle realtive dirs) + oldPath = os.path.dirname(self.fullpath) + newFullPath = os.path.abspath(os.path.join(oldPath, newPath, newName)) - p("New path: %s" % new_fullpath) + # apply full-path replacements + if len(Config['move_files_fullpath_replacements']) > 0: + p("Before custom full path replacements: '%s'" % (newFullPath)) + newFullPath = applyCustomFullpathReplacements(newFullPath) - if getPathPreview: - return new_fullpath + return newFullPath - if create_dirs: - p("Creating directory %s" % new_dir) - try: - os.makedirs(new_dir) - except OSError, e: - if e.errno != 17: - raise - - if os.path.isfile(new_fullpath): - # If the destination exists, raise exception unless force is True - if not force: - raise OSError("File %s already exists, not forcefully moving %s" % ( - new_fullpath, self.filename)) - - if same_partition(self.filename, new_dir): - if always_copy: - # Same partition, but forced to copy - copy_file(self.filename, new_fullpath) - else: - # Same partition, just rename the file to move it - rename_file(self.filename, new_fullpath) + def generateFilename(self): + """ Wraps getNewFullPath + DEPRECATED: useful only for tests, getNewFullPath should be used instead + """ + return os.path.split(self.getNewFullPath())[1] - # Leave a symlink behind if configured to do so - if leave_symlink: - symlink_file(new_fullpath, self.filename) - else: - # File is on different partition (different disc), copy it - copy_file(self.filename, new_fullpath) - if always_move: - # Forced to move file, we just trash old file - p("Deleting %s" % (self.filename)) - delete_file(self.filename) - - # Leave a symlink behind if configured to do so - if leave_symlink: - symlink_file(new_fullpath, self.filename) - - self.filename = new_fullpath + def __repr__(self): + return u"<%s: %r>" % (self.__class__.__name__, self.fullfilename)