Repository: pymedusa/Medusa

Issue:
OSError handling on invalid filename (tab character)
**Branch/Commit:**
Branch: master
Commit: d6eb72d4e1019e5d1286ae6ac1d7e4b318229ebe
Version: v0.2.1
**OS:**
Windows Server 2016
**What you did:**
Post processing
**What happened:**
File got removed from download folder but did not show up in Correct place, because OSError is not catch as a failure state for the post processing.
**What you expected:**
Correct file movement (stripping of bad chars from filename)
**Logs:**
```
2018-03-07 20:42:22 ERROR    Thread-34 :: [d6eb72d] OSError: 
Traceback (most recent call last):
  File "C:\PythonPrograms\Medusa\medusa\helpers\__init__.py", line 285, in copy_file
    shutil.copyfile(src_file, dest_file)
  File "C:\PythonPrograms\Medusa\lib\shutil_custom\__init__.py", line 42, in copyfile_custom
    fout = os.open(dst, WRITE_FLAGS)
OSError: [Errno 22] Invalid argument
2018-03-07 20:42:22 ERROR    Thread-34 :: [d6eb72d] Unable to write file to D:\media\Videos\TV Shows\Ask Us Anything\Season 01\Ask Us Anything - S01E98 - Episode 98 with Jung-soo & Ha Yeon-soo	.nfo: Environment error: [Errno 22] Invalid argument: u'D:\\media\\Videos\\TV Shows\\Ask Us Anything\\Season 01\\Ask Us Anything - S01E98 - Episode 98 with Jung-soo & Ha Yeon-soo\t.nfo'
```

A quick fix would be to strip and illegal characters from file names. In this case the TheTVDB episode name ended in a tab character. Proper exception handling would probably be a better fix.



Discussion:

Resolving patch:
diff --git a/medusa/helper/common.py b/medusa/helper/common.py
--- a/medusa/helper/common.py
+++ b/medusa/helper/common.py
@@ -293,13 +293,22 @@ def sanitize_filename(filename):
     """
     Remove specific characters from the provided ``filename``.
     :param filename: The filename to clean
-    :return: The ``filename``cleaned
+    :return: The cleaned ``filename``
     """
 
     if isinstance(filename, (str, text_type)):
+        # https://stackoverflow.com/a/31976060/7597273
+        remove = r''.join((
+            r':"<>|?',
+            r'™',  # Trade Mark Sign [unicode: \u2122]
+            r'\t',  # Tab
+            r'\x00-\x1f',  # Null & Control characters
+        ))
+        remove = r'[' + remove + r']'
+
         filename = re.sub(r'[\\/\*]', '-', filename)
-        filename = re.sub(r'[:"<>|?]', '', filename)
-        filename = re.sub(r'™', '', filename)  # Trade Mark Sign unicode: \u2122
+        filename = re.sub(remove, '', filename)
+        # Filenames cannot end in a space or dot on Windows systems
         filename = filename.strip(' .')
 
         return filename


