Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix ascii decoding bug in rss #11712

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions medusa/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,9 +633,9 @@ def initialize(self, console_logging=True):
app.USE_NZBS = bool(check_setting_int(app.CFG, 'General', 'use_nzbs', 0))
app.USE_TORRENTS = bool(check_setting_int(app.CFG, 'General', 'use_torrents', 1))

app.NZB_METHOD = check_setting_str(app.CFG, 'General', 'nzb_method', 'blackhole', valid_values=('blackhole', 'sabnzbd', 'nzbget'))
app.NZB_METHOD = check_setting_str(app.CFG, 'General', 'nzb_method', 'blackhole', valid_values=('blackhole', 'rss', 'sabnzbd', 'nzbget'))
app.TORRENT_METHOD = check_setting_str(app.CFG, 'General', 'torrent_method', 'blackhole',
valid_values=('blackhole', 'utorrent', 'transmission', 'deluge',
valid_values=('rss', 'blackhole', 'utorrent', 'transmission', 'deluge',
'deluged', 'downloadstation', 'rtorrent', 'qbittorrent', 'mlnet'))
app.SAVE_MAGNET_FILE = bool(check_setting_int(app.CFG, 'General', 'save_magnet_file', 1))
app.DOWNLOAD_PROPERS = bool(check_setting_int(app.CFG, 'General', 'download_propers', 1))
Expand Down Expand Up @@ -673,7 +673,8 @@ def initialize(self, console_logging=True):

app.NZB_DIR = check_setting_str(app.CFG, 'Blackhole', 'nzb_dir', '')
app.TORRENT_DIR = check_setting_str(app.CFG, 'Blackhole', 'torrent_dir', '')

app.RSS_DIR = check_setting_str(app.CFG, 'RSS', 'rss_dir', '')
app.RSS_MAX_ITEMS = check_setting_int(app.CFG, 'RSS', 'rss_max_items', 100)
app.TV_DOWNLOAD_DIR = check_setting_str(app.CFG, 'General', 'tv_download_dir', '')
app.DEFAULT_CLIENT_PATH = check_setting_str(app.CFG, 'General', 'default_client_path', '')
app.PROCESS_AUTOMATICALLY = bool(check_setting_int(app.CFG, 'General', 'process_automatically', 0))
Expand Down Expand Up @@ -1861,6 +1862,10 @@ def save_config():
new_config['TORRENT']['torrent_auth_type'] = app.TORRENT_AUTH_TYPE
new_config['TORRENT']['torrent_seed_location'] = app.TORRENT_SEED_LOCATION

new_config['RSS'] = {}
new_config['RSS']['rss_dir'] = app.RSS_DIR
new_config['RSS']['rss_max_items'] = app.RSS_MAX_ITEMS

new_config['KODI'] = {}
new_config['KODI']['use_kodi'] = int(app.USE_KODI)
new_config['KODI']['kodi_always_on'] = int(app.KODI_ALWAYS_ON)
Expand Down
12 changes: 12 additions & 0 deletions medusa/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ def __init__(self):
self.TORRENT_SEED_ACTION = None
self.SAVE_MAGNET_FILE = False
self._TORRENT_DIR = None
self._RSS_DIR = None
self.RSS_MAX_ITEMS = 100
self._DOWNLOAD_PROPERS = False
self._CHECK_PROPERS_INTERVAL = None
self.PROPERS_SEARCH_DAYS = 2
Expand Down Expand Up @@ -987,6 +989,16 @@ def TORRENT_DIR(self, value):
"""Change TORRENT_DIR."""
self.handle_prop('TORRENT_DIR', value)

@property
def RSS_DIR(self):
"""Return app.RSS_DIR."""
return self._RSS_DIR

@RSS_DIR.setter
def RSS_DIR(self, value):
"""Change RSS_DIR."""
self.handle_prop('RSS_DIR', value)

@property
def TV_DOWNLOAD_DIR(self):
"""Return app.TV_DOWNLOAD_DIR."""
Expand Down
185 changes: 185 additions & 0 deletions medusa/clients/rss/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# coding=utf-8

"""Rss feed Generator."""

from __future__ import unicode_literals

import logging
import os
import time
import xml.etree.ElementTree as ElemTree
from datetime import datetime, timedelta

from medusa import app
from medusa.helper.exceptions import ex
from medusa.logger.adapters.style import BraceAdapter

log = BraceAdapter(logging.getLogger(__name__))
log.logger.addHandler(logging.NullHandler())


XMLNS = {'medusa': 'https://pymedusa.com'}


def add_result_to_feed(result):
"""
Adds search result and metadata to rss feed.

:return: bool representing success
"""
for k, v in XMLNS.items():
ElemTree.register_namespace(k, v)

file_path = os.path.join(app.RSS_DIR, u'medusa.xml')

if result.provider is None:
log.error(u'Invalid provider name - this is a coding error, report it please')
return False

root_element = _read_existing_xml(file_path)

if root_element is None:
return False

channel_element = _find_channel_element(root_element)
if channel_element is None:
return False

item_start_index = _find_item_start_index(channel_element)
channel_element.insert(item_start_index, _result_to_item(result))
while len(channel_element) > app.RSS_MAX_ITEMS:
channel_element.remove(channel_element[-1])

return _write_xml(root_element, file_path)


def __element(name, text, **attribs):
"""
Creates xml element with the given data.

Basically just ElemTree.Element() but assigns text
Example:
<name attribK1="attribV1" attribK2="attribV2">text</name>

:return: ElemTree.Element
"""
elem = ElemTree.Element(name, attribs)
elem.text = str(text) if text is not None else ''
return elem


def _pubdate():
"""
Generates pubdate according to rss standard format eg "Mon, 02 Mar 2004 05:06:07 GMT".

:return: string
"""
return (datetime.now() + timedelta(hours=time.timezone / 3600)).strftime('%a, %d %b %Y %H:%M:%S') + ' GMT'


def _result_to_item(result):
"""
Populates xml element 'item' with child elements using metadata from result.

:return: ElemTree.Element
"""
item_root = __element('item', None)
item_root.append(__element('title', result.name))
item_root.append(__element('link', result.url))
item_root.append(__element('guid', result.identifier, isPermalink='false'))
item_root.append(__element('pubDate', _pubdate()))
if len(result.episodes) == 1:
item_root.append(__element('description', f'{result.episodes[0].name} | {result.episodes[0].description}'))
else:
item_root.append(__element('description', result.name))
item_root.append(__element('enclosure', None, url=result.url, length='0', type='application/x-bittorrent' if result.result_type else 'application/x-nzb'))
item_root.append(__element('medusa:series',
result.series.name,
isAnime=str(result.series.anime),
tvdb=str(result.series.tvdb_id),
imdb=result.series.imdb_id))
item_root.append(__element('medusa:season', result.actual_season))
item_root.append(__element('medusa:episode', result.actual_episode))
item_root.append(__element('medusa:provider', result.provider.name))

return item_root


def _find_item_start_index(channel_element):
"""
Finds index of the last child that isn't a <item>.

:return: int
"""
for i, child in enumerate(channel_element):
if child.tag == 'item':
return i
return len(channel_element)


def _read_existing_xml(file_path):
"""
Reads xml from disk or creates a new xml from template.

:return: ElemTree.Element root
"""
if not os.path.isfile(file_path):
root = _make_empty_xml()
if not _write_xml(root, file_path):
return None
return root

try:
with open(file_path, 'r', encoding='utf-8') as f:
xml_string = f.read()
except OSError as e:
log.error(u'Error reading RSS file at {0}: {1}', file_path, ex(e))
try:
root = ElemTree.fromstring(xml_string)
return root
except ElemTree.ParseError as e:
log.error(u'Error parsing RSS file at {0}: {1}', file_path, ex(e))
return None


def _make_empty_xml():
"""
Builds empty xml template.

:return: ElemTree.Element root 'rss' element
"""
root = ElemTree.Element('rss')
for k, v in XMLNS.items():
root.attrib['xmlns:' + k] = v
root.attrib['version'] = '2.0'
root.append(__element('title', 'Medusa RSS Feed'))
root.append(__element('link', 'https://pymedusa.com/'))
root.append(__element('description', 'Medusa RSS Feed'))
root.append(__element('channel', None))
return root


def _write_xml(root_element, file_path):
"""
Writes rss xml file to disk.

:return: bool representing success
"""
try:
xml_string = ElemTree.tostring(root_element, encoding='unicode')
xml_string = xml_string.replace('\n', '')
with open(file_path, 'w', encoding='utf-8') as f:
f.write(xml_string)
return True
except OSError as e:
log.error(u'Error writing RSS file at {0}: {1}', file_path, ex(e))
return False


def _find_channel_element(root_element):
"""
Finds channel element in xml.

:return: ElemTree.Element channel
"""
return root_element.find('channel')
23 changes: 23 additions & 0 deletions medusa/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,29 @@ def change_TORRENT_DIR(torrent_dir):
return True


def change_RSS_DIR(rss_dir):
"""
Change rss directory

:param: New rss directory
:return: Bool representing success
"""
if not rss_dir:
app._RSS_DIR = ''
return True

app_rss_dir = os.path.normpath(app._RSS_DIR) if app._RSS_DIR else None

if app_rss_dir != os.path.normpath(rss_dir):
if helpers.make_dir(rss_dir):
app._RSS_DIR = os.path.normpath(rss_dir)
log.info(u'Changed rss dir to {0}', rss_dir)
else:
return False

return True


def change_TV_DOWNLOAD_DIR(tv_download_dir):
"""
Change TV_DOWNLOAD directory (used by postprocessor)
Expand Down
5 changes: 5 additions & 0 deletions medusa/search/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ui,
ws
)
from medusa.clients import rss
from medusa.clients import torrent
from medusa.clients.nzb import (
nzbget,
Expand Down Expand Up @@ -141,6 +142,8 @@ def snatch_result(result):
if result.result_type in (u'nzb', u'nzbdata'):
if app.NZB_METHOD == u'blackhole':
result_downloaded = _download_result(result)
elif app.NZB_METHOD == u'rss':
result_downloaded = rss.add_result_to_feed(result)
elif app.NZB_METHOD == u'sabnzbd':
result_downloaded = sab.send_nzb(result)
elif app.NZB_METHOD == u'nzbget':
Expand All @@ -155,6 +158,8 @@ def snatch_result(result):
# Handle SAVE_MAGNET_FILE
if app.TORRENT_METHOD == u'blackhole':
result_downloaded = _download_result(result)
elif app.TORRENT_METHOD == u'rss':
result_downloaded = rss.add_result_to_feed(result)
else:
if not result.content and not result.url.startswith(u'magnet:'):
if result.provider.login():
Expand Down
6 changes: 6 additions & 0 deletions medusa/server/api/v2/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ class ConfigHandler(BaseRequestHandler):
'clients.torrents.seedTime': IntegerField(app, 'TORRENT_SEED_TIME'),
'clients.torrents.username': StringField(app, 'TORRENT_USERNAME'),
'clients.torrents.verifySSL': BooleanField(app, 'TORRENT_VERIFY_CERT'),
'clients.rss.dir': StringField(app, 'RSS_DIR'),
'clients.rss.max_items': IntegerField(app, 'RSS_MAX_ITEMS'),
'clients.nzb.enabled': BooleanField(app, 'USE_NZBS'),
'clients.nzb.dir': StringField(app, 'NZB_DIR'),
'clients.nzb.method': StringField(app, 'NZB_METHOD'),
Expand Down Expand Up @@ -1191,6 +1193,10 @@ def data_clients():
section_data['torrents']['password'] = app.TORRENT_PASSWORD
section_data['torrents']['verifySSL'] = bool(app.TORRENT_VERIFY_CERT)

section_data['rss'] = {}
section_data['rss']['max_items'] = app.RSS_MAX_ITEMS
section_data['rss']['dir'] = app.RSS_DIR

section_data['nzb'] = {}
section_data['nzb']['enabled'] = bool(app.USE_NZBS)
section_data['nzb']['dir'] = app.NZB_DIR
Expand Down
4 changes: 4 additions & 0 deletions tests/apiv2/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,10 @@ def config_clients():
section_data['torrents']['verifySSL'] = bool(app.TORRENT_VERIFY_CERT)
section_data['torrents']['saveMagnetFile'] = bool(app.SAVE_MAGNET_FILE)

section_data['rss'] = {}
section_data['rss']['dir'] = app.RSS_DIR
section_data['rss']['max_items'] = app.RSS_MAX_ITEMS

section_data['nzb'] = {}
section_data['nzb']['enabled'] = bool(app.USE_NZBS)
section_data['nzb']['dir'] = app.NZB_DIR
Expand Down
66 changes: 66 additions & 0 deletions tests/clients/test_rss.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import os
import re
import medusa
import xml.etree.ElementTree as ElemTree
from medusa.clients import rss


EXPECTED_RAW_TEXT = """<rss xmlns:medusa="https://pymedusa.com" version="2.0"><title>Medusa RSS Feed</title><link>https://pymedusa.com/</link><description>
Medusa RSS Feed</description><channel><item><title>test_release_name</title><link>https://test_provider.com/test_result.url</link><guid isPermalink="false">
test_guid</guid><description>test_episode_name | test_episode_description</description><enclosure url="https://test_provider.com/test_result.url" length="0"
type="application/x-bittorrent" /><medusa:series isAnime="test_is_anime" tvdb="test_tvdbid" imdb="test_imdbid">test_series_name</medusa:series><medusa:season>
test_actual_season</medusa:season><medusa:episode>test_actual_episode</medusa:episode><medusa:provider>test_provider</medusa:provider></item></channel></rss>"""


class AnonymousClass:
def __init__(self, **entries):
self.__dict__.update(entries)


def _result():
r = medusa.classes.SearchResult('test_provider', 'test_series')
r.name = 'test_release_name'
r.url = 'https://test_provider.com/test_result.url'
r.episodes = [AnonymousClass(name='test_episode_name', description='test_episode_description')]
r.season = 'test_season'
r.episode = 'test_episode_name'
r.series = AnonymousClass(name='test_series_name', anime='test_is_anime', tvdb_id='test_tvdbid', imdb_id='test_imdbid')
r.actual_season = 'test_actual_season'
r.actual_episode = 'test_actual_episode'
r.provider = AnonymousClass(name='test_provider', identifier='test_guid', _get_identifier=lambda _: 'test_guid')
r.result_type = 'torrent'
return r


def test_add_result_to_feed():
"""
This isn't a fantastic test, but it confirms that the rss xml is properly formed.
"""
medusa.app.RSS_DIR = './'
file_path = os.path.join(medusa.app.RSS_DIR, u'medusa.xml')

rss.add_result_to_feed(_result())

with open(file_path, 'r') as f:
rss_raw_text = f.read()
os.unlink(file_path)
rss_raw_text = re.sub(r'<pubDate>(.*)<\/pubDate>', '', rss_raw_text)

result_et = ElemTree.fromstring(rss_raw_text)
expected_et = ElemTree.fromstring(EXPECTED_RAW_TEXT.replace('\n', ''))

for (e1, e2) in zip(result_et, expected_et):
elements_equal(e1, e2)


def elements_equal(elem1, elem2):
"""
Recursively asserts equality between elementtree elements and children.
"""
assert elem1.tag == elem2.tag
assert elem1.text == elem2.text
assert elem1.tail == elem2.tail
assert elem1.attrib == elem2.attrib
assert len(elem1) == len(elem2)
for (e1, e2) in zip(elem1, elem2):
elements_equal(e1, e2)
Loading