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
+ *