diff --git a/couchpotato/core/media/_base/providers/nzb/newznab.py b/couchpotato/core/media/_base/providers/nzb/newznab.py index 6af5eae..5e3ab54 100644 --- a/couchpotato/core/media/_base/providers/nzb/newznab.py +++ b/couchpotato/core/media/_base/providers/nzb/newznab.py @@ -72,9 +72,17 @@ class Base(NZBProvider, RSS): detail_url = self.getTextElement(nzb, 'guid') nzb_id = detail_url.split('/')[-1:].pop() + try: + link = self.getElement(nzb, 'enclosure').attrib['url'] + except: + link = self.getTextElement(nzb, 'link') + if '://' not in detail_url: detail_url = (cleanHost(host['host']) + self.urls['detail']) % tryUrlencode(nzb_id) + if not link: + link = ((self.getUrl(host['host']) + self.urls['download']) % tryUrlencode(nzb_id)) + self.getApiExt(host) + if not name: continue @@ -106,7 +114,7 @@ class Base(NZBProvider, RSS): 'name_extra': name_extra, 'age': self.calculateAge(int(time.mktime(parse(date).timetuple()))), 'size': int(self.getElement(nzb, 'enclosure').attrib['length']) / 1024 / 1024, - 'url': ((self.getUrl(host['host']) + self.urls['download']) % tryUrlencode(nzb_id)) + self.getApiExt(host), + 'url': link, 'detail_url': detail_url, 'content': self.getTextElement(nzb, 'description'), 'description': description, @@ -225,7 +233,7 @@ config = [{ 'description': 'Enable NewzNab such as NZB.su, \ NZBs.org, DOGnzb.cr, \ Spotweb, NZBGeek, \ - NZBFinder', + NZBFinder, Usenet-Crawler', 'wizard': True, 'icon': 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAgMAAABinRfyAAAACVBMVEVjhwD///86aRovd/sBAAAAMklEQVQI12NgAIPQUCCRmQkjssDEShiRuRIqwZqZGcDAGBrqANUhGgIkWAOABKMDxCAA24UK50b26SAAAAAASUVORK5CYII=', 'options': [ @@ -236,30 +244,30 @@ config = [{ }, { 'name': 'use', - 'default': '0,0,0,0,0' + 'default': '0,0,0,0,0,0' }, { 'name': 'host', - 'default': 'api.nzb.su,api.dognzb.cr,nzbs.org,https://api.nzbgeek.info,https://www.nzbfinder.ws', + 'default': 'api.nzb.su,api.dognzb.cr,nzbs.org,https://api.nzbgeek.info,https://www.nzbfinder.ws,https://www.usenet-crawler.com', 'description': 'The hostname of your newznab provider', }, { 'name': 'extra_score', 'advanced': True, 'label': 'Extra Score', - 'default': '0,0,0,0,0', + 'default': '0,0,0,0,0,0', 'description': 'Starting score for each release found via this provider.', }, { 'name': 'custom_tag', 'advanced': True, 'label': 'Custom tag', - 'default': ',,,,', + 'default': ',,,,,', 'description': 'Add custom tags, for example add rls=1 to get only scene releases from nzbs.org', }, { 'name': 'api_key', - 'default': ',,,,', + 'default': ',,,,,', 'label': 'Api Key', 'description': 'Can be found on your profile page', 'type': 'combined', diff --git a/couchpotato/core/media/_base/providers/torrent/torrentbytes.py b/couchpotato/core/media/_base/providers/torrent/torrentbytes.py index 32221d8..bf3d590 100644 --- a/couchpotato/core/media/_base/providers/torrent/torrentbytes.py +++ b/couchpotato/core/media/_base/providers/torrent/torrentbytes.py @@ -55,7 +55,7 @@ class Base(TorrentProvider): link = cells[1].find('a', attrs = {'class': 'index'}) full_id = link['href'].replace('details.php?id=', '') - torrent_id = full_id[:6] + torrent_id = full_id[:7] name = toUnicode(link.get('title', link.contents[0]).encode('ISO-8859-1')).strip() results.append({ diff --git a/couchpotato/core/plugins/browser.py b/couchpotato/core/plugins/browser.py index 660070a..599a02d 100644 --- a/couchpotato/core/plugins/browser.py +++ b/couchpotato/core/plugins/browser.py @@ -10,7 +10,7 @@ from couchpotato.core.event import addEvent from couchpotato.core.helpers.encoding import sp, ss, toUnicode from couchpotato.core.helpers.variable import getUserDir from couchpotato.core.plugins.base import Plugin - +from couchpotato.core.softchroot import SoftChroot log = CPLog(__name__) @@ -33,6 +33,9 @@ autoload = 'FileBrowser' class FileBrowser(Plugin): def __init__(self): + soft_chroot_dir = self.conf('soft_chroot', section='core') + self.soft_chroot = SoftChroot(soft_chroot_dir) + addApiView('directory.list', self.view, docs = { 'desc': 'Return the directory list of a given directory', 'params': { @@ -78,11 +81,17 @@ class FileBrowser(Plugin): return driveletters def view(self, path = '/', show_hidden = True, **kwargs): - home = getUserDir() + if self.soft_chroot.enabled: + if not self.soft_chroot.is_subdir(home): + home = self.soft_chroot.chdir if not path: path = home + if path.endswith(os.path.sep): + path = path.rstrip(os.path.sep) + elif self.soft_chroot.enabled: + path = self.soft_chroot.add(path) try: dirs = self.getDirectories(path = path, show_hidden = show_hidden) @@ -90,17 +99,34 @@ class FileBrowser(Plugin): log.error('Failed getting directory "%s" : %s', (path, traceback.format_exc())) dirs = [] + if self.soft_chroot.enabled: + dirs = map(self.soft_chroot.cut, dirs) + parent = os.path.dirname(path.rstrip(os.path.sep)) if parent == path.rstrip(os.path.sep): parent = '/' elif parent != '/' and parent[-2:] != ':\\': parent += os.path.sep + # TODO : check on windows: + is_root = path == '/' + + if self.soft_chroot.enabled: + is_root = self.soft_chroot.is_root_abs(path) + + # fix paths: + if self.soft_chroot.is_subdir(parent): + parent = self.soft_chroot.cut(parent) + else: + parent = os.path.sep + + home = self.soft_chroot.cut(home) + return { - 'is_root': path == '/', + 'is_root': is_root, 'empty': len(dirs) == 0, 'parent': parent, - 'home': home + os.path.sep, + 'home': home, 'platform': os.name, 'dirs': dirs, } diff --git a/couchpotato/core/plugins/profile/main.py b/couchpotato/core/plugins/profile/main.py index 29bd6cb..eed5f9d 100644 --- a/couchpotato/core/plugins/profile/main.py +++ b/couchpotato/core/plugins/profile/main.py @@ -206,6 +206,9 @@ class ProfilePlugin(Plugin): 'label': '3D HD', 'qualities': ['1080p', '720p'], '3d': [True, True] + }, { + 'label': 'UHD 4K', + 'qualities': ['720p', '1080p', '2160p'] }] # Create default quality profile diff --git a/couchpotato/core/plugins/quality/main.py b/couchpotato/core/plugins/quality/main.py index 75b09bb..8bdd383 100644 --- a/couchpotato/core/plugins/quality/main.py +++ b/couchpotato/core/plugins/quality/main.py @@ -23,6 +23,7 @@ class QualityPlugin(Plugin): } qualities = [ + {'identifier': '2160p', 'hd': True, 'allow_3d': True, 'size': (10000, 650000), 'median_size': 20000, 'label': '2160p', 'width': 3840, 'height': 2160, 'alternative': [], 'allow': [], 'ext':['mkv'], 'tags': ['x264', 'h264', '2160']}, {'identifier': 'bd50', 'hd': True, 'allow_3d': True, 'size': (20000, 60000), 'median_size': 40000, 'label': 'BR-Disk', 'alternative': ['bd25', ('br', 'disk')], 'allow': ['1080p'], 'ext':['iso', 'img'], 'tags': ['bdmv', 'certificate', ('complete', 'bluray'), 'avc', 'mvc']}, {'identifier': '1080p', 'hd': True, 'allow_3d': True, 'size': (4000, 20000), 'median_size': 10000, 'label': '1080p', 'width': 1920, 'height': 1080, 'alternative': [], 'allow': [], 'ext':['mkv', 'm2ts', 'ts'], 'tags': ['m2ts', 'x264', 'h264', '1080']}, {'identifier': '720p', 'hd': True, 'allow_3d': True, 'size': (3000, 10000), 'median_size': 5500, 'label': '720p', 'width': 1280, 'height': 720, 'alternative': [], 'allow': [], 'ext':['mkv', 'ts'], 'tags': ['x264', 'h264', '720']}, @@ -65,6 +66,7 @@ class QualityPlugin(Plugin): }) addEvent('app.initialize', self.fill, priority = 10) + addEvent('app.load', self.fillBlank, priority = 120) addEvent('app.test', self.doTest) @@ -146,7 +148,18 @@ class QualityPlugin(Plugin): 'success': False } - def fill(self): + def fillBlank(self): + db = get_db() + + try: + existing = list(db.all('quality')) + if len(self.qualities) > len(existing): + log.error('Filling in new qualities') + self.fill(reorder = True) + except: + log.error('Failed filling quality database with new qualities: %s', traceback.format_exc()) + + def fill(self, reorder = False): try: db = get_db() @@ -156,7 +169,7 @@ class QualityPlugin(Plugin): existing = None try: - existing = db.get('quality', q.get('identifier')) + existing = db.get('quality', q.get('identifier'), with_doc = reorder) except RecordNotFound: pass @@ -179,6 +192,10 @@ class QualityPlugin(Plugin): 'finish': [True], 'wait_for': [0], }) + elif reorder: + log.info2('Updating quality order') + existing['doc']['order'] = order + db.update(existing['doc']) order += 1 @@ -493,6 +510,8 @@ class QualityPlugin(Plugin): 'Movie.Name.2014.720p.HDSCR.4PARTS.MP4.AAC.ReleaseGroup': {'size': 2401, 'quality': 'scr'}, 'Movie.Name.2014.720p.BluRay.x264-ReleaseGroup': {'size': 10300, 'quality': '720p'}, 'Movie.Name.2014.720.Bluray.x264.DTS-ReleaseGroup': {'size': 9700, 'quality': '720p'}, + 'Movie Name 2015 2160p SourceSite WEBRip DD5 1 x264-ReleaseGroup': {'size': 21800, 'quality': '2160p'}, + 'Movie Name 2012 2160p WEB-DL FLAC 5 1 x264-ReleaseGroup': {'size': 59650, 'quality': '2160p'} } correct = 0 diff --git a/couchpotato/core/plugins/renamer.py b/couchpotato/core/plugins/renamer.py index 945f285..d7844a8 100755 --- a/couchpotato/core/plugins/renamer.py +++ b/couchpotato/core/plugins/renamer.py @@ -807,7 +807,7 @@ Remove it if you want it to be renamed (again, or at least let it try again) ignore_files.extend(fnmatch.filter([sp(os.path.join(root, filename)) for filename in filenames], '*%s.ignore' % tag)) # Match all found ignore files with the tag_files and return True found - for tag_file in tag_files: + for tag_file in [tag_files] if isinstance(tag_files,str) else tag_files: ignore_file = fnmatch.filter(ignore_files, fnEscape('%s.%s.ignore' % (os.path.splitext(tag_file)[0], tag if tag else '*'))) if ignore_file: return True @@ -1228,6 +1228,9 @@ Remove it if you want it to be renamed (again, or at least let it try again) log.error('Rar modify date enabled, but failed: %s', traceback.format_exc()) extr_files.append(extr_file_path) del rar_handle + # Tag archive as extracted if no cleanup. + if not cleanup and os.path.isfile(extr_file_path): + self.tagRelease(release_download = {'folder': os.path.dirname(archive['file']), 'files': [archive['file']]}, tag = 'extracted') except Exception as e: log.error('Failed to extract %s: %s %s', (archive['file'], e, traceback.format_exc())) continue diff --git a/couchpotato/core/plugins/scanner.py b/couchpotato/core/plugins/scanner.py index ce5d32c..9be6ab3 100644 --- a/couchpotato/core/plugins/scanner.py +++ b/couchpotato/core/plugins/scanner.py @@ -74,6 +74,7 @@ class Scanner(Plugin): } resolutions = { + '2160p': {'resolution_width': 3840, 'resolution_height': 2160, 'aspect': 1.78}, '1080p': {'resolution_width': 1920, 'resolution_height': 1080, 'aspect': 1.78}, '1080i': {'resolution_width': 1920, 'resolution_height': 1080, 'aspect': 1.78}, '720p': {'resolution_width': 1280, 'resolution_height': 720, 'aspect': 1.78}, diff --git a/couchpotato/core/plugins/score/scores.py b/couchpotato/core/plugins/score/scores.py index f53f69a..4a4d189 100644 --- a/couchpotato/core/plugins/score/scores.py +++ b/couchpotato/core/plugins/score/scores.py @@ -19,7 +19,7 @@ name_scores = [ # Audio 'dts:4', 'ac3:2', # Quality - '720p:10', '1080p:10', 'bluray:10', 'dvd:1', 'dvdrip:1', 'brrip:1', 'bdrip:1', 'bd50:1', 'bd25:1', + '720p:10', '1080p:10', '2160p:10', 'bluray:10', 'dvd:1', 'dvdrip:1', 'brrip:1', 'bdrip:1', 'bd50:1', 'bd25:1', # Language / Subs 'german:-10', 'french:-10', 'spanish:-10', 'swesub:-20', 'danish:-10', 'dutch:-10', # Release groups diff --git a/couchpotato/core/plugins/test_browser.py b/couchpotato/core/plugins/test_browser.py new file mode 100644 index 0000000..1983d84 --- /dev/null +++ b/couchpotato/core/plugins/test_browser.py @@ -0,0 +1,49 @@ +import sys +import os +import logging +import unittest +from unittest import TestCase +#from mock import MagicMock + +from couchpotato.core.plugins.browser import FileBrowser +from couchpotato.core.softchroot import SoftChroot + +CHROOT_DIR = '/tmp/' + +class FileBrowserChrootedTest(TestCase): + def setUp(self): + self.b = FileBrowser() + + # TODO : remove scrutch: + self.b.soft_chroot = SoftChroot(CHROOT_DIR) + + # Logger + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + # To screen + hdlr = logging.StreamHandler(sys.stderr) + formatter = logging.Formatter('%(asctime)s %(levelname)s %(message)s', '%m-%d %H:%M:%S') + hdlr.setFormatter(formatter) + #logger.addHandler(hdlr) + + def test_soft_chroot_enabled(self): + self.assertTrue( self.b.soft_chroot.enabled) + + def test_view__chrooted_path_none(self): + #def view(self, path = '/', show_hidden = True, **kwargs): + r = self.b.view(None) + self.assertEqual(r['home'], '/') + self.assertEqual(r['parent'], '/') + self.assertTrue(r['is_root']) + + def test_view__chrooted_path_chroot(self): + #def view(self, path = '/', show_hidden = True, **kwargs): + for path, parent in [('/asdf','/'), (CHROOT_DIR, '/'), ('/mnk/123/t', '/mnk/123/')]: + r = self.b.view(path) + path_strip = path + if (path.endswith(os.path.sep)): + path_strip = path_strip.rstrip(os.path.sep) + + self.assertEqual(r['home'], '/') + self.assertEqual(r['parent'], parent) + self.assertFalse(r['is_root']) diff --git a/couchpotato/core/settings.py b/couchpotato/core/settings.py index ffc142a..963e5c0 100644 --- a/couchpotato/core/settings.py +++ b/couchpotato/core/settings.py @@ -7,7 +7,7 @@ from couchpotato.api import addApiView from couchpotato.core.event import addEvent, fireEvent from couchpotato.core.helpers.encoding import toUnicode from couchpotato.core.helpers.variable import mergeDicts, tryInt, tryFloat - +from couchpotato.core.softchroot import SoftChroot class Settings(object): @@ -77,7 +77,8 @@ class Settings(object): return self.p def sections(self): - return self.p.sections() + res = filter( self.isSectionReadable, self.p.sections()) + return res def connectEvents(self): addEvent('settings.options', self.addOptions) @@ -106,9 +107,21 @@ class Settings(object): self.save() def set(self, section, option, value): + if not self.isOptionWritable(section, option): + self.log.warning('set::option "%s.%s" isn\'t writable', (section, option)) + return None + if self.isOptionMeta(section, option): + self.log.warning('set::option "%s.%s" cancelled, since it is a META option', (section, option)) + return None + + return self.p.set(section, option, value) def get(self, option = '', section = 'core', default = None, type = None): + if self.isOptionMeta(section, option): + self.log.warning('set::option "%s.%s" cancelled, since it is a META option', (section, option)) + return None + try: try: type = self.types[section][option] @@ -123,6 +136,14 @@ class Settings(object): return default def delete(self, option = '', section = 'core'): + if not self.isOptionWritable(section, option): + self.log.warning('delete::option "%s.%s" isn\'t writable', (section, option)) + return None + + if self.isOptionMeta(section, option): + self.log.warning('set::option "%s.%s" cancelled, since it is a META option', (section, option)) + return None + self.p.remove_option(section, option) self.save() @@ -153,11 +174,30 @@ class Settings(object): def getValues(self): values = {} + + # TODO : There is two commented "continue" blocks (# COMMENTED_SKIPPING). They both are good... + # ... but, they omit output of values of hidden and non-readable options + # Currently, such behaviour could break the Web UI of CP... + # So, currently this two blocks are commented (but they are required to + # provide secure hidding of options. for section in self.sections(): + + # COMMENTED_SKIPPING + #if not self.isSectionReadable(section): + # continue + values[section] = {} for option in self.p.items(section): (option_name, option_value) = option + #skip meta options: + if self.isOptionMeta(section, option_name): + continue + + # COMMENTED_SKIPPING + #if not self.isOptionReadable(section, option_name): + # continue + is_password = False try: is_password = self.types[section][option_name] == 'password' except: pass @@ -189,14 +229,52 @@ class Settings(object): self.types[section][option] = type def addOptions(self, section_name, options): - + # no additional actions (related to ro-rw options) are required here if not self.options.get(section_name): self.options[section_name] = options else: self.options[section_name] = mergeDicts(self.options[section_name], options) def getOptions(self): - return self.options + """Returns dict of UI-readable options + + To check, whether the option is readable self.isOptionReadable() is used + """ + + res = {} + + if isinstance(self.options, dict): + for section_key in self.options.keys(): + section = self.options[section_key] + section_name = section.get('name') if 'name' in section else section_key + if self.isSectionReadable(section_name) and isinstance(section, dict): + s = {} + sg = [] + for section_field in section: + if section_field.lower() != 'groups': + s[section_field] = section[section_field] + else: + groups = section['groups'] + for group in groups: + g = {} + go = [] + for group_field in group: + if group_field.lower() != 'options': + g[group_field] = group[group_field] + else: + for option in group[group_field]: + option_name = option.get('name') + if self.isOptionReadable(section_name, option_name): + go.append(option) + option['writable'] = self.isOptionWritable(section_name, option_name) + if len(go)>0: + g['options'] = go + sg.append(g) + if len(sg)>0: + s['groups'] = sg + res[section_key] = s + + return res def view(self, **kwargs): return { @@ -210,6 +288,11 @@ class Settings(object): option = kwargs.get('name') value = kwargs.get('value') + if (section in self.types) and (option in self.types[section]) and (self.types[section][option] == 'directory'): + soft_chroot_dir = self.get('soft_chroot', default = None) + soft_chroot = SoftChroot(soft_chroot_dir) + value = soft_chroot.add(str(value)) + # See if a value handler is attached, use that as value new_value = fireEvent('setting.save.%s.%s' % (section, option), value, single = True) @@ -221,9 +304,56 @@ class Settings(object): fireEvent('setting.save.%s.*.after' % section, single = True) return { - 'success': True, + 'success': True } + def isSectionReadable(self, section): + meta = 'section_hidden' + self.optionMetaSuffix() + try: + return not self.p.getboolean(section, meta) + except: pass + + # by default - every section is readable: + return True + + def isOptionReadable(self, section, option): + meta = option + self.optionMetaSuffix() + if self.p.has_option(section, meta): + meta_v = self.p.get(section, meta).lower() + return (meta_v == 'rw') or (meta_v == 'ro') + + # by default - all is writable: + return True + + def optionReadableCheckAndWarn(self, section, option): + x = self.isOptionReadable(section, option) + if not x: + self.log.warning('Option "%s.%s" isn\'t readable', (section, option)) + return x + + def isOptionWritable(self, section, option): + meta = option + self.optionMetaSuffix() + if self.p.has_option(section, meta): + return self.p.get(section, meta).lower() == 'rw' + + # by default - all is writable: + return True + + def optionMetaSuffix(self): + return '_internal_meta' + + def isOptionMeta(self, section, option): + """ A helper method for detecting internal-meta options in the ini-file + + For a meta options used following names: + * section_hidden_internal_meta = (True | False) - for section visibility + *