Test patch:
diff --git a/tests/helper/__init__.py b/tests/helper/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/helper/__init__.py
@@ -0,0 +1,2 @@
+# coding=utf-8
+"""Tests for medusa.helper module."""
diff --git a/tests/helper/test_common.py b/tests/helper/test_common.py
new file mode 100644
--- /dev/null
+++ b/tests/helper/test_common.py
@@ -0,0 +1,446 @@
+# coding=utf-8
+"""Tests for medusa.helper.common module."""
+
+from __future__ import unicode_literals
+
+import medusa.helper.common as sut
+
+import pytest
+
+from six import iteritems
+
+
+@pytest.mark.parametrize('p', [
+    {  # p0 - bytes test cases
+        None: None,
+        b'': None,
+        b'123': None,
+        b'12.3': None,
+        b'-123': None,
+        b'-12.3': None,
+        b'300': 'Multiple Choices',
+        0: None,
+        123: None,
+        12.3: None,
+        -123: None,
+        -12.3: None,
+        300: 'Multiple Choices',
+        451: '(Redirect, Unavailable For Legal Reasons)',
+        497: 'HTTP to HTTPS',
+        499: '(Client Closed Request, Token required)',
+        600: None,
+    },
+    {  # p1 - unicode test cases
+        '': None,
+        '123': None,
+        '12.3': None,
+        '-123': None,
+        '-12.3': None,
+        '300': 'Multiple Choices',
+    }
+])
+def test_http_code_description(p):
+    for (http_code, result) in iteritems(p):
+        assert sut.http_code_description(http_code) == result
+
+
+@pytest.mark.parametrize('p', [
+    {  # p0 - bytes test cases
+        None: False,
+        42: False,
+        b'': False,
+        b'filename': False,
+        b'.syncthingfilename': True,
+        b'.syncthing.filename': True,
+        b'.syncthing-filename': True,
+        b'.!sync': True,
+        b'file.!sync': True,
+        b'file.!sync.ext': False,
+        b'.lftp-pget-status': True,
+        b'file.lftp-pget-status': True,
+        b'file.lftp-pget-status.ext': False,
+        b'.part': True,
+        b'file.part': True,
+        b'file.part.ext': False,
+    },
+    {  # p1 - unicode test cases
+        '': False,
+        'filename': False,
+        '.syncthingfilename': True,
+        '.syncthing.filename': True,
+        '.syncthing-filename': True,
+        '.!sync': True,
+        'file.!sync': True,
+        'file.!sync.ext': False,
+        '.lftp-pget-status': True,
+        'file.lftp-pget-status': True,
+        'file.lftp-pget-status.ext': False,
+        '.part': True,
+        'file.part': True,
+        'file.part.ext': False,
+    }
+])
+def test_is_sync_file(p, app_config):
+    app_config('SYNC_FILES', ['!sync', 'lftp-pget-status', 'part'])
+
+    for (filename, result) in iteritems(p):
+        assert sut.is_sync_file(filename) == result
+
+
+@pytest.mark.parametrize('p', [
+    {  # p0 - bytes test cases
+        None: False,
+        42: False,
+        b'': False,
+        b'filename': False,
+        b'.nzb': True,
+        b'file.nzb': True,
+        b'file.nzb.part': False,
+        b'.torrent': True,
+        b'file.torrent': True,
+        b'file.torrent.part': False,
+    },
+    {  # p1 - unicode test cases
+        '': False,
+        'filename': False,
+        '.nzb': True,
+        'file.nzb': True,
+        'file.nzb.part': False,
+        '.torrent': True,
+        'file.torrent': True,
+        'file.torrent.part': False,
+    }
+])
+def test_is_torrent_or_nzb_file(p):
+    for (filename, result) in iteritems(p):
+        assert sut.is_torrent_or_nzb_file(filename) == result
+
+
+@pytest.mark.parametrize('p', [
+    {  # p0 - bytes test cases
+        b'': '0.00 B',
+        b'1024': '1.00 KB',
+        b'1024.5': '1.00 KB',
+    },
+    {  # p1 - unicode test cases
+        None: '0.00 B',
+        '': '0.00 B',
+        '1024': '1.00 KB',
+        '1024.5': '1.00 KB',
+        -42.5: '0.00 B',
+        -42: '0.00 B',
+        0: '0.00 B',
+        25: '25.00 B',
+        25.5: '25.50 B',
+        2 ** 10: '1.00 KB',
+        50 * 2 ** 10 + 25: '50.02 KB',
+        2 ** 20: '1.00 MB',
+        100 * 2 ** 20 + 50 * 2 ** 10 + 25: '100.05 MB',
+        2 ** 30: '1.00 GB',
+        200 * 2 ** 30 + 100 * 2 ** 20 + 50 * 2 ** 10 + 25: '200.10 GB',
+        2 ** 40: '1.00 TB',
+        400 * 2 ** 40 + 200 * 2 ** 30 + 100 * 2 ** 20 + 50 * 2 ** 10 + 25: '400.20 TB',
+        2 ** 50: '1.00 PB',
+        800 * 2 ** 50 + 400 * 2 ** 40 + 200 * 2 ** 30 + 100 * 2 ** 20 + 50 * 2 ** 10 + 25: '800.39 PB',
+        2 ** 60: 2 ** 60,
+    }
+])
+def test_pretty_file_size(p):
+    for (size, result) in iteritems(p):
+        assert sut.pretty_file_size(size) == result
+
+
+@pytest.mark.parametrize('p', [
+    {  # p0 - bytes test cases
+        None: None,
+        42: 42,
+        b'': b'',
+        b'.': b'.',
+        b'filename': b'filename',
+        b'.bashrc': b'.bashrc',
+        b'.nzb': b'.nzb',
+        b'file.nzb': b'file',
+        b'file.name.nzb': b'file.name',
+        b'.torrent': b'.torrent',
+        b'file.torrent': b'file',
+        b'file.name.torrent': b'file.name',
+        b'.avi': b'.avi',
+        b'file.avi': b'file',
+        b'file.name.avi': b'file.name',
+    },
+    {  # p1 - unicode test cases
+        '': '',
+        '.': '.',
+        'filename': 'filename',
+        '.bashrc': '.bashrc',
+        '.nzb': '.nzb',
+        'file.nzb': 'file',
+        'file.name.nzb': 'file.name',
+        '.torrent': '.torrent',
+        'file.torrent': 'file',
+        'file.name.torrent': 'file.name',
+        '.avi': '.avi',
+        'file.avi': 'file',
+        'file.name.avi': 'file.name',
+    }
+])
+def test_remove_extension(p):
+    for (extension, result) in iteritems(p):
+        assert sut.remove_extension(extension) == result
+
+
+@pytest.mark.parametrize('p', [
+    {  # p0 - bytes test cases
+        (None, None): None,
+        (None, b''): None,
+        (42, None): 42,
+        (42, b''): 42,
+        (b'', None): b'',
+        (b'', b''): b'',
+        (b'.', None): b'.',
+        (b'.', b''): b'.',
+        (b'.', b'avi'): b'.',
+        (b'filename', None): b'filename',
+        (b'filename', b''): b'filename',
+        (b'filename', b'avi'): b'filename',
+        (b'.bashrc', None): b'.bashrc',
+        (b'.bashrc', b''): b'.bashrc',
+        (b'.bashrc', b'avi'): b'.bashrc',
+        (b'file.mkv', None): b'file.None',
+        (b'file.mkv', b''): b'file.',
+        (b'file.mkv', b'avi'): b'file.avi',
+        (b'file.name.mkv', None): b'file.name.None',
+        (b'file.name.mkv', b''): b'file.name.',
+        (b'file.name.mkv', b'avi'): b'file.name.avi',
+    },
+    {  # p1 - unicode test cases
+        (None, ''): None,
+        (42, ''): 42,
+        ('', ''): b'',
+        ('', None): '',
+        ('', b''): '',
+        ('', ''): '',
+        ('.', ''): b'.',
+        ('.', 'avi'): b'.',
+        ('.', None): '.',
+        ('.', b''): '.',
+        ('.', ''): '.',
+        ('.', b'avi'): '.',
+        ('.', 'avi'): '.',
+        ('filename', ''): b'filename',
+        ('filename', 'avi'): b'filename',
+        ('filename', None): 'filename',
+        ('filename', b''): 'filename',
+        ('filename', ''): 'filename',
+        ('filename', b'avi'): 'filename',
+        ('filename', 'avi'): 'filename',
+        ('.bashrc', ''): b'.bashrc',
+        ('.bashrc', 'avi'): b'.bashrc',
+        ('.bashrc', None): '.bashrc',
+        ('.bashrc', b''): '.bashrc',
+        ('.bashrc', ''): '.bashrc',
+        ('.bashrc', b'avi'): '.bashrc',
+        ('.bashrc', 'avi'): '.bashrc',
+        ('file.mkv', ''): b'file.',
+        ('file.mkv', 'avi'): b'file.avi',
+        ('file.mkv', None): 'file.None',
+        ('file.mkv', b''): 'file.',
+        ('file.mkv', ''): 'file.',
+        ('file.mkv', b'avi'): 'file.avi',
+        ('file.mkv', 'avi'): 'file.avi',
+        ('file.name.mkv', ''): b'file.name.',
+        ('file.name.mkv', 'avi'): b'file.name.avi',
+        ('file.name.mkv', None): 'file.name.None',
+        ('file.name.mkv', b''): 'file.name.',
+        ('file.name.mkv', ''): 'file.name.',
+        ('file.name.mkv', b'avi'): 'file.name.avi',
+        ('file.name.mkv', 'avi'): 'file.name.avi',
+    }
+])
+def test_replace_extension(p):
+    for ((filename, extension), result) in iteritems(p):
+        assert sut.replace_extension(filename, extension) == result
+
+
+@pytest.mark.parametrize('value,expected', [
+    # bytes test cases
+    (None, b''),
+    (42, b''),
+    (b'', b''),
+    (b'filename', b'filename'),
+    (b'fi\\le/na*me', b'fi-le-na-me'),
+    (b'fi:le"na<me', b'filename'),
+    (b'fi>le|na?me', b'filename'),
+    (b' . file\u2122name. .', b'file-u2122name'),
+    (b' . file\tname. .', b'filename'),
+    (b' . file\x00as\x08df\x1fname. .', b'fileasdfname'),
+
+    # unicode test cases
+    ('', ''),
+    ('filename', 'filename'),
+    ('fi\\le/na*me', 'fi-le-na-me'),
+    ('fi:le"na<me', 'filename'),
+    ('fi>le|na?me', 'filename'),
+    (' . file\u2122name. .', 'filename'),
+    (' . file\tname. .', 'filename'),
+    (' . file\x00as\x08df\x1fname. .', 'fileasdfname'),
+])
+def test_sanitize_filename(value, expected):
+    # Given
+
+    # When
+    actual = sut.sanitize_filename(value)
+
+    # Then
+    assert expected == actual
+
+
+@pytest.mark.parametrize('value,expected', [
+    # bytes test cases
+    (None, 0),
+    (b'', 0),
+    (b'123', 123),
+    (b'-123', -123),
+    (b'12.3', 0),
+    (b'-12.3', 0),
+    (0, 0),
+    (123, 123),
+    (-123, -123),
+    (12.3, 12),
+    (-12.3, -12),
+
+    # unicode test cases
+    ('', 0),
+    ('123', 123),
+    ('-123', -123),
+    ('12.3', 0),
+    ('-12.3', 0),
+])
+def test_try_int(value, expected):
+    # Given
+
+    # When
+    actual = sut.try_int(value)
+
+    # Then
+    assert expected == actual
+
+
+@pytest.mark.parametrize('value,expected', [
+    # bytes test cases
+    (None, lambda: 'default_value'),
+    (b'', lambda: 'default_value'),
+    (b'123', 123),
+    (b'-123', -123),
+    (b'12.3', lambda: 'default_value'),
+    (b'-12.3', lambda: 'default_value'),
+    (0, 0),
+    (123, 123),
+    (-123, -123),
+    (12.3, 12),
+    (-12.3, -12),
+
+    # unicode test cases
+    ('', lambda: 'default_value'),
+    ('123', 123),
+    ('-123', -123),
+    ('12.3', lambda: 'default_value'),
+    ('-12.3', lambda: 'default_value'),
+])
+def test_try_int_with_default(value, expected):
+    # Given
+    default_value = 42
+
+    if callable(expected):
+        expected = eval(expected())
+
+    # When
+    actual = sut.try_int(value, default_value)
+
+    # Then
+    assert expected == actual
+
+
+def test_convert_size():
+    # converts pretty file sizes to integers
+    assert sut.convert_size('1 B') == 1
+    assert sut.convert_size('1 KB') == 1024
+    # can use decimal units (e.g. KB = 1000 bytes instead of 1024)
+    assert sut.convert_size('1 kb', use_decimal=True) == 1000
+
+    # returns integer sizes for integers
+    assert sut.convert_size(0, -1) == 0
+    assert sut.convert_size(100, -1) == 100
+    # returns integer sizes for floats too
+    assert sut.convert_size(1.312, -1) == 1
+
+    # without a default value, failures return None
+    assert sut.convert_size('pancakes') is None
+
+    # default value can be anything
+    assert sut.convert_size(None, -1) == -1
+    assert sut.convert_size('', 3.14) == 3.14
+    assert sut.convert_size('elephant', 'frog') == 'frog'
+
+    # negative sizes return 0
+    assert sut.convert_size(-1024, -1) == 0
+    assert sut.convert_size('-1 GB', -1) == 0
+
+    # can also use `or` for a default value
+    assert sut.convert_size(None) or 100 == 100
+    # default doesn't have to be integer
+    assert sut.convert_size(None) or 1.61803 == 1.61803
+    # default doesn't have to be numeric either
+    assert sut.convert_size(None) or '100' == '100'
+    # can use `or` to provide a default when size evaluates to 0
+    assert sut.convert_size('-1 GB') or -1 == -1
+
+    # default units can be kwarg'd
+    assert sut.convert_size('1', default_units='GB') == sut.convert_size('1 GB')
+
+    # separator can be kwarg'd
+    assert sut.convert_size('1?GB', sep='?') == sut.convert_size('1 GB')
+
+    # can use custom dictionary to support internationalization
+    french = ['O', 'KO', 'MO', 'GO', 'TO', 'PO']
+    assert sut.convert_size('1 o', units=french) == 1
+    assert sut.convert_size('1 go', use_decimal=True, units=french) == 1000000000
+    assert sut.convert_size('1 o') is None  # Wrong units so result is None
+
+    # custom units need to be uppercase or they won't match
+    oops = ['b', 'kb', 'Mb', 'Gb', 'tB', 'Pb']
+    assert sut.convert_size('1 b', units=oops) is None
+    assert sut.convert_size('1 B', units=oops) is None
+    assert sut.convert_size('1 Mb', units=oops) is None
+    assert sut.convert_size('1 MB', units=oops) is None
+
+    # utilize the regex to parse sizes without separator
+    assert sut.convert_size('1GB', sep='') == 1073741824
+    assert sut.convert_size('1.00GB', sep='') == 1073741824
+    assert sut.convert_size('1.01GB', sep='') == 1084479242
+    assert sut.convert_size('1B', sep='') == 1
+
+
+def test_episode_num():
+    # Standard numbering
+    assert sut.episode_num(0, 1) == 'S00E01'  # Seasons start at 0 for specials
+    assert sut.episode_num(1, 1) == 'S01E01'
+
+    # Absolute numbering
+    assert sut.episode_num(1, numbering='absolute') == '001'
+    assert sut.episode_num(0, 1, numbering='absolute') == '001'
+    assert sut.episode_num(1, 0, numbering='absolute') == '001'
+
+    # Must have both season and episode for standard numbering
+    assert sut.episode_num(0) is None
+    assert sut.episode_num(1) is None
+
+    # Episode numbering starts at 1
+    assert sut.episode_num(0, 0) is None
+    assert sut.episode_num(1, 0) is None
+
+    # Absolute numbering starts at 1
+    assert sut.episode_num(0, 0, numbering='absolute') is None
+
+    # Absolute numbering can't have both season and episode
+    assert sut.episode_num(1, 1, numbering='absolute') is None
diff --git a/tests/legacy/helper/common_tests.py b/tests/legacy/helper/common_tests.py
deleted file mode 100644
--- a/tests/legacy/helper/common_tests.py
+++ /dev/null
@@ -1,440 +0,0 @@
-# coding=utf-8
-# This file is part of Medusa.
-#
-# Medusa is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# Medusa is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with Medusa. If not, see <http://www.gnu.org/licenses/>.
-"""Tests for helper.common package."""
-
-from __future__ import print_function
-
-import unittest
-
-from medusa import app
-from medusa.helper.common import (
-    convert_size, episode_num, http_code_description, is_sync_file, is_torrent_or_nzb_file,
-    pretty_file_size, remove_extension, replace_extension, sanitize_filename, try_int
-)
-from six import iteritems
-
-
-class CommonTests(unittest.TestCase):
-    """Test common."""
-
-    def test_http_code_description(self):
-        test_cases = {
-            None: None,
-            '': None,
-            '123': None,
-            '12.3': None,
-            '-123': None,
-            '-12.3': None,
-            '300': 'Multiple Choices',
-            0: None,
-            123: None,
-            12.3: None,
-            -123: None,
-            -12.3: None,
-            300: 'Multiple Choices',
-            451: '(Redirect, Unavailable For Legal Reasons)',
-            497: 'HTTP to HTTPS',
-            499: '(Client Closed Request, Token required)',
-            600: None,
-        }
-
-        unicode_test_cases = {
-            u'': None,
-            u'123': None,
-            u'12.3': None,
-            u'-123': None,
-            u'-12.3': None,
-            u'300': 'Multiple Choices',
-        }
-
-        for test in test_cases, unicode_test_cases:
-            for (http_code, result) in iteritems(test):
-                self.assertEqual(http_code_description(http_code), result)
-
-    def test_is_sync_file(self):
-        app.SYNC_FILES = ['!sync', 'lftp-pget-status', 'part']
-
-        test_cases = {
-            None: False,
-            42: False,
-            '': False,
-            'filename': False,
-            '.syncthingfilename': True,
-            '.syncthing.filename': True,
-            '.syncthing-filename': True,
-            '.!sync': True,
-            'file.!sync': True,
-            'file.!sync.ext': False,
-            '.lftp-pget-status': True,
-            'file.lftp-pget-status': True,
-            'file.lftp-pget-status.ext': False,
-            '.part': True,
-            'file.part': True,
-            'file.part.ext': False,
-        }
-
-        unicode_test_cases = {
-            u'': False,
-            u'filename': False,
-            u'.syncthingfilename': True,
-            u'.syncthing.filename': True,
-            u'.syncthing-filename': True,
-            u'.!sync': True,
-            u'file.!sync': True,
-            u'file.!sync.ext': False,
-            u'.lftp-pget-status': True,
-            u'file.lftp-pget-status': True,
-            u'file.lftp-pget-status.ext': False,
-            u'.part': True,
-            u'file.part': True,
-            u'file.part.ext': False,
-        }
-
-        for tests in test_cases, unicode_test_cases:
-            for (filename, result) in iteritems(tests):
-                self.assertEqual(is_sync_file(filename), result)
-
-    def test_is_torrent_or_nzb_file(self):
-        test_cases = {
-            None: False,
-            42: False,
-            '': False,
-            'filename': False,
-            '.nzb': True,
-            'file.nzb': True,
-            'file.nzb.part': False,
-            '.torrent': True,
-            'file.torrent': True,
-            'file.torrent.part': False,
-        }
-
-        unicode_test_cases = {
-            u'': False,
-            u'filename': False,
-            u'.nzb': True,
-            u'file.nzb': True,
-            u'file.nzb.part': False,
-            u'.torrent': True,
-            u'file.torrent': True,
-            u'file.torrent.part': False,
-        }
-
-        for tests in test_cases, unicode_test_cases:
-            for (filename, result) in iteritems(tests):
-                self.assertEqual(is_torrent_or_nzb_file(filename), result)
-
-    def test_pretty_file_size(self):
-        test_cases = {
-            None: '0.00 B',
-            '': '0.00 B',
-            '1024': '1.00 KB',
-            '1024.5': '1.00 KB',
-            -42.5: '0.00 B',
-            -42: '0.00 B',
-            0: '0.00 B',
-            25: '25.00 B',
-            25.5: '25.50 B',
-            2 ** 10: '1.00 KB',
-            50 * 2 ** 10 + 25: '50.02 KB',
-            2 ** 20: '1.00 MB',
-            100 * 2 ** 20 + 50 * 2 ** 10 + 25: '100.05 MB',
-            2 ** 30: '1.00 GB',
-            200 * 2 ** 30 + 100 * 2 ** 20 + 50 * 2 ** 10 + 25: '200.10 GB',
-            2 ** 40: '1.00 TB',
-            400 * 2 ** 40 + 200 * 2 ** 30 + 100 * 2 ** 20 + 50 * 2 ** 10 + 25: '400.20 TB',
-            2 ** 50: '1.00 PB',
-            800 * 2 ** 50 + 400 * 2 ** 40 + 200 * 2 ** 30 + 100 * 2 ** 20 + 50 * 2 ** 10 + 25: '800.39 PB',
-            2 ** 60: 2 ** 60,
-        }
-
-        unicode_test_cases = {
-            u'': '0.00 B',
-            u'1024': '1.00 KB',
-            u'1024.5': '1.00 KB',
-        }
-
-        for tests in test_cases, unicode_test_cases:
-            for (size, result) in iteritems(tests):
-                self.assertEqual(pretty_file_size(size), result)
-
-    def test_remove_extension(self):
-        test_cases = {
-            None: None,
-            42: 42,
-            '': '',
-            '.': '.',
-            'filename': 'filename',
-            '.bashrc': '.bashrc',
-            '.nzb': '.nzb',
-            'file.nzb': 'file',
-            'file.name.nzb': 'file.name',
-            '.torrent': '.torrent',
-            'file.torrent': 'file',
-            'file.name.torrent': 'file.name',
-            '.avi': '.avi',
-            'file.avi': 'file',
-            'file.name.avi': 'file.name',
-        }
-
-        unicode_test_cases = {
-            u'': u'',
-            u'.': u'.',
-            u'filename': u'filename',
-            u'.bashrc': u'.bashrc',
-            u'.nzb': u'.nzb',
-            u'file.nzb': u'file',
-            u'file.name.nzb': u'file.name',
-            u'.torrent': u'.torrent',
-            u'file.torrent': u'file',
-            u'file.name.torrent': u'file.name',
-            u'.avi': u'.avi',
-            u'file.avi': u'file',
-            u'file.name.avi': u'file.name',
-        }
-        for tests in test_cases, unicode_test_cases:
-            for (extension, result) in iteritems(tests):
-                self.assertEqual(remove_extension(extension), result)
-
-    def test_replace_extension(self):
-        test_cases = {
-            (None, None): None,
-            (None, ''): None,
-            (42, None): 42,
-            (42, ''): 42,
-            ('', None): '',
-            ('', ''): '',
-            ('.', None): '.',
-            ('.', ''): '.',
-            ('.', 'avi'): '.',
-            ('filename', None): 'filename',
-            ('filename', ''): 'filename',
-            ('filename', 'avi'): 'filename',
-            ('.bashrc', None): '.bashrc',
-            ('.bashrc', ''): '.bashrc',
-            ('.bashrc', 'avi'): '.bashrc',
-            ('file.mkv', None): 'file.None',
-            ('file.mkv', ''): 'file.',
-            ('file.mkv', 'avi'): 'file.avi',
-            ('file.name.mkv', None): 'file.name.None',
-            ('file.name.mkv', ''): 'file.name.',
-            ('file.name.mkv', 'avi'): 'file.name.avi',
-        }
-
-        unicode_test_cases = {
-            (None, u''): None,
-            (42, u''): 42,
-            ('', u''): '',
-            (u'', None): u'',
-            (u'', ''): u'',
-            (u'', u''): u'',
-            ('.', u''): '.',
-            ('.', u'avi'): '.',
-            (u'.', None): u'.',
-            (u'.', ''): u'.',
-            (u'.', u''): u'.',
-            (u'.', 'avi'): u'.',
-            (u'.', u'avi'): u'.',
-            ('filename', u''): 'filename',
-            ('filename', u'avi'): 'filename',
-            (u'filename', None): u'filename',
-            (u'filename', ''): u'filename',
-            (u'filename', u''): u'filename',
-            (u'filename', 'avi'): u'filename',
-            (u'filename', u'avi'): u'filename',
-            ('.bashrc', u''): '.bashrc',
-            ('.bashrc', u'avi'): '.bashrc',
-            (u'.bashrc', None): u'.bashrc',
-            (u'.bashrc', ''): u'.bashrc',
-            (u'.bashrc', u''): u'.bashrc',
-            (u'.bashrc', 'avi'): u'.bashrc',
-            (u'.bashrc', u'avi'): u'.bashrc',
-            ('file.mkv', u''): 'file.',
-            ('file.mkv', u'avi'): 'file.avi',
-            (u'file.mkv', None): u'file.None',
-            (u'file.mkv', ''): u'file.',
-            (u'file.mkv', u''): u'file.',
-            (u'file.mkv', 'avi'): u'file.avi',
-            (u'file.mkv', u'avi'): u'file.avi',
-            ('file.name.mkv', u''): 'file.name.',
-            ('file.name.mkv', u'avi'): 'file.name.avi',
-            (u'file.name.mkv', None): u'file.name.None',
-            (u'file.name.mkv', ''): u'file.name.',
-            (u'file.name.mkv', u''): u'file.name.',
-            (u'file.name.mkv', 'avi'): u'file.name.avi',
-            (u'file.name.mkv', u'avi'): u'file.name.avi',
-        }
-
-        for tests in test_cases, unicode_test_cases:
-            for ((filename, extension), result) in iteritems(tests):
-                self.assertEqual(replace_extension(filename, extension), result)
-
-    def test_sanitize_filename(self):
-        test_cases = {
-            None: '',
-            42: '',
-            '': '',
-            'filename': 'filename',
-            'fi\\le/na*me': 'fi-le-na-me',
-            'fi:le"na<me': 'filename',
-            'fi>le|na?me': 'filename',
-            ' . file\u2122name. .': 'file-u2122name',  # pylint: disable=anomalous-unicode-escape-in-string
-        }
-
-        unicode_test_cases = {
-            u'': u'',
-            u'filename': u'filename',
-            u'fi\\le/na*me': u'fi-le-na-me',
-            u'fi:le"na<me': u'filename',
-            u'fi>le|na?me': u'filename',
-            u' . file\u2122name. .': u'filename',
-        }
-
-        for tests in test_cases, unicode_test_cases:
-            for (filename, result) in iteritems(tests):
-                self.assertEqual(sanitize_filename(filename), result)
-
-    def test_try_int(self):
-        test_cases = {
-            None: 0,
-            '': 0,
-            '123': 123,
-            '-123': -123,
-            '12.3': 0,
-            '-12.3': 0,
-            0: 0,
-            123: 123,
-            -123: -123,
-            12.3: 12,
-            -12.3: -12,
-        }
-
-        unicode_test_cases = {
-            u'': 0,
-            u'123': 123,
-            u'-123': -123,
-            u'12.3': 0,
-            u'-12.3': 0,
-        }
-
-        for test in test_cases, unicode_test_cases:
-            for (candidate, result) in iteritems(test):
-                self.assertEqual(try_int(candidate), result)
-
-    def test_try_int_with_default(self):
-        default_value = 42
-        test_cases = {
-            None: default_value,
-            '': default_value,
-            '123': 123,
-            '-123': -123,
-            '12.3': default_value,
-            '-12.3': default_value,
-            0: 0,
-            123: 123,
-            -123: -123,
-            12.3: 12,
-            -12.3: -12,
-        }
-
-        unicode_test_cases = {
-            u'': default_value,
-            u'123': 123,
-            u'-123': -123,
-            u'12.3': default_value,
-            u'-12.3': default_value,
-        }
-
-        for test in test_cases, unicode_test_cases:
-            for (candidate, result) in iteritems(test):
-                self.assertEqual(try_int(candidate, default_value), result)
-
-    def test_convert_size(self):
-        # converts pretty file sizes to integers
-        self.assertEqual(convert_size('1 B'), 1)
-        self.assertEqual(convert_size('1 KB'), 1024)
-        self.assertEqual(convert_size('1 kb', use_decimal=True), 1000)  # can use decimal units (e.g. KB = 1000 bytes instead of 1024)
-
-        # returns integer sizes for integers
-        self.assertEqual(convert_size(0, -1), 0)
-        self.assertEqual(convert_size(100, -1), 100)
-        self.assertEqual(convert_size(1.312, -1), 1)  # returns integer sizes for floats too
-
-        # without a default value, failures return None
-        self.assertEqual(convert_size('pancakes'), None)
-
-        # default value can be anything
-        self.assertEqual(convert_size(None, -1), -1)
-        self.assertEqual(convert_size('', 3.14), 3.14)
-        self.assertEqual(convert_size('elephant', 'frog'), 'frog')
-
-        # negative sizes return 0
-        self.assertEqual(convert_size(-1024, -1), 0)
-        self.assertEqual(convert_size('-1 GB', -1), 0)
-
-        # can also use `or` for a default value
-        self.assertEqual(convert_size(None) or 100, 100)
-        self.assertEqual(convert_size(None) or 1.61803, 1.61803)  # default doesn't have to be integer
-        self.assertEqual(convert_size(None) or '100', '100')  # default doesn't have to be numeric either
-        self.assertEqual(convert_size('-1 GB') or -1, -1)  # can use `or` to provide a default when size evaluates to 0
-
-        # default units can be kwarg'd
-        self.assertEqual(convert_size('1', default_units='GB'), convert_size('1 GB'))
-
-        # separator can be kwarg'd
-        self.assertEqual(convert_size('1?GB', sep='?'), convert_size('1 GB'))
-
-        # can use custom dictionary to support internationalization
-        french = ['O', 'KO', 'MO', 'GO', 'TO', 'PO']
-        self.assertEqual(convert_size('1 o', units=french), 1)
-        self.assertEqual(convert_size('1 go', use_decimal=True, units=french), 1000000000)
-        self.assertEqual(convert_size('1 o'), None)  # Wrong units so result is None
-
-        # custom units need to be uppercase or they won't match
-        oops = ['b', 'kb', 'Mb', 'Gb', 'tB', 'Pb']
-        self.assertEqual(convert_size('1 b', units=oops), None)
-        self.assertEqual(convert_size('1 B', units=oops), None)
-        self.assertEqual(convert_size('1 Mb', units=oops), None)
-        self.assertEqual(convert_size('1 MB', units=oops), None)
-
-        # utilize the regex to parse sizes without separator
-        self.assertEqual(convert_size('1GB', sep=''), 1073741824)
-        self.assertEqual(convert_size('1.00GB', sep=''), 1073741824)
-        self.assertEqual(convert_size('1.01GB', sep=''), 1084479242)
-        self.assertEqual(convert_size('1B', sep=''), 1)
-
-    def test_episode_num(self):
-        # Standard numbering
-        self.assertEqual(episode_num(0, 1), 'S00E01')  # Seasons start at 0 for specials
-        self.assertEqual(episode_num(1, 1), 'S01E01')
-
-        # Absolute numbering
-        self.assertEqual(episode_num(1, numbering='absolute'), '001')
-        self.assertEqual(episode_num(0, 1, numbering='absolute'), '001')
-        self.assertEqual(episode_num(1, 0, numbering='absolute'), '001')
-
-        # Must have both season and episode for standard numbering
-        self.assertEqual(episode_num(0), None)
-        self.assertEqual(episode_num(1), None)
-
-        # Episode numbering starts at 1
-        self.assertEqual(episode_num(0, 0), None)
-        self.assertEqual(episode_num(1, 0), None)
-
-        # Absolute numbering starts at 1
-        self.assertEqual(episode_num(0, 0, numbering='absolute'), None)
-
-        # Absolute numbering can't have both season and episode
-        self.assertEqual(episode_num(1, 1, numbering='absolute'), None)