Repository: pymedusa/Medusa

Issue:
Parsing in Notification Emails

**Branch/Commit:** 77b20916ab577b82112ebc624f816054788c63f2 / master
**OS:** Server 2012 R2
**What you did:** - Sent a notification email
**What happened:** Email body parsing incorrect
**What you expected:** Parsing to be correct 
**Logs:** NA - See screenshots below! 

Hey guys, my TV show notification emails are showing like below, they display the same on webmail / outlook / android mail app etc.

![image](https://user-images.githubusercontent.com/38138869/39642804-bc6b7c16-4fca-11e8-8376-d094f26882a7.png)

![image](https://user-images.githubusercontent.com/38138869/39642829-cc348336-4fca-11e8-8eaf-b0704107f0d6.png)
![image](https://user-images.githubusercontent.com/38138869/39642858-db2890ee-4fca-11e8-9fc3-19c80cf32c1b.png)
![image](https://user-images.githubusercontent.com/38138869/39642877-e943be06-4fca-11e8-9dc8-5359a479fc04.png)
![image](https://user-images.githubusercontent.com/38138869/39642894-f637f0c8-4fca-11e8-8c13-fa898e059fb7.png)



Discussion:

Resolving patch:
diff --git a/medusa/notifiers/emailnotify.py b/medusa/notifiers/emailnotify.py
--- a/medusa/notifiers/emailnotify.py
+++ b/medusa/notifiers/emailnotify.py
@@ -1,4 +1,5 @@
 # coding=utf-8
+"""Email notifier module."""
 
 from __future__ import unicode_literals
 
@@ -20,10 +21,34 @@
 
 
 class Notifier(object):
+    """
+    Email notifier class.
+
+    Possible patterns for the `ep_name` input:
+        Downloaded/Snatched:
+            %SN - %Sx%0E - %EN - %QN
+            %SN - %Sx%0E - %AB - %EN - %QN
+        Subtitle Downloaded:
+            %SN - %AB - %EN
+            %SN - %AD - %EN
+            %SN - S%0SE%0E - %EN
+    """
+
+    name_pattern = re.compile(
+        r'(?P<show>.+?) - '
+        r'(?P<ep_id>S?\d+[Ex]\d+( - \d{3})?|\d{3}|\d{4}-\d{2}-\d{2}) - '
+        r'(?P<episode>.*)'
+    )
+
     def __init__(self):
         self.last_err = None
 
-    def test_notify(self, host, port, smtp_from, use_tls, user, pwd, to):  # pylint: disable=too-many-arguments
+    def test_notify(self, host, port, smtp_from, use_tls, user, pwd, to):
+        """
+        Send a test notification.
+
+        :return: True for no issue or False if there was an error
+        """
         msg = MIMEText('This is a test message from Medusa. If you\'re reading this, the test succeeded.')
         if app.EMAIL_SUBJECT:
             msg[b'Subject'] = '[TEST] ' + app.EMAIL_SUBJECT
@@ -34,9 +59,9 @@ def test_notify(self, host, port, smtp_from, use_tls, user, pwd, to):  # pylint:
         msg[b'Date'] = formatdate(localtime=True)
         return self._sendmail(host, port, smtp_from, use_tls, user, pwd, [to], msg, True)
 
-    def notify_snatch(self, ep_name, is_proper, title='Snatched:'):  # pylint: disable=unused-argument
+    def notify_snatch(self, ep_name, is_proper, title='Snatched:'):
         """
-        Send a notification that an episode was snatched
+        Send a notification that an episode was snatched.
 
         ep_name: The name of the episode that was snatched
         title: The title of the notification (optional)
@@ -44,8 +69,8 @@ def notify_snatch(self, ep_name, is_proper, title='Snatched:'):  # pylint: disab
         ep_name = ss(ep_name)
 
         if app.USE_EMAIL and app.EMAIL_NOTIFY_ONSNATCH:
-            show = self._parseEp(ep_name)
-            to = self._generate_recipients(show)
+            parsed = self._parse_name(ep_name)
+            to = self._generate_recipients(parsed['show'])
             if not to:
                 log.debug('Skipping email notify because there are no configured recipients')
             else:
@@ -54,11 +79,15 @@ def notify_snatch(self, ep_name, is_proper, title='Snatched:'):  # pylint: disab
                     msg.attach(MIMEText(
                         '<body style="font-family:Helvetica, Arial, sans-serif;">'
                         '<h3>Medusa Notification - Snatched</h3><br>'
-                        '<p>Show: <b>{0}</b></p><br><p>Episode: <b>{1}</b></p><br><br>'
+                        '<p>Show: <b>{show}</b></p><br>'
+                        '<p>Episode: <b>{ep_id}{episode}</b></p><br><br>'
                         '<footer style="margin-top: 2.5em; padding: .7em 0; '
                         'color: #777; border-top: #BBB solid 1px;">'
-                        'Powered by Medusa.</footer></body>'.format
-                        (show, re.search('.+ - (.+?-.+) -.+', ep_name).group(1)),
+                        'Powered by Medusa.</footer></body>'.format(
+                            show=parsed['show'],
+                            ep_id=(parsed['ep_id'] + ' - ') if 'ep_id' in parsed else '',
+                            episode=parsed['episode']
+                        ),
                         'html'))
 
                 except Exception:
@@ -81,9 +110,9 @@ def notify_snatch(self, ep_name, is_proper, title='Snatched:'):  # pylint: disab
                 else:
                     log.warning('Snatch notification error: {0}', self.last_err)
 
-    def notify_download(self, ep_name, title='Completed:'):  # pylint: disable=unused-argument
+    def notify_download(self, ep_name, title='Completed:'):
         """
-        Send a notification that an episode was downloaded
+        Send a notification that an episode was downloaded.
 
         ep_name: The name of the episode that was downloaded
         title: The title of the notification (optional)
@@ -91,8 +120,8 @@ def notify_download(self, ep_name, title='Completed:'):  # pylint: disable=unuse
         ep_name = ss(ep_name)
 
         if app.USE_EMAIL and app.EMAIL_NOTIFY_ONDOWNLOAD:
-            show = self._parseEp(ep_name)
-            to = self._generate_recipients(show)
+            parsed = self._parse_name(ep_name)
+            to = self._generate_recipients(parsed['show'])
             if not to:
                 log.debug('Skipping email notify because there are no configured recipients')
             else:
@@ -101,11 +130,15 @@ def notify_download(self, ep_name, title='Completed:'):  # pylint: disable=unuse
                     msg.attach(MIMEText(
                         '<body style="font-family:Helvetica, Arial, sans-serif;">'
                         '<h3>Medusa Notification - Downloaded</h3><br>'
-                        '<p>Show: <b>{0}</b></p><br><p>Episode: <b>{1}</b></p><br><br>'
+                        '<p>Show: <b>{show}</b></p><br>'
+                        '<p>Episode: <b>{ep_id}{episode}</b></p><br><br>'
                         '<footer style="margin-top: 2.5em; padding: .7em 0; '
                         'color: #777; border-top: #BBB solid 1px;">'
-                        'Powered by Medusa.</footer></body>'.format
-                        (show, re.search('.+ - (.+?-.+) -.+', ep_name).group(1)),
+                        'Powered by Medusa.</footer></body>'.format(
+                            show=parsed['show'],
+                            ep_id=(parsed['ep_id'] + ' - ') if 'ep_id' in parsed else '',
+                            episode=parsed['episode']
+                        ),
                         'html'))
 
                 except Exception:
@@ -128,9 +161,9 @@ def notify_download(self, ep_name, title='Completed:'):  # pylint: disable=unuse
                 else:
                     log.warning('Download notification error: {0}', self.last_err)
 
-    def notify_subtitle_download(self, ep_name, lang, title='Downloaded subtitle:'):  # pylint: disable=unused-argument
+    def notify_subtitle_download(self, ep_name, lang, title='Downloaded subtitle:'):
         """
-        Send a notification that an subtitle was downloaded
+        Send a notification that a subtitle was downloaded.
 
         ep_name: The name of the episode that was downloaded
         lang: Subtitle language wanted
@@ -138,8 +171,8 @@ def notify_subtitle_download(self, ep_name, lang, title='Downloaded subtitle:'):
         ep_name = ss(ep_name)
 
         if app.USE_EMAIL and app.EMAIL_NOTIFY_ONSUBTITLEDOWNLOAD:
-            show = self._parseEp(ep_name)
-            to = self._generate_recipients(show)
+            parsed = self._parse_name(ep_name)
+            to = self._generate_recipients(parsed['show'])
             if not to:
                 log.debug('Skipping email notify because there are no configured recipients')
             else:
@@ -148,12 +181,17 @@ def notify_subtitle_download(self, ep_name, lang, title='Downloaded subtitle:'):
                     msg.attach(MIMEText(
                         '<body style="font-family:Helvetica, Arial, sans-serif;">'
                         '<h3>Medusa Notification - Subtitle Downloaded</h3><br>'
-                        '<p>Show: <b>{0}</b></p><br><p>Episode: <b>{1}</b></p><br>'
-                        '<p>Language: <b>{2}</b></p><br><br>'
+                        '<p>Show: <b>{show}</b></p><br>'
+                        '<p>Episode: <b>{ep_id}{episode}</b></p><br>'
+                        '<p>Language: <b>{lang}</b></p><br><br>'
                         '<footer style="margin-top: 2.5em; padding: .7em 0; '
                         'color: #777; border-top: #BBB solid 1px;">'
-                        'Powered by Medusa.</footer></body>'.format
-                        (show, re.search('.+ - (.+?-.+) -.+', ep_name).group(1), lang),
+                        'Powered by Medusa.</footer></body>'.format(
+                            show=parsed['show'],
+                            ep_id=(parsed['ep_id'] + ' - ') if 'ep_id' in parsed else '',
+                            episode=parsed['episode'],
+                            lang=lang
+                        ),
                         'html'))
                 except Exception:
                     try:
@@ -176,7 +214,8 @@ def notify_subtitle_download(self, ep_name, lang, title='Downloaded subtitle:'):
 
     def notify_git_update(self, new_version='??'):
         """
-        Send a notification that Medusa was updated
+        Send a notification that Medusa was updated.
+
         new_version: The commit Medusa was updated to
         """
         if app.USE_EMAIL:
@@ -214,7 +253,8 @@ def notify_git_update(self, new_version='??'):
 
     def notify_login(self, ipaddress=''):
         """
-        Send a notification that Medusa was logged into remotely
+        Send a notification that Medusa was logged into remotely.
+
         ipaddress: The ip Medusa was logged into from
         """
         if app.USE_EMAIL:
@@ -250,40 +290,44 @@ def notify_login(self, ipaddress=''):
                     log.warning('Login notification error: {0}', self.last_err)
 
     @staticmethod
-    def _generate_recipients(show):  # pylint: disable=too-many-branches
+    def _generate_recipients(show):
         addrs = []
         main_db_con = db.DBConnection()
 
         # Grab the global recipients
         if app.EMAIL_LIST:
-            for addr in app.EMAIL_LIST:
-                if addr.strip():
-                    addrs.append(addr)
+            addrs.extend(
+                addr for addr in app.EMAIL_LIST
+                if addr.strip()
+            )
 
         # Grab the per-show-notification recipients
-        if show is not None:
-            for s in show:
-                for subs in main_db_con.select(
-                        'SELECT notify_list '
-                        'FROM tv_shows '
-                        'WHERE show_name = ?',
-                        (s,)):
-                    if subs[b'notify_list']:
-                        if subs[b'notify_list'][0] == '{':
-                            entries = dict(ast.literal_eval(subs[b'notify_list']))
-                            for addr in entries[b'emails'].split(','):
-                                if addr.strip():
-                                    addrs.append(addr)
-                        else:                                           # Legacy
-                            for addr in subs[b'notify_list'].split(','):
-                                if addr.strip():
-                                    addrs.append(addr)
+        if show:
+            sql_results = main_db_con.select(
+                'SELECT notify_list '
+                'FROM tv_shows '
+                'WHERE show_name = ?',
+                [show]
+            )
+            for row in sql_results:
+                notify_list = row[b'notify_list']
+                if not notify_list:
+                    continue
+
+                if notify_list[0] == '{':
+                    entries = dict(ast.literal_eval(notify_list))
+                    notify_list = entries['emails']
+
+                addrs.extend(
+                    addr for addr in notify_list.split(',')
+                    if addr.strip()
+                )
 
         addrs = set(addrs)
         log.debug('Notification recipients: {0}', addrs)
         return addrs
 
-    def _sendmail(self, host, port, smtp_from, use_tls, user, pwd, to, msg, smtpDebug=False):  # pylint: disable=too-many-arguments
+    def _sendmail(self, host, port, smtp_from, use_tls, user, pwd, to, msg, smtp_debug=False):
         log.debug(
             'HOST: {host}; PORT: {port}; FROM: {sender}, TLS: {tls},'
             ' USER: {user}, PWD: {password}, TO: {recipient}', {
@@ -304,7 +348,7 @@ def _sendmail(self, host, port, smtp_from, use_tls, user, pwd, to, msg, smtpDebu
             self.last_err = '{0}'.format(error)
             return False
 
-        if smtpDebug:
+        if smtp_debug:
             srv.set_debuglevel(1)
         try:
             if use_tls in ('1', True) or (user and pwd):
@@ -325,12 +369,25 @@ def _sendmail(self, host, port, smtp_from, use_tls, user, pwd, to, msg, smtpDebu
             self.last_err = '{0}'.format(error)
             return False
 
-    @staticmethod
-    def _parseEp(ep_name):
+    @classmethod
+    def _parse_name(cls, ep_name):
         ep_name = ss(ep_name)
 
-        sep = ' - '
-        titles = ep_name.split(sep)
-        titles.sort(key=len, reverse=True)
-        log.debug('TITLES: {0}', titles)
-        return titles
+        # @TODO: Prone to issues, best solution is to have a dictionary passed to notifiers
+        match = cls.name_pattern.match(ep_name)
+
+        # Fallback
+        if not match:
+            # @TODO: This won't be needed when notifiers receive a dictionary
+            log.warning('Unable to parse "{0}" for email notification', ep_name)
+            titles = ep_name.split(' - ')
+            return {
+                'show': titles[0],
+                'episode': ' - '.join(titles[1:])
+            }
+
+        result = match.groupdict()
+
+        log.debug('Email notifier parsed "{0}" into {1!r}',
+                  ep_name, result)
+        return result


Test patch:
diff --git a/tests/notifiers/__init__.py b/tests/notifiers/__init__.py
new file mode 100644
--- /dev/null
+++ b/tests/notifiers/__init__.py
@@ -0,0 +1,2 @@
+# coding=utf-8
+"""Tests for medusa.notifiers module."""
diff --git a/tests/notifiers/test_emailnotify.py b/tests/notifiers/test_emailnotify.py
new file mode 100644
--- /dev/null
+++ b/tests/notifiers/test_emailnotify.py
@@ -0,0 +1,240 @@
+# coding=utf-8
+"""Tests for medusa.notifiers.emailnotify module."""
+
+from __future__ import unicode_literals
+
+from medusa.notifiers.emailnotify import Notifier
+
+import pytest
+
+from six import text_type
+
+
+@pytest.mark.parametrize('p', [
+    {  # p0 - Fail and fallback to legacy parsing
+        'ep_name': 'Archer - 9x02 -x Danger Island - Disheartening Situation - 1080p WEB-DL',
+        'expected': {
+            'show': 'Archer',
+            'episode': '9x02 -x Danger Island - Disheartening Situation - 1080p WEB-DL'
+        }
+    },
+    {  # p1 - [%SN - %Sx%0E - %EN - %QN] - hypen in episode name
+        'ep_name': 'Archer - 9x02 - Danger Island - Disheartening Situation - 1080p WEB-DL',
+        'expected': {
+            'show': 'Archer',
+            'ep_id': '9x02',
+            'episode': 'Danger Island - Disheartening Situation - 1080p WEB-DL'
+        }
+    },
+    {  # p2 - [%SN - %Sx%0E - %EN - %QN]
+        'ep_name': 'Jersey Shore Family Vacation - 1x06 - Meatball Down - 1080p WEB-DL',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': '1x06',
+            'episode': 'Meatball Down - 1080p WEB-DL'
+        }
+    },
+    {  # p3 - [%SN - %Sx%0E - %EN - %QN] - empty episode name
+        'ep_name': 'Jersey Shore Family Vacation - 1x06 -  - 1080p WEB-DL',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': '1x06',
+            'episode': ' - 1080p WEB-DL'
+        }
+    },
+    {  # p4 - [%SN - %Sx%0E - %EN - %QN] - with additions
+        'ep_name': 'Jersey Shore Family Vacation - 1x06 - Meatball Down - 1080p WEB-DL with {seeders} seeders and {leechers} leechers from {provider}',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': '1x06',
+            'episode': 'Meatball Down - 1080p WEB-DL with {seeders} seeders and {leechers} leechers from {provider}'
+        }
+    },
+    {  # p5 - [%SN - %Sx%0E - %AB - %EN - %QN] - hypen in episode name
+        'ep_name': 'Archer - 9x02 - 092 - Danger Island - Disheartening Situation - 1080p WEB-DL',
+        'expected': {
+            'show': 'Archer',
+            'ep_id': '9x02 - 092',
+            'episode': 'Danger Island - Disheartening Situation - 1080p WEB-DL'
+        }
+    },
+    {  # p6 - [%SN - %Sx%0E - %AB - %EN - %QN]
+        'ep_name': 'Jersey Shore Family Vacation - 1x06 - 006 - Meatball Down - 1080p WEB-DL',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': '1x06 - 006',
+            'episode': 'Meatball Down - 1080p WEB-DL'
+        }
+    },
+    {  # p7 - [%SN - %Sx%0E - %AB - %EN - %QN] - empty episode name
+        'ep_name': 'Jersey Shore Family Vacation - 1x06 - 006 -  - 1080p WEB-DL',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': '1x06 - 006',
+            'episode': ' - 1080p WEB-DL'
+        }
+    },
+    {  # p8 - [%SN - %AB - %EN] - hypen in episode name
+        'ep_name': 'Archer - 092 - Danger Island - Disheartening Situation',
+        'expected': {
+            'show': 'Archer',
+            'ep_id': '092',
+            'episode': 'Danger Island - Disheartening Situation'
+        }
+    },
+    {  # p9 - [%SN - %AB - %EN]
+        'ep_name': 'Jersey Shore Family Vacation - 006 - Meatball Down',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': '006',
+            'episode': 'Meatball Down'
+        }
+    },
+    {  # p10 - [%SN - %AB - %EN] - empty episode name
+        'ep_name': 'Jersey Shore Family Vacation - 006 - ',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': '006',
+            'episode': ''
+        }
+    },
+    {  # p11 - [%SN - %AD - %EN] - hypen in episode name
+        'ep_name': 'Archer - 2017-08-12 - Danger Island - Disheartening Situation',
+        'expected': {
+            'show': 'Archer',
+            'ep_id': '2017-08-12',
+            'episode': 'Danger Island - Disheartening Situation'
+        }
+    },
+    {  # p12 - [%SN - %AD - %EN]
+        'ep_name': 'Jersey Shore Family Vacation - 2018-04-20 - Meatball Down',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': '2018-04-20',
+            'episode': 'Meatball Down'
+        }
+    },
+    {  # p13 - [%SN - %AD - %EN] - empty episode name
+        'ep_name': 'Jersey Shore Family Vacation - 2018-04-20 - ',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': '2018-04-20',
+            'episode': ''
+        }
+    },
+    {  # p14 - [%SN - S%0SE%0E - %EN]
+        'ep_name': 'Jersey Shore Family Vacation - S01E06 - Meatball Down',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': 'S01E06',
+            'episode': 'Meatball Down'
+        }
+    },
+    {  # p15 - [%SN - S%0SE%0E - %EN] - with absolute number
+        'ep_name': 'Jersey Shore Family Vacation - S01E06 - 006 - Meatball Down',
+        'expected': {
+            'show': 'Jersey Shore Family Vacation',
+            'ep_id': 'S01E06 - 006',
+            'episode': 'Meatball Down'
+        }
+    },
+    {  # p16 - [%SN - S%0SE%0E - %EN] - with absolute number and hypen in show name
+        'ep_name': 'Jersey Shore - Family Vacation - S01E06 - 006 - Meatball Down',
+        'expected': {
+            'show': 'Jersey Shore - Family Vacation',
+            'ep_id': 'S01E06 - 006',
+            'episode': 'Meatball Down'
+        }
+    },
+])
+def test__parse_name(p):
+    # Given
+    ep_name = p['ep_name']
+    expected = p['expected']
+
+    # When
+    actual = Notifier._parse_name(ep_name)
+
+    # Then
+    assert actual == expected
+
+
+@pytest.mark.parametrize('p', [
+    {  # p0 - not show-specific
+        'show': None,
+        'EMAIL_LIST': [
+            'admin@pymedusa.com',
+            'sameuser@pymedusa.com',
+            'sameuser@pymedusa.com',
+        ],
+        'mocks': [],
+        'expected': {
+            'admin@pymedusa.com',
+            'sameuser@pymedusa.com',
+        }
+    },
+    {  # p1 - show-specific, legacy
+        'show': 'Show Name',
+        'EMAIL_LIST': [
+            'admin@pymedusa.com',
+            'sameuser@pymedusa.com',
+        ],
+        'mocks': [
+            ('medusa.db.DBConnection.select', [{
+                'notify_list': 'sameuser@pymedusa.com,user1@pymedusa.com'
+            }])
+        ],
+        'expected': {
+            'admin@pymedusa.com',
+            'sameuser@pymedusa.com',
+            'user1@pymedusa.com'
+        }
+    },
+    {  # p2 - show-specific, no emails
+        'show': 'Show Name',
+        'EMAIL_LIST': [
+            'admin@pymedusa.com',
+            'sameuser@pymedusa.com',
+        ],
+        'mocks': [
+            ('medusa.db.DBConnection.select', [{
+                'notify_list': ''
+            }])
+        ],
+        'expected': {
+            'admin@pymedusa.com',
+            'sameuser@pymedusa.com',
+        }
+    },
+    {  # p3 - show-specific, new style
+        'show': 'Show Name',
+        'EMAIL_LIST': [
+            'admin@pymedusa.com',
+            'sameuser@pymedusa.com',
+        ],
+        'mocks': [
+            ('medusa.db.DBConnection.select', [{
+                'notify_list': text_type({'emails': 'sameuser@pymedusa.com,user1@pymedusa.com'})
+            }])
+        ],
+        'expected': {
+            'admin@pymedusa.com',
+            'sameuser@pymedusa.com',
+            'user1@pymedusa.com'
+        }
+    }
+])
+def test__generate_recipients(p, app_config, monkeypatch_function_return):
+    # Given
+    show = p['show']
+    expected = p['expected']
+
+    app_config('EMAIL_LIST', p['EMAIL_LIST'])
+    if show:
+        monkeypatch_function_return(p['mocks'])
+
+    # When
+    actual = Notifier._generate_recipients(show)
+
+    # Then
+    assert actual == expected