From 7692322fbad526eeb6c37912301399bc22ccd8f1 Mon Sep 17 00:00:00 2001 From: dkboy Date: Sat, 13 Jul 2013 16:45:39 +1200 Subject: [PATCH 01/58] Expand IMDB automation provider to include charts Expand IMDB automation provider to include certain top charts, this includes the 'in theaters' list, as well as the top 250 list. They both respect the minimum requirement settings. --- .../providers/automation/imdb/__init__.py | 28 ++++++- .../core/providers/automation/imdb/main.py | 83 +++++++++++++++++-- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/couchpotato/core/providers/automation/imdb/__init__.py b/couchpotato/core/providers/automation/imdb/__init__.py index a0013c4a..ee804af1 100644 --- a/couchpotato/core/providers/automation/imdb/__init__.py +++ b/couchpotato/core/providers/automation/imdb/__init__.py @@ -9,7 +9,7 @@ config = [{ { 'tab': 'automation', 'list': 'watchlist_providers', - 'name': 'imdb_automation', + 'name': 'imdb_automation_watchlist', 'label': 'IMDB', 'description': 'From any public IMDB watchlists. Url should be the CSV link.', 'options': [ @@ -30,5 +30,31 @@ config = [{ }, ], }, + { + 'tab': 'automation', + 'list': 'automation_providers', + 'name': 'imdb_automation_charts', + 'label': 'IMDB', + 'description': 'Import movies from IMDB Charts', + 'options': [ + { + 'name': 'automation_enabled', + 'default': False, + 'type': 'enabler', + }, + { + 'name': 'automation_charts_theaters_use', + 'type': 'checkbox', + 'label': 'In Theaters', + 'description': 'New Movies In-Theaters chart', + }, + { + 'name': 'automation_charts_top250_use', + 'type': 'checkbox', + 'label': 'TOP 250', + 'description': 'IMDB TOP 250 chart', + }, + ], + }, ], }] diff --git a/couchpotato/core/providers/automation/imdb/main.py b/couchpotato/core/providers/automation/imdb/main.py index 75a2d75c..0d494949 100644 --- a/couchpotato/core/providers/automation/imdb/main.py +++ b/couchpotato/core/providers/automation/imdb/main.py @@ -1,7 +1,9 @@ +from bs4 import BeautifulSoup from couchpotato.core.helpers.rss import RSS from couchpotato.core.helpers.variable import getImdb, splitString, tryInt from couchpotato.core.logger import CPLog from couchpotato.core.providers.automation.base import Automation +import re import traceback log = CPLog(__name__) @@ -11,22 +13,91 @@ class IMDB(Automation, RSS): interval = 1800 + chart_urls = { + 'theater': 'http://www.imdb.com/movies-in-theaters/', + 'top250': 'http://www.imdb.com/chart/top', + } + + def getIMDBids(self): movies = [] - enablers = [tryInt(x) for x in splitString(self.conf('automation_urls_use'))] - urls = splitString(self.conf('automation_urls')) + # Handle Chart URLs + if self.conf('automation_charts_theaters_use'): + log.debug('Started IMDB chart: %s', self.chart_urls['theater']) + data = self.getHTMLData(self.chart_urls['theater']) + if data: + html = BeautifulSoup(data) + + try: + result_div = html.find('div', attrs = {'id': 'main'}) + + entries = result_div.find_all('div', attrs = {'itemtype': 'http://schema.org/Movie'}) + + for entry in entries: + title = entry.find('h4', attrs = {'itemprop': 'name'}).getText() + + log.debug('Identified title: %s', title) + result = re.search('(.*) \((.*)\)', title) + + if result: + name = result.group(1) + year = result.group(2) + + imdb = self.search(name, year) + + if imdb and self.isMinimalMovie(imdb): + movies.append(imdb['imdb']) + + except: + log.error('Failed loading IMDB chart results from %s: %s', (self.chart_urls['theater'], traceback.format_exc())) + + if self.conf('automation_charts_top250_use'): + log.debug('Started IMDB chart: %s', self.chart_urls['top250']) + data = self.getHTMLData(self.chart_urls['top250']) + if data: + html = BeautifulSoup(data) + + try: + result_div = html.find('div', attrs = {'id': 'main'}) + + result_table = result_div.find_all('table')[1] + entries = result_table.find_all('tr') + + for entry in entries[1:]: + title = entry.find_all('td')[2].getText() + + log.debug('Identified title: %s', title) + result = re.search('(.*) \((.*)\)', title) + + if result: + name = result.group(1) + year = result.group(2) + + imdb = self.search(name, year) + + if imdb and self.isMinimalMovie(imdb): + movies.append(imdb['imdb']) + + except: + log.error('Failed loading IMDB chart results from %s: %s', (self.chart_urls['theater'], traceback.format_exc())) + + + # Handle Watchlists + watchlist_enablers = [tryInt(x) for x in splitString(self.conf('automation_urls_use'))] + watchlist_urls = splitString(self.conf('automation_urls')) index = -1 - for url in urls: + for watchlist_url in watchlist_urls: index += 1 - if not enablers[index]: + if not watchlist_enablers[index]: continue try: - rss_data = self.getHTMLData(url) + log.debug('Started IMDB watchlists: %s', watchlist_url) + rss_data = self.getHTMLData(watchlist_url) imdbs = getImdb(rss_data, multiple = True) if rss_data else [] for imdb in imdbs: @@ -35,4 +106,6 @@ class IMDB(Automation, RSS): except: log.error('Failed loading IMDB watchlist: %s %s', (url, traceback.format_exc())) + + # Return the combined resultset return movies From 4ebbc1a01d8bda9f5b4435afbc936ec9ff6c992d Mon Sep 17 00:00:00 2001 From: mano3m <-> Date: Sun, 14 Jul 2013 02:19:35 +0200 Subject: [PATCH 02/58] XBMC: Only scan the new movie folder --- couchpotato/core/notifications/xbmc/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py index ad6fa605..b1ad57a1 100755 --- a/couchpotato/core/notifications/xbmc/main.py +++ b/couchpotato/core/notifications/xbmc/main.py @@ -34,7 +34,7 @@ class XBMC(Notification): ] if not self.conf('only_first') or hosts.index(host) == 0: - calls.append(('VideoLibrary.Scan', {})) + calls.append(('VideoLibrary.Scan', {'directory': data.get('destination_dir', None)})) max_successful += len(calls) response = self.request(host, calls) From 564a27461d6ab020c4bf83ea5101958d74febda2 Mon Sep 17 00:00:00 2001 From: mano3m <-> Date: Sun, 14 Jul 2013 23:30:37 +0200 Subject: [PATCH 03/58] XBMC: Only add directory if XBMC is on localhost --- couchpotato/core/notifications/xbmc/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py index b1ad57a1..7266b25c 100755 --- a/couchpotato/core/notifications/xbmc/main.py +++ b/couchpotato/core/notifications/xbmc/main.py @@ -34,7 +34,7 @@ class XBMC(Notification): ] if not self.conf('only_first') or hosts.index(host) == 0: - calls.append(('VideoLibrary.Scan', {'directory': data.get('destination_dir', None)})) + calls.append(('VideoLibrary.Scan', {'directory': data.get('destination_dir', None)} if 'localhost' in host else {})) max_successful += len(calls) response = self.request(host, calls) From 470fde08902e6c91e2ef2a05286205c2289e53a6 Mon Sep 17 00:00:00 2001 From: mano3m <-> Date: Sat, 20 Jul 2013 13:49:12 +0200 Subject: [PATCH 04/58] Unset the uTorrent read only flags Fix for #1871 Note that this is a fix for Windows only. I am unaware if this issue arises on Linux/Mac and what happens with this fix on those systems. --- couchpotato/core/downloaders/utorrent/main.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/couchpotato/core/downloaders/utorrent/main.py b/couchpotato/core/downloaders/utorrent/main.py index be6ff107..04595546 100644 --- a/couchpotato/core/downloaders/utorrent/main.py +++ b/couchpotato/core/downloaders/utorrent/main.py @@ -10,7 +10,9 @@ from multipartpost import MultipartPostHandler import cookielib import httplib import json +import os import re +import stat import time import urllib import urllib2 @@ -52,7 +54,7 @@ class uTorrent(Downloader): new_settings['seed_prio_limitul_flag'] = True log.info('Updated uTorrent settings to set a torrent to complete after it the seeding requirements are met.') - if settings.get('bt.read_only_on_complete'): #This doesnt work as this option seems to be not available through the api + if settings.get('bt.read_only_on_complete'): #This doesn't work as this option seems to be not available through the api. Mitigated with removeReadOnly function new_settings['bt.read_only_on_complete'] = False log.info('Updated uTorrent settings to not set the files to read only after completing.') @@ -130,8 +132,10 @@ class uTorrent(Downloader): status = 'busy' if 'Finished' in item[21]: status = 'completed' + self.removeReadOnly(item[26]) elif 'Seeding' in item[21]: status = 'seeding' + self.removeReadOnly(item[26]) statuses.append({ 'id': item[0], @@ -161,6 +165,13 @@ class uTorrent(Downloader): if not self.connect(): return False return self.utorrent_api.remove_torrent(item['id'], remove_data = delete_files) + + def removeReadOnly(self, folder): + #Removes all read-only flags in a folder + if folder and os.path.isdir(folder): + for root, folders, filenames in os.walk(folder): + for filename in filenames: + os.chmod(os.path.join(root, filename), stat.S_IWRITE) class uTorrentAPI(object): From fd95364d5ffd835c7b4cb4e62da8e2e9d38127ed Mon Sep 17 00:00:00 2001 From: mano3m <-> Date: Sat, 20 Jul 2013 13:53:19 +0200 Subject: [PATCH 05/58] uTorrent ratio issue fixed The tryFloat function returns 0 if it is fed with a float(!). This resulted in the seed_ratio being set to 0 on first/automatic download. When manually downloading, it did work as the ratio is stored as a string. --- couchpotato/core/downloaders/utorrent/main.py | 2 +- couchpotato/core/helpers/variable.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/couchpotato/core/downloaders/utorrent/main.py b/couchpotato/core/downloaders/utorrent/main.py index 04595546..79f9f5b9 100644 --- a/couchpotato/core/downloaders/utorrent/main.py +++ b/couchpotato/core/downloaders/utorrent/main.py @@ -95,7 +95,7 @@ class uTorrent(Downloader): else: self.utorrent_api.add_torrent_file(torrent_filename, filedata) - # Change settings of added torrents + # Change settings of added torrent self.utorrent_api.set_torrent(torrent_hash, torrent_params) if self.conf('paused', default = 0): self.utorrent_api.pause_torrent(torrent_hash) diff --git a/couchpotato/core/helpers/variable.py b/couchpotato/core/helpers/variable.py index fa8a8b51..48daa289 100644 --- a/couchpotato/core/helpers/variable.py +++ b/couchpotato/core/helpers/variable.py @@ -140,7 +140,11 @@ def tryInt(s): except: return 0 def tryFloat(s): - try: return float(s) if '.' in s else tryInt(s) + try: + if isinstance(s, str): + return float(s) if '.' in s else tryInt(s) + else: + return float(s) except: return 0 def natsortKey(s): From 56a788286c83f49581b455e4d7832f9b7b1f7458 Mon Sep 17 00:00:00 2001 From: Micah James Date: Wed, 31 Jul 2013 22:41:49 -0400 Subject: [PATCH 06/58] Adding code for custom urls UI --- .../providers/automation/rottentomatoes/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/couchpotato/core/providers/automation/rottentomatoes/__init__.py b/couchpotato/core/providers/automation/rottentomatoes/__init__.py index 83a545b6..579fe1f2 100644 --- a/couchpotato/core/providers/automation/rottentomatoes/__init__.py +++ b/couchpotato/core/providers/automation/rottentomatoes/__init__.py @@ -18,6 +18,16 @@ config = [{ 'default': False, 'type': 'enabler', }, + { + 'name': 'automation_urls_use', + 'label': 'Use', + }, + { + 'name': 'automation_urls', + 'label': 'url', + 'type': 'combined', + 'combine': ['automation_urls_use', 'automation_urls'], + }, { 'name': 'tomatometer_percent', 'default': '80', From 3a8f891c7d168d570b1c02ac51ebe00ebef34604 Mon Sep 17 00:00:00 2001 From: Micah James Date: Wed, 31 Jul 2013 22:45:48 -0400 Subject: [PATCH 07/58] Adding more code. --- .../core/providers/automation/rottentomatoes/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/providers/automation/rottentomatoes/__init__.py b/couchpotato/core/providers/automation/rottentomatoes/__init__.py index 579fe1f2..19406144 100644 --- a/couchpotato/core/providers/automation/rottentomatoes/__init__.py +++ b/couchpotato/core/providers/automation/rottentomatoes/__init__.py @@ -8,7 +8,7 @@ config = [{ 'groups': [ { 'tab': 'automation', - 'list': 'automation_providers', + 'list': 'watchlist_providers', 'name': 'rottentomatoes_automation', 'label': 'Rottentomatoes', 'description': 'Imports movies from the rottentomatoes "in theaters"-feed.', From 797018fb8aabbc8caa39a9f91cdeb78a82c77168 Mon Sep 17 00:00:00 2001 From: Micah James Date: Wed, 31 Jul 2013 22:47:52 -0400 Subject: [PATCH 08/58] Revert "Adding more code." This reverts commit 3a8f891c7d168d570b1c02ac51ebe00ebef34604. --- .../core/providers/automation/rottentomatoes/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/providers/automation/rottentomatoes/__init__.py b/couchpotato/core/providers/automation/rottentomatoes/__init__.py index 19406144..579fe1f2 100644 --- a/couchpotato/core/providers/automation/rottentomatoes/__init__.py +++ b/couchpotato/core/providers/automation/rottentomatoes/__init__.py @@ -8,7 +8,7 @@ config = [{ 'groups': [ { 'tab': 'automation', - 'list': 'watchlist_providers', + 'list': 'automation_providers', 'name': 'rottentomatoes_automation', 'label': 'Rottentomatoes', 'description': 'Imports movies from the rottentomatoes "in theaters"-feed.', From da50b19b6b08d456deef5b977eb2de40c05eafbb Mon Sep 17 00:00:00 2001 From: Micah James Date: Wed, 31 Jul 2013 23:06:12 -0400 Subject: [PATCH 09/58] Added custom url code handling --- .../automation/rottentomatoes/main.py | 54 +++++++++++-------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/couchpotato/core/providers/automation/rottentomatoes/main.py b/couchpotato/core/providers/automation/rottentomatoes/main.py index 9842d4c9..47b9395d 100644 --- a/couchpotato/core/providers/automation/rottentomatoes/main.py +++ b/couchpotato/core/providers/automation/rottentomatoes/main.py @@ -1,5 +1,5 @@ from couchpotato.core.helpers.rss import RSS -from couchpotato.core.helpers.variable import tryInt +from couchpotato.core.helpers.variable import tryInt, splitString from couchpotato.core.logger import CPLog from couchpotato.core.providers.automation.base import Automation from xml.etree.ElementTree import QName @@ -11,38 +11,48 @@ log = CPLog(__name__) class Rottentomatoes(Automation, RSS): interval = 1800 - urls = { - 'namespace': 'http://www.rottentomatoes.com/xmlns/rtmovie/', - 'theater': 'http://www.rottentomatoes.com/syndication/rss/in_theaters.xml', - } + + def getIMDBids(self): movies = [] - rss_movies = self.getRSSData(self.urls['theater']) - rating_tag = str(QName(self.urls['namespace'], 'tomatometer_percent')) + rotten_tomatoes_namespace = 'http://www.rottentomatoes.com/xmlns/rtmovie/' + enablers = [tryInt(x) for x in splitString(self.conf('automation_urls_use'))] + urls = splitString(self.conf('automation_urls')) - for movie in rss_movies: + index = -1 - value = self.getTextElement(movie, "title") - result = re.search('(?<=%\s).*', value) + for url in urls: - if result: + index += 1 + if not enablers[index]: + continue - log.info2('Something smells...') - rating = tryInt(self.getTextElement(movie, rating_tag)) - name = result.group(0) + rss_movies = self.getRSSData(url) + rating_tag = str(QName(rotten_tomatoes_namespace, 'tomatometer_percent')) - if rating < tryInt(self.conf('tomatometer_percent')): - log.info2('%s seems to be rotten...', name) - else: + for movie in rss_movies: - log.info2('Found %s fresh enough movies, enqueuing: %s', (rating, name)) - year = datetime.datetime.now().strftime("%Y") - imdb = self.search(name, year) + value = self.getTextElement(movie, "title") + result = re.search('(?<=%\s).*', value) - if imdb and self.isMinimalMovie(imdb): - movies.append(imdb['imdb']) + if result: + + log.info2('Something smells...') + rating = tryInt(self.getTextElement(movie, rating_tag)) + name = result.group(0) + + if rating < tryInt(self.conf('tomatometer_percent')): + log.info2('%s seems to be rotten...', name) + else: + + log.info2('Found %s fresh enough movies, enqueuing: %s', (rating, name)) + year = datetime.datetime.now().strftime("%Y") + imdb = self.search(name, year) + + if imdb and self.isMinimalMovie(imdb): + movies.append(imdb['imdb']) return movies From 4330dc39bf37d6e1332d2a2471454c6667b86aa7 Mon Sep 17 00:00:00 2001 From: Micah James Date: Wed, 31 Jul 2013 23:14:58 -0400 Subject: [PATCH 10/58] Changed description to be better suited for this. --- .../core/providers/automation/rottentomatoes/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/providers/automation/rottentomatoes/__init__.py b/couchpotato/core/providers/automation/rottentomatoes/__init__.py index 579fe1f2..52b1c882 100644 --- a/couchpotato/core/providers/automation/rottentomatoes/__init__.py +++ b/couchpotato/core/providers/automation/rottentomatoes/__init__.py @@ -11,7 +11,7 @@ config = [{ 'list': 'automation_providers', 'name': 'rottentomatoes_automation', 'label': 'Rottentomatoes', - 'description': 'Imports movies from the rottentomatoes "in theaters"-feed.', + 'description': 'Imports movies from rottentomatoes rss feeds specified below.', 'options': [ { 'name': 'automation_enabled', From 4ffda9f705c32e903ea8cda2323b4272a290a653 Mon Sep 17 00:00:00 2001 From: Micah James Date: Thu, 1 Aug 2013 23:15:36 -0400 Subject: [PATCH 11/58] Made code more python-y per mano3ms recommendation. --- .../core/providers/automation/rottentomatoes/main.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/couchpotato/core/providers/automation/rottentomatoes/main.py b/couchpotato/core/providers/automation/rottentomatoes/main.py index 47b9395d..40f72a5c 100644 --- a/couchpotato/core/providers/automation/rottentomatoes/main.py +++ b/couchpotato/core/providers/automation/rottentomatoes/main.py @@ -19,15 +19,11 @@ class Rottentomatoes(Automation, RSS): movies = [] rotten_tomatoes_namespace = 'http://www.rottentomatoes.com/xmlns/rtmovie/' - enablers = [tryInt(x) for x in splitString(self.conf('automation_urls_use'))] - urls = splitString(self.conf('automation_urls')) - - index = -1 + urls = dict(zip(splitString(self.conf('automation_urls')), [tryInt(x) for x in splitString(self.conf('automation_urls_use'))])) for url in urls: - index += 1 - if not enablers[index]: + if not urls[url]: continue rss_movies = self.getRSSData(url) From 0492e90d6fdc76b97f505dcb851740d75187901d Mon Sep 17 00:00:00 2001 From: mano3m <-> Date: Tue, 16 Jul 2013 22:35:32 +0200 Subject: [PATCH 12/58] XBMC: properly check if host is local And added option to scan if remote --- couchpotato/core/notifications/xbmc/__init__.py | 8 ++++++++ couchpotato/core/notifications/xbmc/main.py | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/couchpotato/core/notifications/xbmc/__init__.py b/couchpotato/core/notifications/xbmc/__init__.py index e3c467ce..f0167ce8 100644 --- a/couchpotato/core/notifications/xbmc/__init__.py +++ b/couchpotato/core/notifications/xbmc/__init__.py @@ -38,6 +38,14 @@ config = [{ 'advanced': True, 'description': 'Only update the first host when movie snatched, useful for synced XBMC', }, + { + 'name': 'remote_dir_scan', + 'label': 'Remote Folder Scan', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Scan new movie folder at remote XBMC servers, only works if movie location is the same.', + }, { 'name': 'on_snatch', 'default': 0, diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py index 7266b25c..34a9c1da 100755 --- a/couchpotato/core/notifications/xbmc/main.py +++ b/couchpotato/core/notifications/xbmc/main.py @@ -13,7 +13,7 @@ log = CPLog(__name__) class XBMC(Notification): - listen_to = ['renamer.after'] + listen_to = ['renamer.after', 'movie.snatched'] use_json_notifications = {} http_time_between_calls = 0 @@ -33,15 +33,19 @@ class XBMC(Notification): ('GUI.ShowNotification', {'title': self.default_title, 'message': message, 'image': self.getNotificationImage('small')}), ] - if not self.conf('only_first') or hosts.index(host) == 0: - calls.append(('VideoLibrary.Scan', {'directory': data.get('destination_dir', None)} if 'localhost' in host else {})) + if data and data.get('destination_dir') and (not self.conf('only_first') or hosts.index(host) == 0): + param = {} + if self.conf('remote_dir_scan') or socket.getfqdn('localhost') == socket.getfqdn(host.split(':')[0]): + param = {'directory': data['destination_dir']} + + calls.append(('VideoLibrary.Scan', param)) max_successful += len(calls) response = self.request(host, calls) else: response = self.notifyXBMCnoJSON(host, {'title':self.default_title, 'message':message}) - if not self.conf('only_first') or hosts.index(host) == 0: + if data and data.get('destination_dir') and (not self.conf('only_first') or hosts.index(host) == 0): response += self.request(host, [('VideoLibrary.Scan', {})]) max_successful += 1 From 3bd18753211956e9e05b345deefdb7db330638ae Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Sat, 27 Jul 2013 21:01:23 +1200 Subject: [PATCH 13/58] Added initial rtorrent downloader, currently testing, possibly has some bugs. --- .../core/downloaders/rtorrent/__init__.py | 54 ++ couchpotato/core/downloaders/rtorrent/main.py | 97 +++ libs/rtorrent/__init__.py | 567 ++++++++++++++++++ libs/rtorrent/common.py | 86 +++ libs/rtorrent/compat.py | 30 + libs/rtorrent/err.py | 40 ++ libs/rtorrent/file.py | 91 +++ libs/rtorrent/lib/__init__.py | 0 libs/rtorrent/lib/bencode.py | 281 +++++++++ libs/rtorrent/lib/torrentparser.py | 159 +++++ libs/rtorrent/lib/xmlrpc/__init__.py | 0 libs/rtorrent/lib/xmlrpc/http.py | 23 + libs/rtorrent/peer.py | 98 +++ libs/rtorrent/rpc/__init__.py | 354 +++++++++++ libs/rtorrent/torrent.py | 484 +++++++++++++++ libs/rtorrent/tracker.py | 138 +++++ 16 files changed, 2502 insertions(+) create mode 100755 couchpotato/core/downloaders/rtorrent/__init__.py create mode 100755 couchpotato/core/downloaders/rtorrent/main.py create mode 100755 libs/rtorrent/__init__.py create mode 100755 libs/rtorrent/common.py create mode 100755 libs/rtorrent/compat.py create mode 100755 libs/rtorrent/err.py create mode 100755 libs/rtorrent/file.py create mode 100755 libs/rtorrent/lib/__init__.py create mode 100755 libs/rtorrent/lib/bencode.py create mode 100755 libs/rtorrent/lib/torrentparser.py create mode 100755 libs/rtorrent/lib/xmlrpc/__init__.py create mode 100755 libs/rtorrent/lib/xmlrpc/http.py create mode 100755 libs/rtorrent/peer.py create mode 100755 libs/rtorrent/rpc/__init__.py create mode 100755 libs/rtorrent/torrent.py create mode 100755 libs/rtorrent/tracker.py diff --git a/couchpotato/core/downloaders/rtorrent/__init__.py b/couchpotato/core/downloaders/rtorrent/__init__.py new file mode 100755 index 00000000..d0047893 --- /dev/null +++ b/couchpotato/core/downloaders/rtorrent/__init__.py @@ -0,0 +1,54 @@ +from .main import rTorrent + +def start(): + return rTorrent() + +config = [{ + 'name': 'rtorrent', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'rtorrent', + 'label': 'rTorrent', + 'description': '', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'torrent', + }, + { + 'name': 'url', + 'default': 'http://localhost:80/RPC2', + }, + { + 'name': 'username', + }, + { + 'name': 'password', + 'type': 'password', + }, + { + 'name': 'label', + 'description': 'Label to add torrent as.', + }, + { + 'name': 'paused', + 'type': 'bool', + 'default': False, + 'description': 'Add the torrent paused.', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py new file mode 100755 index 00000000..5da64cb7 --- /dev/null +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -0,0 +1,97 @@ +from base64 import b16encode, b32decode +from bencode import bencode, bdecode +from couchpotato.core.downloaders.base import Downloader, StatusList +from couchpotato.core.helpers.encoding import isInt, ss +from couchpotato.core.logger import CPLog +from datetime import timedelta +from hashlib import sha1 +from multipartpost import MultipartPostHandler +import cookielib +import httplib +import json +import re +import time +import urllib +import urllib2 +from rtorrent import RTorrent + + +log = CPLog(__name__) + + +class rTorrent(Downloader): + + type = ['torrent', 'torrent_magnet'] + rtorrent_api = None + + def get_conn(self): + return RTorrent( + self.conf('url'), + self.conf('username'), + self.conf('password') + ) + + def download(self, data, movie, filedata=None): + log.debug('Sending "%s" (%s) to rTorrent.', (data.get('name'), data.get('type'))) + + torrent_params = {} + if self.conf('label'): + torrent_params['label'] = self.conf('label') + + if not filedata and data.get('type') == 'torrent': + log.error('Failed sending torrent, no data') + return False + + if data.get('type') == 'torrent_magnet': + log.info('magnet torrents are not supported') + return False + + info = bdecode(filedata)["info"] + torrent_hash = sha1(bencode(info)).hexdigest().upper() + torrent_filename = self.createFileName(data, filedata, movie) + + # Convert base 32 to hex + if len(torrent_hash) == 32: + torrent_hash = b16encode(b32decode(torrent_hash)) + + # Send request to rTorrent + try: + if not self.rtorrent_api: + self.rtorrent_api = self.get_conn() + + torrent = self.rtorrent_api.load_torrent(filedata, not self.conf('paused', default=0)) + + return self.downloadReturnId(torrent_hash) + except Exception, err: + log.error('Failed to send torrent to rTorrent: %s', err) + return False + + + def getAllDownloadStatus(self): + + log.debug('Checking rTorrent download status.') + + try: + if not self.rtorrent_api: + self.rtorrent_api = self.get_conn() + + torrents = self.rtorrent_api.get_torrents() + + statuses = StatusList(self) + + for item in torrents: + statuses.append({ + 'id': item.info_hash, + 'name': item.name, + 'status': 'completed' if item.complete else 'busy', + 'original_status': item.state, + 'timeleft': str(timedelta(seconds=float(item.left_bytes) / item.down_rate)) + if item.down_rate > 0 else -1, + 'folder': '' + }) + + return statuses + + except Exception, err: + log.error('Failed to send torrent to rTorrent: %s', err) + return False diff --git a/libs/rtorrent/__init__.py b/libs/rtorrent/__init__.py new file mode 100755 index 00000000..e427b65e --- /dev/null +++ b/libs/rtorrent/__init__.py @@ -0,0 +1,567 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from rtorrent.common import find_torrent, \ + is_valid_port, convert_version_tuple_to_str +from rtorrent.lib.torrentparser import TorrentParser +from rtorrent.lib.xmlrpc.http import HTTPServerProxy +from rtorrent.rpc import Method, BasicAuthTransport +from rtorrent.torrent import Torrent +import os.path +import rtorrent.rpc # @UnresolvedImport +import time +import xmlrpclib + +__version__ = "0.2.9" +__author__ = "Chris Lucas" +__contact__ = "chris@chrisjlucas.com" +__license__ = "MIT" + +MIN_RTORRENT_VERSION = (0, 8, 1) +MIN_RTORRENT_VERSION_STR = convert_version_tuple_to_str(MIN_RTORRENT_VERSION) + + +class RTorrent: + """ Create a new rTorrent connection """ + rpc_prefix = None + + def __init__(self, url, username=None, password=None, + verify=False, sp=HTTPServerProxy, sp_kwargs={}): + self.url = url # : From X{__init__(self, url)} + self.username = username + self.password = password + self.sp = sp + self.sp_kwargs = sp_kwargs + + self.torrents = [] # : List of L{Torrent} instances + self._rpc_methods = [] # : List of rTorrent RPC methods + self._torrent_cache = [] + self._client_version_tuple = () + + if verify is True: + self._verify_conn() + + def _get_conn(self): + """Get ServerProxy instance""" + if self.username is not None and self.password is not None: + return self.sp( + self.url, + transport=BasicAuthTransport(self.username, self.password), + **self.sp_kwargs + ) + return self.sp(self.url, **self.sp_kwargs) + + def _verify_conn(self): + # check for rpc methods that should be available + assert {"system.client_version", + "system.library_version"}.issubset(set(self._get_rpc_methods())),\ + "Required RPC methods not available." + + # minimum rTorrent version check + + assert self._meets_version_requirement() is True,\ + "Error: Minimum rTorrent version required is {0}".format( + MIN_RTORRENT_VERSION_STR) + + def _meets_version_requirement(self): + return self._get_client_version_tuple() >= MIN_RTORRENT_VERSION + + def _get_client_version_tuple(self): + conn = self._get_conn() + + if not self._client_version_tuple: + if not hasattr(self, "client_version"): + setattr(self, "client_version", + conn.system.client_version()) + + rtver = getattr(self, "client_version") + self._client_version_tuple = tuple([int(i) for i in + rtver.split(".")]) + + return self._client_version_tuple + + def _get_rpc_methods(self): + """ Get list of raw RPC commands + + @return: raw RPC commands + @rtype: list + """ + + if self._rpc_methods == []: + self._rpc_methods = self._get_conn().system.listMethods() + + return(self._rpc_methods) + + def get_torrents(self, view="main"): + """Get list of all torrents in specified view + + @return: list of L{Torrent} instances + + @rtype: list + + @todo: add validity check for specified view + """ + self.torrents = [] + methods = rtorrent.torrent.methods + retriever_methods = [m for m in methods + if m.is_retriever() and m.is_available(self)] + + m = rtorrent.rpc.Multicall(self) + m.add("d.multicall", view, "d.get_hash=", + *[method.rpc_call + "=" for method in retriever_methods]) + + results = m.call()[0] # only sent one call, only need first result + + for result in results: + results_dict = {} + # build results_dict + for m, r in zip(retriever_methods, result[1:]): # result[0] is the info_hash + results_dict[m.varname] = rtorrent.rpc.process_result(m, r) + + self.torrents.append( + Torrent(self, info_hash=result[0], **results_dict) + ) + + self._manage_torrent_cache() + return(self.torrents) + + def _manage_torrent_cache(self): + """Carry tracker/peer/file lists over to new torrent list""" + for torrent in self._torrent_cache: + new_torrent = rtorrent.common.find_torrent(torrent.info_hash, + self.torrents) + if new_torrent is not None: + new_torrent.files = torrent.files + new_torrent.peers = torrent.peers + new_torrent.trackers = torrent.trackers + + self._torrent_cache = self.torrents + + def _get_load_function(self, file_type, start, verbose): + """Determine correct "load torrent" RPC method""" + func_name = None + if file_type == "url": + # url strings can be input directly + if start and verbose: + func_name = "load_start_verbose" + elif start: + func_name = "load_start" + elif verbose: + func_name = "load_verbose" + else: + func_name = "load" + elif file_type in ["file", "raw"]: + if start and verbose: + func_name = "load_raw_start_verbose" + elif start: + func_name = "load_raw_start" + elif verbose: + func_name = "load_raw_verbose" + else: + func_name = "load_raw" + + return(func_name) + + def load_torrent(self, torrent, start=False, verbose=False, verify_load=True): + """ + Loads torrent into rTorrent (with various enhancements) + + @param torrent: can be a url, a path to a local file, or the raw data + of a torrent file + @type torrent: str + + @param start: start torrent when loaded + @type start: bool + + @param verbose: print error messages to rTorrent log + @type verbose: bool + + @param verify_load: verify that torrent was added to rTorrent successfully + @type verify_load: bool + + @return: Depends on verify_load: + - if verify_load is True, (and the torrent was + loaded successfully), it'll return a L{Torrent} instance + - if verify_load is False, it'll return None + + @rtype: L{Torrent} instance or None + + @raise AssertionError: If the torrent wasn't successfully added to rTorrent + - Check L{TorrentParser} for the AssertionError's + it raises + + + @note: Because this function includes url verification (if a url was input) + as well as verification as to whether the torrent was successfully added, + this function doesn't execute instantaneously. If that's what you're + looking for, use load_torrent_simple() instead. + """ + p = self._get_conn() + tp = TorrentParser(torrent) + torrent = xmlrpclib.Binary(tp._raw_torrent) + info_hash = tp.info_hash + + func_name = self._get_load_function("raw", start, verbose) + + # load torrent + getattr(p, func_name)(torrent) + + if verify_load: + MAX_RETRIES = 3 + i = 0 + while i < MAX_RETRIES: + self.get_torrents() + if info_hash in [t.info_hash for t in self.torrents]: + break + + # was still getting AssertionErrors, delay should help + time.sleep(1) + i += 1 + + assert info_hash in [t.info_hash for t in self.torrents],\ + "Adding torrent was unsuccessful." + + return(find_torrent(info_hash, self.torrents)) + + def load_torrent_simple(self, torrent, file_type, + start=False, verbose=False): + """Loads torrent into rTorrent + + @param torrent: can be a url, a path to a local file, or the raw data + of a torrent file + @type torrent: str + + @param file_type: valid options: "url", "file", or "raw" + @type file_type: str + + @param start: start torrent when loaded + @type start: bool + + @param verbose: print error messages to rTorrent log + @type verbose: bool + + @return: None + + @raise AssertionError: if incorrect file_type is specified + + @note: This function was written for speed, it includes no enhancements. + If you input a url, it won't check if it's valid. You also can't get + verification that the torrent was successfully added to rTorrent. + Use load_torrent() if you would like these features. + """ + p = self._get_conn() + + assert file_type in ["raw", "file", "url"], \ + "Invalid file_type, options are: 'url', 'file', 'raw'." + func_name = self._get_load_function(file_type, start, verbose) + + if file_type == "file": + # since we have to assume we're connected to a remote rTorrent + # client, we have to read the file and send it to rT as raw + assert os.path.isfile(torrent), \ + "Invalid path: \"{0}\"".format(torrent) + torrent = open(torrent, "rb").read() + + if file_type in ["raw", "file"]: + finput = xmlrpclib.Binary(torrent) + elif file_type == "url": + finput = torrent + + getattr(p, func_name)(finput) + + def set_dht_port(self, port): + """Set DHT port + + @param port: port + @type port: int + + @raise AssertionError: if invalid port is given + """ + assert is_valid_port(port), "Valid port range is 0-65535" + self.dht_port = self._p.set_dht_port(port) + + def enable_check_hash(self): + """Alias for set_check_hash(True)""" + self.set_check_hash(True) + + def disable_check_hash(self): + """Alias for set_check_hash(False)""" + self.set_check_hash(False) + + def find_torrent(self, info_hash): + """Frontend for rtorrent.common.find_torrent""" + return(rtorrent.common.find_torrent(info_hash, self.get_torrents())) + + def poll(self): + """ poll rTorrent to get latest torrent/peer/tracker/file information + + @note: This essentially refreshes every aspect of the rTorrent + connection, so it can be very slow if working with a remote + connection that has a lot of torrents loaded. + + @return: None + """ + self.update() + torrents = self.get_torrents() + for t in torrents: + t.poll() + + def update(self): + """Refresh rTorrent client info + + @note: All fields are stored as attributes to self. + + @return: None + """ + multicall = rtorrent.rpc.Multicall(self) + retriever_methods = [m for m in methods + if m.is_retriever() and m.is_available(self)] + for method in retriever_methods: + multicall.add(method) + + multicall.call() + + +def _build_class_methods(class_obj): + # multicall add class + caller = lambda self, multicall, method, *args:\ + multicall.add(method, self.rpc_id, *args) + + caller.__doc__ = """Same as Multicall.add(), but with automatic inclusion + of the rpc_id + + @param multicall: A L{Multicall} instance + @type: multicall: Multicall + + @param method: L{Method} instance or raw rpc method + @type: Method or str + + @param args: optional arguments to pass + """ + setattr(class_obj, "multicall_add", caller) + + +def __compare_rpc_methods(rt_new, rt_old): + from pprint import pprint + rt_new_methods = set(rt_new._get_rpc_methods()) + rt_old_methods = set(rt_old._get_rpc_methods()) + print("New Methods:") + pprint(rt_new_methods - rt_old_methods) + print("Methods not in new rTorrent:") + pprint(rt_old_methods - rt_new_methods) + + +def __check_supported_methods(rt): + from pprint import pprint + supported_methods = set([m.rpc_call for m in + methods + + rtorrent.file.methods + + rtorrent.torrent.methods + + rtorrent.tracker.methods + + rtorrent.peer.methods]) + all_methods = set(rt._get_rpc_methods()) + + print("Methods NOT in supported methods") + pprint(all_methods - supported_methods) + print("Supported methods NOT in all methods") + pprint(supported_methods - all_methods) + +methods = [ + # RETRIEVERS + Method(RTorrent, 'get_xmlrpc_size_limit', 'get_xmlrpc_size_limit'), + Method(RTorrent, 'get_proxy_address', 'get_proxy_address'), + Method(RTorrent, 'get_split_suffix', 'get_split_suffix'), + Method(RTorrent, 'get_up_limit', 'get_upload_rate'), + Method(RTorrent, 'get_max_memory_usage', 'get_max_memory_usage'), + Method(RTorrent, 'get_max_open_files', 'get_max_open_files'), + Method(RTorrent, 'get_min_peers_seed', 'get_min_peers_seed'), + Method(RTorrent, 'get_use_udp_trackers', 'get_use_udp_trackers'), + Method(RTorrent, 'get_preload_min_size', 'get_preload_min_size'), + Method(RTorrent, 'get_max_uploads', 'get_max_uploads'), + Method(RTorrent, 'get_max_peers', 'get_max_peers'), + Method(RTorrent, 'get_timeout_sync', 'get_timeout_sync'), + Method(RTorrent, 'get_receive_buffer_size', 'get_receive_buffer_size'), + Method(RTorrent, 'get_split_file_size', 'get_split_file_size'), + Method(RTorrent, 'get_dht_throttle', 'get_dht_throttle'), + Method(RTorrent, 'get_max_peers_seed', 'get_max_peers_seed'), + Method(RTorrent, 'get_min_peers', 'get_min_peers'), + Method(RTorrent, 'get_tracker_numwant', 'get_tracker_numwant'), + Method(RTorrent, 'get_max_open_sockets', 'get_max_open_sockets'), + Method(RTorrent, 'get_session', 'get_session'), + Method(RTorrent, 'get_ip', 'get_ip'), + Method(RTorrent, 'get_scgi_dont_route', 'get_scgi_dont_route'), + Method(RTorrent, 'get_hash_read_ahead', 'get_hash_read_ahead'), + Method(RTorrent, 'get_http_cacert', 'get_http_cacert'), + Method(RTorrent, 'get_dht_port', 'get_dht_port'), + Method(RTorrent, 'get_handshake_log', 'get_handshake_log'), + Method(RTorrent, 'get_preload_type', 'get_preload_type'), + Method(RTorrent, 'get_max_open_http', 'get_max_open_http'), + Method(RTorrent, 'get_http_capath', 'get_http_capath'), + Method(RTorrent, 'get_max_downloads_global', 'get_max_downloads_global'), + Method(RTorrent, 'get_name', 'get_name'), + Method(RTorrent, 'get_session_on_completion', 'get_session_on_completion'), + Method(RTorrent, 'get_down_limit', 'get_download_rate'), + Method(RTorrent, 'get_down_total', 'get_down_total'), + Method(RTorrent, 'get_up_rate', 'get_up_rate'), + Method(RTorrent, 'get_hash_max_tries', 'get_hash_max_tries'), + Method(RTorrent, 'get_peer_exchange', 'get_peer_exchange'), + Method(RTorrent, 'get_down_rate', 'get_down_rate'), + Method(RTorrent, 'get_connection_seed', 'get_connection_seed'), + Method(RTorrent, 'get_http_proxy', 'get_http_proxy'), + Method(RTorrent, 'get_stats_preloaded', 'get_stats_preloaded'), + Method(RTorrent, 'get_timeout_safe_sync', 'get_timeout_safe_sync'), + Method(RTorrent, 'get_hash_interval', 'get_hash_interval'), + Method(RTorrent, 'get_port_random', 'get_port_random'), + Method(RTorrent, 'get_directory', 'get_directory'), + Method(RTorrent, 'get_port_open', 'get_port_open'), + Method(RTorrent, 'get_max_file_size', 'get_max_file_size'), + Method(RTorrent, 'get_stats_not_preloaded', 'get_stats_not_preloaded'), + Method(RTorrent, 'get_memory_usage', 'get_memory_usage'), + Method(RTorrent, 'get_connection_leech', 'get_connection_leech'), + Method(RTorrent, 'get_check_hash', 'get_check_hash', + boolean=True, + ), + Method(RTorrent, 'get_session_lock', 'get_session_lock'), + Method(RTorrent, 'get_preload_required_rate', 'get_preload_required_rate'), + Method(RTorrent, 'get_max_uploads_global', 'get_max_uploads_global'), + Method(RTorrent, 'get_send_buffer_size', 'get_send_buffer_size'), + Method(RTorrent, 'get_port_range', 'get_port_range'), + Method(RTorrent, 'get_max_downloads_div', 'get_max_downloads_div'), + Method(RTorrent, 'get_max_uploads_div', 'get_max_uploads_div'), + Method(RTorrent, 'get_safe_sync', 'get_safe_sync'), + Method(RTorrent, 'get_bind', 'get_bind'), + Method(RTorrent, 'get_up_total', 'get_up_total'), + Method(RTorrent, 'get_client_version', 'system.client_version'), + Method(RTorrent, 'get_library_version', 'system.library_version'), + Method(RTorrent, 'get_api_version', 'system.api_version', + min_version=(0, 9, 1) + ), + Method(RTorrent, "get_system_time", "system.time", + docstring="""Get the current time of the system rTorrent is running on + + @return: time (posix) + @rtype: int""", + ), + + # MODIFIERS + Method(RTorrent, 'set_http_proxy', 'set_http_proxy'), + Method(RTorrent, 'set_max_memory_usage', 'set_max_memory_usage'), + Method(RTorrent, 'set_max_file_size', 'set_max_file_size'), + Method(RTorrent, 'set_bind', 'set_bind', + docstring="""Set address bind + + @param arg: ip address + @type arg: str + """, + ), + Method(RTorrent, 'set_up_limit', 'set_upload_rate', + docstring="""Set global upload limit (in bytes) + + @param arg: speed limit + @type arg: int + """, + ), + Method(RTorrent, 'set_port_random', 'set_port_random'), + Method(RTorrent, 'set_connection_leech', 'set_connection_leech'), + Method(RTorrent, 'set_tracker_numwant', 'set_tracker_numwant'), + Method(RTorrent, 'set_max_peers', 'set_max_peers'), + Method(RTorrent, 'set_min_peers', 'set_min_peers'), + Method(RTorrent, 'set_max_uploads_div', 'set_max_uploads_div'), + Method(RTorrent, 'set_max_open_files', 'set_max_open_files'), + Method(RTorrent, 'set_max_downloads_global', 'set_max_downloads_global'), + Method(RTorrent, 'set_session_lock', 'set_session_lock'), + Method(RTorrent, 'set_session', 'set_session'), + Method(RTorrent, 'set_split_suffix', 'set_split_suffix'), + Method(RTorrent, 'set_hash_interval', 'set_hash_interval'), + Method(RTorrent, 'set_handshake_log', 'set_handshake_log'), + Method(RTorrent, 'set_port_range', 'set_port_range'), + Method(RTorrent, 'set_min_peers_seed', 'set_min_peers_seed'), + Method(RTorrent, 'set_scgi_dont_route', 'set_scgi_dont_route'), + Method(RTorrent, 'set_preload_min_size', 'set_preload_min_size'), + Method(RTorrent, 'set_log.tracker', 'set_log.tracker'), + Method(RTorrent, 'set_max_uploads_global', 'set_max_uploads_global'), + Method(RTorrent, 'set_down_limit', 'set_download_rate', + docstring="""Set global download limit (in bytes) + + @param arg: speed limit + @type arg: int + """, + ), + Method(RTorrent, 'set_preload_required_rate', 'set_preload_required_rate'), + Method(RTorrent, 'set_hash_read_ahead', 'set_hash_read_ahead'), + Method(RTorrent, 'set_max_peers_seed', 'set_max_peers_seed'), + Method(RTorrent, 'set_max_uploads', 'set_max_uploads'), + Method(RTorrent, 'set_session_on_completion', 'set_session_on_completion'), + Method(RTorrent, 'set_max_open_http', 'set_max_open_http'), + Method(RTorrent, 'set_directory', 'set_directory'), + Method(RTorrent, 'set_http_cacert', 'set_http_cacert'), + Method(RTorrent, 'set_dht_throttle', 'set_dht_throttle'), + Method(RTorrent, 'set_hash_max_tries', 'set_hash_max_tries'), + Method(RTorrent, 'set_proxy_address', 'set_proxy_address'), + Method(RTorrent, 'set_split_file_size', 'set_split_file_size'), + Method(RTorrent, 'set_receive_buffer_size', 'set_receive_buffer_size'), + Method(RTorrent, 'set_use_udp_trackers', 'set_use_udp_trackers'), + Method(RTorrent, 'set_connection_seed', 'set_connection_seed'), + Method(RTorrent, 'set_xmlrpc_size_limit', 'set_xmlrpc_size_limit'), + Method(RTorrent, 'set_xmlrpc_dialect', 'set_xmlrpc_dialect'), + Method(RTorrent, 'set_safe_sync', 'set_safe_sync'), + Method(RTorrent, 'set_http_capath', 'set_http_capath'), + Method(RTorrent, 'set_send_buffer_size', 'set_send_buffer_size'), + Method(RTorrent, 'set_max_downloads_div', 'set_max_downloads_div'), + Method(RTorrent, 'set_name', 'set_name'), + Method(RTorrent, 'set_port_open', 'set_port_open'), + Method(RTorrent, 'set_timeout_sync', 'set_timeout_sync'), + Method(RTorrent, 'set_peer_exchange', 'set_peer_exchange'), + Method(RTorrent, 'set_ip', 'set_ip', + docstring="""Set IP + + @param arg: ip address + @type arg: str + """, + ), + Method(RTorrent, 'set_timeout_safe_sync', 'set_timeout_safe_sync'), + Method(RTorrent, 'set_preload_type', 'set_preload_type'), + Method(RTorrent, 'set_check_hash', 'set_check_hash', + docstring="""Enable/Disable hash checking on finished torrents + + @param arg: True to enable, False to disable + @type arg: bool + """, + boolean=True, + ), +] + +_all_methods_list = [methods, + rtorrent.file.methods, + rtorrent.torrent.methods, + rtorrent.tracker.methods, + rtorrent.peer.methods, + ] + +class_methods_pair = { + RTorrent: methods, + rtorrent.file.File: rtorrent.file.methods, + rtorrent.torrent.Torrent: rtorrent.torrent.methods, + rtorrent.tracker.Tracker: rtorrent.tracker.methods, + rtorrent.peer.Peer: rtorrent.peer.methods, +} +for c in class_methods_pair.keys(): + rtorrent.rpc._build_rpc_methods(c, class_methods_pair[c]) + _build_class_methods(c) diff --git a/libs/rtorrent/common.py b/libs/rtorrent/common.py new file mode 100755 index 00000000..371c71c3 --- /dev/null +++ b/libs/rtorrent/common.py @@ -0,0 +1,86 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +from rtorrent.compat import is_py3 + + +def bool_to_int(value): + """Translates python booleans to RPC-safe integers""" + if value is True: + return("1") + elif value is False: + return("0") + else: + return(value) + + +def cmd_exists(cmds_list, cmd): + """Check if given command is in list of available commands + + @param cmds_list: see L{RTorrent._rpc_methods} + @type cmds_list: list + + @param cmd: name of command to be checked + @type cmd: str + + @return: bool + """ + + return(cmd in cmds_list) + + +def find_torrent(info_hash, torrent_list): + """Find torrent file in given list of Torrent classes + + @param info_hash: info hash of torrent + @type info_hash: str + + @param torrent_list: list of L{Torrent} instances (see L{RTorrent.get_torrents}) + @type torrent_list: list + + @return: L{Torrent} instance, or -1 if not found + """ + for t in torrent_list: + if t.info_hash == info_hash: + return t + + return None + + +def is_valid_port(port): + """Check if given port is valid""" + return(0 <= int(port) <= 65535) + + +def convert_version_tuple_to_str(t): + return(".".join([str(n) for n in t])) + + +def safe_repr(fmt, *args, **kwargs): + """ Formatter that handles unicode arguments """ + + if not is_py3(): + # unicode fmt can take str args, str fmt cannot take unicode args + fmt = fmt.decode("utf-8") + out = fmt.format(*args, **kwargs) + return out.encode("utf-8") + else: + return fmt.format(*args, **kwargs) diff --git a/libs/rtorrent/compat.py b/libs/rtorrent/compat.py new file mode 100755 index 00000000..1778818b --- /dev/null +++ b/libs/rtorrent/compat.py @@ -0,0 +1,30 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import sys + + +def is_py3(): + return sys.version_info[0] == 3 + +if is_py3(): + import xmlrpc.client as xmlrpclib +else: + import xmlrpclib diff --git a/libs/rtorrent/err.py b/libs/rtorrent/err.py new file mode 100755 index 00000000..920b8385 --- /dev/null +++ b/libs/rtorrent/err.py @@ -0,0 +1,40 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from rtorrent.common import convert_version_tuple_to_str + + +class RTorrentVersionError(Exception): + def __init__(self, min_version, cur_version): + self.min_version = min_version + self.cur_version = cur_version + self.msg = "Minimum version required: {0}".format( + convert_version_tuple_to_str(min_version)) + + def __str__(self): + return(self.msg) + + +class MethodError(Exception): + def __init__(self, msg): + self.msg = msg + + def __str__(self): + return(self.msg) diff --git a/libs/rtorrent/file.py b/libs/rtorrent/file.py new file mode 100755 index 00000000..a3db35cf --- /dev/null +++ b/libs/rtorrent/file.py @@ -0,0 +1,91 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# from rtorrent.rpc import Method +import rtorrent.rpc + +from rtorrent.common import safe_repr + +Method = rtorrent.rpc.Method + + +class File: + """Represents an individual file within a L{Torrent} instance.""" + + def __init__(self, _rt_obj, info_hash, index, **kwargs): + self._rt_obj = _rt_obj + self.info_hash = info_hash # : info hash for the torrent the file is associated with + self.index = index # : The position of the file within the file list + for k in kwargs.keys(): + setattr(self, k, kwargs.get(k, None)) + + self.rpc_id = "{0}:f{1}".format( + self.info_hash, self.index) # : unique id to pass to rTorrent + + def update(self): + """Refresh file data + + @note: All fields are stored as attributes to self. + + @return: None + """ + multicall = rtorrent.rpc.Multicall(self) + retriever_methods = [m for m in methods + if m.is_retriever() and m.is_available(self._rt_obj)] + for method in retriever_methods: + multicall.add(method, self.rpc_id) + + multicall.call() + + def __repr__(self): + return safe_repr("File(index={0} path=\"{1}\")", self.index, self.path) + +methods = [ + # RETRIEVERS + Method(File, 'get_last_touched', 'f.get_last_touched'), + Method(File, 'get_range_second', 'f.get_range_second'), + Method(File, 'get_size_bytes', 'f.get_size_bytes'), + Method(File, 'get_priority', 'f.get_priority'), + Method(File, 'get_match_depth_next', 'f.get_match_depth_next'), + Method(File, 'is_resize_queued', 'f.is_resize_queued', + boolean=True, + ), + Method(File, 'get_range_first', 'f.get_range_first'), + Method(File, 'get_match_depth_prev', 'f.get_match_depth_prev'), + Method(File, 'get_path', 'f.get_path'), + Method(File, 'get_completed_chunks', 'f.get_completed_chunks'), + Method(File, 'get_path_components', 'f.get_path_components'), + Method(File, 'is_created', 'f.is_created', + boolean=True, + ), + Method(File, 'is_open', 'f.is_open', + boolean=True, + ), + Method(File, 'get_size_chunks', 'f.get_size_chunks'), + Method(File, 'get_offset', 'f.get_offset'), + Method(File, 'get_frozen_path', 'f.get_frozen_path'), + Method(File, 'get_path_depth', 'f.get_path_depth'), + Method(File, 'is_create_queued', 'f.is_create_queued', + boolean=True, + ), + + + # MODIFIERS +] diff --git a/libs/rtorrent/lib/__init__.py b/libs/rtorrent/lib/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/libs/rtorrent/lib/bencode.py b/libs/rtorrent/lib/bencode.py new file mode 100755 index 00000000..97bd2f0e --- /dev/null +++ b/libs/rtorrent/lib/bencode.py @@ -0,0 +1,281 @@ +# Copyright (C) 2011 by clueless +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +# +# Version: 20111107 +# +# Changelog +# --------- +# 2011-11-07 - Added support for Python2 (tested on 2.6) +# 2011-10-03 - Fixed: moved check for end of list at the top of the while loop +# in _decode_list (in case the list is empty) (Chris Lucas) +# - Converted dictionary keys to str +# 2011-04-24 - Changed date format to YYYY-MM-DD for versioning, bigger +# integer denotes a newer version +# - Fixed a bug that would treat False as an integral type but +# encode it using the 'False' string, attempting to encode a +# boolean now results in an error +# - Fixed a bug where an integer value of 0 in a list or +# dictionary resulted in a parse error while decoding +# +# 2011-04-03 - Original release + +import sys + +_py3 = sys.version_info[0] == 3 + +if _py3: + _VALID_STRING_TYPES = (str,) +else: + _VALID_STRING_TYPES = (str, unicode) # @UndefinedVariable + +_TYPE_INT = 1 +_TYPE_STRING = 2 +_TYPE_LIST = 3 +_TYPE_DICTIONARY = 4 +_TYPE_END = 5 +_TYPE_INVALID = 6 + +# Function to determine the type of he next value/item +# Arguments: +# char First character of the string that is to be decoded +# Return value: +# Returns an integer that describes what type the next value/item is + + +def _gettype(char): + if not isinstance(char, int): + char = ord(char) + if char == 0x6C: # 'l' + return _TYPE_LIST + elif char == 0x64: # 'd' + return _TYPE_DICTIONARY + elif char == 0x69: # 'i' + return _TYPE_INT + elif char == 0x65: # 'e' + return _TYPE_END + elif char >= 0x30 and char <= 0x39: # '0' '9' + return _TYPE_STRING + else: + return _TYPE_INVALID + +# Function to parse a string from the bendcoded data +# Arguments: +# data bencoded data, must be guaranteed to be a string +# Return Value: +# Returns a tuple, the first member of the tuple is the parsed string +# The second member is whatever remains of the bencoded data so it can +# be used to parse the next part of the data + + +def _decode_string(data): + end = 1 + # if py3, data[end] is going to be an int + # if py2, data[end] will be a string + if _py3: + char = 0x3A + else: + char = chr(0x3A) + + while data[end] != char: # ':' + end = end + 1 + strlen = int(data[:end]) + return (data[end + 1:strlen + end + 1], data[strlen + end + 1:]) + +# Function to parse an integer from the bencoded data +# Arguments: +# data bencoded data, must be guaranteed to be an integer +# Return Value: +# Returns a tuple, the first member of the tuple is the parsed string +# The second member is whatever remains of the bencoded data so it can +# be used to parse the next part of the data + + +def _decode_int(data): + end = 1 + # if py3, data[end] is going to be an int + # if py2, data[end] will be a string + if _py3: + char = 0x65 + else: + char = chr(0x65) + + while data[end] != char: # 'e' + end = end + 1 + return (int(data[1:end]), data[end + 1:]) + +# Function to parse a bencoded list +# Arguments: +# data bencoded data, must be guaranted to be the start of a list +# Return Value: +# Returns a tuple, the first member of the tuple is the parsed list +# The second member is whatever remains of the bencoded data so it can +# be used to parse the next part of the data + + +def _decode_list(data): + x = [] + overflow = data[1:] + while True: # Loop over the data + if _gettype(overflow[0]) == _TYPE_END: # - Break if we reach the end of the list + return (x, overflow[1:]) # and return the list and overflow + + value, overflow = _decode(overflow) # + if isinstance(value, bool) or overflow == '': # - if we have a parse error + return (False, False) # Die with error + else: # - Otherwise + x.append(value) # add the value to the list + + +# Function to parse a bencoded list +# Arguments: +# data bencoded data, must be guaranted to be the start of a list +# Return Value: +# Returns a tuple, the first member of the tuple is the parsed dictionary +# The second member is whatever remains of the bencoded data so it can +# be used to parse the next part of the data +def _decode_dict(data): + x = {} + overflow = data[1:] + while True: # Loop over the data + if _gettype(overflow[0]) != _TYPE_STRING: # - If the key is not a string + return (False, False) # Die with error + key, overflow = _decode(overflow) # + if key == False or overflow == '': # - If parse error + return (False, False) # Die with error + value, overflow = _decode(overflow) # + if isinstance(value, bool) or overflow == '': # - If parse error + print("Error parsing value") + print(value) + print(overflow) + return (False, False) # Die with error + else: + # don't use bytes for the key + key = key.decode() + x[key] = value + if _gettype(overflow[0]) == _TYPE_END: + return (x, overflow[1:]) + +# Arguments: +# data bencoded data in bytes format +# Return Values: +# Returns a tuple, the first member is the parsed data, could be a string, +# an integer, a list or a dictionary, or a combination of those +# The second member is the leftover of parsing, if everything parses correctly this +# should be an empty byte string + + +def _decode(data): + btype = _gettype(data[0]) + if btype == _TYPE_INT: + return _decode_int(data) + elif btype == _TYPE_STRING: + return _decode_string(data) + elif btype == _TYPE_LIST: + return _decode_list(data) + elif btype == _TYPE_DICTIONARY: + return _decode_dict(data) + else: + return (False, False) + +# Function to decode bencoded data +# Arguments: +# data bencoded data, can be str or bytes +# Return Values: +# Returns the decoded data on success, this coud be bytes, int, dict or list +# or a combinatin of those +# If an error occurs the return value is False + + +def decode(data): + # if isinstance(data, str): + # data = data.encode() + decoded, overflow = _decode(data) + return decoded + +# Args: data as integer +# return: encoded byte string + + +def _encode_int(data): + return b'i' + str(data).encode() + b'e' + +# Args: data as string or bytes +# Return: encoded byte string + + +def _encode_string(data): + return str(len(data)).encode() + b':' + data + +# Args: data as list +# Return: Encoded byte string, false on error + + +def _encode_list(data): + elist = b'l' + for item in data: + eitem = encode(item) + if eitem == False: + return False + elist += eitem + return elist + b'e' + +# Args: data as dict +# Return: encoded byte string, false on error + + +def _encode_dict(data): + edict = b'd' + keys = [] + for key in data: + if not isinstance(key, _VALID_STRING_TYPES) and not isinstance(key, bytes): + return False + keys.append(key) + keys.sort() + for key in keys: + ekey = encode(key) + eitem = encode(data[key]) + if ekey == False or eitem == False: + return False + edict += ekey + eitem + return edict + b'e' + +# Function to encode a variable in bencoding +# Arguments: +# data Variable to be encoded, can be a list, dict, str, bytes, int or a combination of those +# Return Values: +# Returns the encoded data as a byte string when successful +# If an error occurs the return value is False + + +def encode(data): + if isinstance(data, bool): + return False + elif isinstance(data, int): + return _encode_int(data) + elif isinstance(data, bytes): + return _encode_string(data) + elif isinstance(data, _VALID_STRING_TYPES): + return _encode_string(data.encode()) + elif isinstance(data, list): + return _encode_list(data) + elif isinstance(data, dict): + return _encode_dict(data) + else: + return False diff --git a/libs/rtorrent/lib/torrentparser.py b/libs/rtorrent/lib/torrentparser.py new file mode 100755 index 00000000..19dd12aa --- /dev/null +++ b/libs/rtorrent/lib/torrentparser.py @@ -0,0 +1,159 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from rtorrent.compat import is_py3 +import os.path +import re +import rtorrent.lib.bencode as bencode +import hashlib + +if is_py3(): + from urllib.request import urlopen # @UnresolvedImport @UnusedImport +else: + from urllib2 import urlopen # @UnresolvedImport @Reimport + + +class TorrentParser(): + def __init__(self, torrent): + """Decode and parse given torrent + + @param torrent: handles: urls, file paths, string of torrent data + @type torrent: str + + @raise AssertionError: Can be raised for a couple reasons: + - If _get_raw_torrent() couldn't figure out + what X{torrent} is + - if X{torrent} isn't a valid bencoded torrent file + """ + self.torrent = torrent + self._raw_torrent = None # : testing yo + self._torrent_decoded = None # : what up + self.file_type = None + + self._get_raw_torrent() + assert self._raw_torrent is not None, "Couldn't get raw_torrent." + if self._torrent_decoded is None: + self._decode_torrent() + assert isinstance(self._torrent_decoded, dict), "Invalid torrent file." + self._parse_torrent() + + def _is_raw(self): + raw = False + if isinstance(self.torrent, (str, bytes)): + if isinstance(self._decode_torrent(self.torrent), dict): + raw = True + else: + # reset self._torrent_decoded (currently equals False) + self._torrent_decoded = None + + return(raw) + + def _get_raw_torrent(self): + """Get raw torrent data by determining what self.torrent is""" + # already raw? + if self._is_raw(): + self.file_type = "raw" + self._raw_torrent = self.torrent + return + # local file? + if os.path.isfile(self.torrent): + self.file_type = "file" + self._raw_torrent = open(self.torrent, "rb").read() + # url? + elif re.search("^(http|ftp):\/\/", self.torrent, re.I): + self.file_type = "url" + self._raw_torrent = urlopen(self.torrent).read() + + def _decode_torrent(self, raw_torrent=None): + if raw_torrent is None: + raw_torrent = self._raw_torrent + self._torrent_decoded = bencode.decode(raw_torrent) + return(self._torrent_decoded) + + def _calc_info_hash(self): + self.info_hash = None + if "info" in self._torrent_decoded.keys(): + info_dict = self._torrent_decoded["info"] + self.info_hash = hashlib.sha1(bencode.encode( + info_dict)).hexdigest().upper() + + return(self.info_hash) + + def _parse_torrent(self): + for k in self._torrent_decoded: + key = k.replace(" ", "_").lower() + setattr(self, key, self._torrent_decoded[k]) + + self._calc_info_hash() + + +class NewTorrentParser(object): + @staticmethod + def _read_file(fp): + return fp.read() + + @staticmethod + def _write_file(fp): + fp.write() + return fp + + @staticmethod + def _decode_torrent(data): + return bencode.decode(data) + + def __init__(self, input): + self.input = input + self._raw_torrent = None + self._decoded_torrent = None + self._hash_outdated = False + + if isinstance(self.input, (str, bytes)): + # path to file? + if os.path.isfile(self.input): + self._raw_torrent = self._read_file(open(self.input, "rb")) + else: + # assume input was the raw torrent data (do we really want + # this?) + self._raw_torrent = self.input + + # file-like object? + elif self.input.hasattr("read"): + self._raw_torrent = self._read_file(self.input) + + assert self._raw_torrent is not None, "Invalid input: input must be a path or a file-like object" + + self._decoded_torrent = self._decode_torrent(self._raw_torrent) + + assert isinstance( + self._decoded_torrent, dict), "File could not be decoded" + + def _calc_info_hash(self): + self.info_hash = None + info_dict = self._torrent_decoded["info"] + self.info_hash = hashlib.sha1(bencode.encode( + info_dict)).hexdigest().upper() + + return(self.info_hash) + + def set_tracker(self, tracker): + self._decoded_torrent["announce"] = tracker + + def get_tracker(self): + return self._decoded_torrent.get("announce") diff --git a/libs/rtorrent/lib/xmlrpc/__init__.py b/libs/rtorrent/lib/xmlrpc/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/libs/rtorrent/lib/xmlrpc/http.py b/libs/rtorrent/lib/xmlrpc/http.py new file mode 100755 index 00000000..3eb85210 --- /dev/null +++ b/libs/rtorrent/lib/xmlrpc/http.py @@ -0,0 +1,23 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +from rtorrent.compat import xmlrpclib + +HTTPServerProxy = xmlrpclib.ServerProxy diff --git a/libs/rtorrent/peer.py b/libs/rtorrent/peer.py new file mode 100755 index 00000000..61ca0941 --- /dev/null +++ b/libs/rtorrent/peer.py @@ -0,0 +1,98 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# from rtorrent.rpc import Method +import rtorrent.rpc + +from rtorrent.common import safe_repr + +Method = rtorrent.rpc.Method + + +class Peer: + """Represents an individual peer within a L{Torrent} instance.""" + def __init__(self, _rt_obj, info_hash, **kwargs): + self._rt_obj = _rt_obj + self.info_hash = info_hash # : info hash for the torrent the peer is associated with + for k in kwargs.keys(): + setattr(self, k, kwargs.get(k, None)) + + self.rpc_id = "{0}:p{1}".format( + self.info_hash, self.id) # : unique id to pass to rTorrent + + def __repr__(self): + return safe_repr("Peer(id={0})", self.id) + + def update(self): + """Refresh peer data + + @note: All fields are stored as attributes to self. + + @return: None + """ + multicall = rtorrent.rpc.Multicall(self) + retriever_methods = [m for m in methods + if m.is_retriever() and m.is_available(self._rt_obj)] + for method in retriever_methods: + multicall.add(method, self.rpc_id) + + multicall.call() + +methods = [ + # RETRIEVERS + Method(Peer, 'is_preferred', 'p.is_preferred', + boolean=True, + ), + Method(Peer, 'get_down_rate', 'p.get_down_rate'), + Method(Peer, 'is_unwanted', 'p.is_unwanted', + boolean=True, + ), + Method(Peer, 'get_peer_total', 'p.get_peer_total'), + Method(Peer, 'get_peer_rate', 'p.get_peer_rate'), + Method(Peer, 'get_port', 'p.get_port'), + Method(Peer, 'is_snubbed', 'p.is_snubbed', + boolean=True, + ), + Method(Peer, 'get_id_html', 'p.get_id_html'), + Method(Peer, 'get_up_rate', 'p.get_up_rate'), + Method(Peer, 'is_banned', 'p.banned', + boolean=True, + ), + Method(Peer, 'get_completed_percent', 'p.get_completed_percent'), + Method(Peer, 'completed_percent', 'p.completed_percent'), + Method(Peer, 'get_id', 'p.get_id'), + Method(Peer, 'is_obfuscated', 'p.is_obfuscated', + boolean=True, + ), + Method(Peer, 'get_down_total', 'p.get_down_total'), + Method(Peer, 'get_client_version', 'p.get_client_version'), + Method(Peer, 'get_address', 'p.get_address'), + Method(Peer, 'is_incoming', 'p.is_incoming', + boolean=True, + ), + Method(Peer, 'is_encrypted', 'p.is_encrypted', + boolean=True, + ), + Method(Peer, 'get_options_str', 'p.get_options_str'), + Method(Peer, 'get_client_version', 'p.client_version'), + Method(Peer, 'get_up_total', 'p.get_up_total'), + + # MODIFIERS +] diff --git a/libs/rtorrent/rpc/__init__.py b/libs/rtorrent/rpc/__init__.py new file mode 100755 index 00000000..8190de46 --- /dev/null +++ b/libs/rtorrent/rpc/__init__.py @@ -0,0 +1,354 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +from base64 import encodestring +import httplib +import string + +import rtorrent +import re +from rtorrent.common import bool_to_int, convert_version_tuple_to_str,\ + safe_repr +from rtorrent.err import RTorrentVersionError, MethodError +from rtorrent.compat import xmlrpclib + + +class BasicAuthTransport(xmlrpclib.Transport): + def __init__(self, username=None, password=None): + xmlrpclib.Transport.__init__(self) + self.username = username + self.password = password + + def send_auth(self, h): + if self.username is not None and self.password is not None: + h.putheader('AUTHORIZATION', "Basic %s" % string.replace( + encodestring("%s:%s" % (self.username, self.password)), + "\012", "" + )) + + def single_request(self, host, handler, request_body, verbose=0): + # issue XML-RPC request + + h = self.make_connection(host) + if verbose: + h.set_debuglevel(1) + + try: + self.send_request(h, handler, request_body) + self.send_host(h, host) + self.send_user_agent(h) + self.send_auth(h) + self.send_content(h, request_body) + + response = h.getresponse(buffering=True) + if response.status == 200: + self.verbose = verbose + return self.parse_response(response) + except xmlrpclib.Fault: + raise + except Exception: + self.close() + raise + + #discard any response data and raise exception + #if (response.getheader("content-length", 0)): + # response.read() + raise xmlrpclib.ProtocolError( + host + handler, + response.status, response.reason, + response.msg, + ) + + +def get_varname(rpc_call): + """Transform rpc method into variable name. + + @newfield example: Example + @example: if the name of the rpc method is 'p.get_down_rate', the variable + name will be 'down_rate' + """ + # extract variable name from xmlrpc func name + r = re.search( + "([ptdf]\.|system\.|get\_|is\_|set\_)+([^=]*)", rpc_call, re.I) + if r: + return(r.groups()[-1]) + else: + return(None) + + +def _handle_unavailable_rpc_method(method, rt_obj): + msg = "Method isn't available." + if rt_obj._get_client_version_tuple() < method.min_version: + msg = "This method is only available in " \ + "RTorrent version v{0} or later".format( + convert_version_tuple_to_str(method.min_version)) + + raise MethodError(msg) + + +class DummyClass: + def __init__(self): + pass + + +class Method: + """Represents an individual RPC method""" + + def __init__(self, _class, method_name, + rpc_call, docstring=None, varname=None, **kwargs): + self._class = _class # : Class this method is associated with + self.class_name = _class.__name__ + self.method_name = method_name # : name of public-facing method + self.rpc_call = rpc_call # : name of rpc method + self.docstring = docstring # : docstring for rpc method (optional) + self.varname = varname # : variable for the result of the method call, usually set to self.varname + self.min_version = kwargs.get("min_version", ( + 0, 0, 0)) # : Minimum version of rTorrent required + self.boolean = kwargs.get("boolean", False) # : returns boolean value? + self.post_process_func = kwargs.get( + "post_process_func", None) # : custom post process function + self.aliases = kwargs.get( + "aliases", []) # : aliases for method (optional) + self.required_args = [] + #: Arguments required when calling the method (not utilized) + + self.method_type = self._get_method_type() + + if self.varname is None: + self.varname = get_varname(self.rpc_call) + assert self.varname is not None, "Couldn't get variable name." + + def __repr__(self): + return safe_repr("Method(method_name='{0}', rpc_call='{1}')", + self.method_name, self.rpc_call) + + def _get_method_type(self): + """Determine whether method is a modifier or a retriever""" + if self.method_name[:4] == "set_": return('m') # modifier + else: + return('r') # retriever + + def is_modifier(self): + if self.method_type == 'm': + return(True) + else: + return(False) + + def is_retriever(self): + if self.method_type == 'r': + return(True) + else: + return(False) + + def is_available(self, rt_obj): + if rt_obj._get_client_version_tuple() < self.min_version or \ + self.rpc_call not in rt_obj._get_rpc_methods(): + return(False) + else: + return(True) + + +class Multicall: + def __init__(self, class_obj, **kwargs): + self.class_obj = class_obj + if class_obj.__class__.__name__ == "RTorrent": + self.rt_obj = class_obj + else: + self.rt_obj = class_obj._rt_obj + self.calls = [] + + def add(self, method, *args): + """Add call to multicall + + @param method: L{Method} instance or name of raw RPC method + @type method: Method or str + + @param args: call arguments + """ + # if a raw rpc method was given instead of a Method instance, + # try and find the instance for it. And if all else fails, create a + # dummy Method instance + if isinstance(method, str): + result = find_method(method) + # if result not found + if result == -1: + method = Method(DummyClass, method, method) + else: + method = result + + # ensure method is available before adding + if not method.is_available(self.rt_obj): + _handle_unavailable_rpc_method(method, self.rt_obj) + + self.calls.append((method, args)) + + def list_calls(self): + for c in self.calls: + print(c) + + def call(self): + """Execute added multicall calls + + @return: the results (post-processed), in the order they were added + @rtype: tuple + """ + m = xmlrpclib.MultiCall(self.rt_obj._get_conn()) + for call in self.calls: + method, args = call + rpc_call = getattr(method, "rpc_call") + getattr(m, rpc_call)(*args) + + results = m() + results = tuple(results) + results_processed = [] + + for r, c in zip(results, self.calls): + method = c[0] # Method instance + result = process_result(method, r) + results_processed.append(result) + # assign result to class_obj + setattr(self.class_obj, method.varname, result) + + return(tuple(results_processed)) + + +def call_method(class_obj, method, *args): + """Handles single RPC calls + + @param class_obj: Peer/File/Torrent/Tracker/RTorrent instance + @type class_obj: object + + @param method: L{Method} instance or name of raw RPC method + @type method: Method or str + """ + if method.is_retriever(): + args = args[:-1] + else: + assert args[-1] is not None, "No argument given." + + if class_obj.__class__.__name__ == "RTorrent": + rt_obj = class_obj + else: + rt_obj = class_obj._rt_obj + + # check if rpc method is even available + if not method.is_available(rt_obj): + _handle_unavailable_rpc_method(method, rt_obj) + + m = Multicall(class_obj) + m.add(method, *args) + # only added one method, only getting one result back + ret_value = m.call()[0] + + ####### OBSOLETE ########################################################## + # if method.is_retriever(): + # #value = process_result(method, ret_value) + # value = ret_value #MultiCall already processed the result + # else: + # # we're setting the user's input to method.varname + # # but we'll return the value that xmlrpc gives us + # value = process_result(method, args[-1]) + ########################################################################## + + return(ret_value) + + +def find_method(rpc_call): + """Return L{Method} instance associated with given RPC call""" + method_lists = [ + rtorrent.methods, + rtorrent.file.methods, + rtorrent.tracker.methods, + rtorrent.peer.methods, + rtorrent.torrent.methods, + ] + + for l in method_lists: + for m in l: + if m.rpc_call.lower() == rpc_call.lower(): + return(m) + + return(-1) + + +def process_result(method, result): + """Process given C{B{result}} based on flags set in C{B{method}} + + @param method: L{Method} instance + @type method: Method + + @param result: result to be processed (the result of given L{Method} instance) + + @note: Supported Processing: + - boolean - convert ones and zeros returned by rTorrent and + convert to python boolean values + """ + # handle custom post processing function + if method.post_process_func is not None: + result = method.post_process_func(result) + + # is boolean? + if method.boolean: + if result in [1, '1']: + result = True + elif result in [0, '0']: + result = False + + return(result) + + +def _build_rpc_methods(class_, method_list): + """Build glorified aliases to raw RPC methods""" + for m in method_list: + class_name = m.class_name + if class_name != class_.__name__: + continue + + if class_name == "RTorrent": + caller = lambda self, arg = None, method = m:\ + call_method(self, method, bool_to_int(arg)) + elif class_name == "Torrent": + caller = lambda self, arg = None, method = m:\ + call_method(self, method, self.rpc_id, + bool_to_int(arg)) + elif class_name in ["Tracker", "File"]: + caller = lambda self, arg = None, method = m:\ + call_method(self, method, self.rpc_id, + bool_to_int(arg)) + + elif class_name == "Peer": + caller = lambda self, arg = None, method = m:\ + call_method(self, method, self.rpc_id, + bool_to_int(arg)) + + if m.docstring is None: + m.docstring = "" + + # print(m) + docstring = """{0} + + @note: Variable where the result for this method is stored: {1}.{2}""".format( + m.docstring, + class_name, + m.varname) + + caller.__doc__ = docstring + + for method_name in [m.method_name] + list(m.aliases): + setattr(class_, method_name, caller) diff --git a/libs/rtorrent/torrent.py b/libs/rtorrent/torrent.py new file mode 100755 index 00000000..1e06e1c2 --- /dev/null +++ b/libs/rtorrent/torrent.py @@ -0,0 +1,484 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import rtorrent.rpc +# from rtorrent.rpc import Method +import rtorrent.peer +import rtorrent.tracker +import rtorrent.file +import rtorrent.compat + +from rtorrent.common import safe_repr + +Peer = rtorrent.peer.Peer +Tracker = rtorrent.tracker.Tracker +File = rtorrent.file.File +Method = rtorrent.rpc.Method + + +class Torrent: + """Represents an individual torrent within a L{RTorrent} instance.""" + + def __init__(self, _rt_obj, info_hash, **kwargs): + self._rt_obj = _rt_obj + self.info_hash = info_hash # : info hash for the torrent + self.rpc_id = self.info_hash # : unique id to pass to rTorrent + for k in kwargs.keys(): + setattr(self, k, kwargs.get(k, None)) + + self.peers = [] + self.trackers = [] + self.files = [] + + self._call_custom_methods() + + def __repr__(self): + return safe_repr("Torrent(info_hash=\"{0}\" name=\"{1}\")", + self.info_hash, self.name) + + def _call_custom_methods(self): + """only calls methods that check instance variables.""" + self._is_hash_checking_queued() + self._is_started() + self._is_paused() + + def get_peers(self): + """Get list of Peer instances for given torrent. + + @return: L{Peer} instances + @rtype: list + + @note: also assigns return value to self.peers + """ + self.peers = [] + retriever_methods = [m for m in rtorrent.peer.methods + if m.is_retriever() and m.is_available(self._rt_obj)] + # need to leave 2nd arg empty (dunno why) + m = rtorrent.rpc.Multicall(self) + m.add("p.multicall", self.info_hash, "", + *[method.rpc_call + "=" for method in retriever_methods]) + + results = m.call()[0] # only sent one call, only need first result + + for result in results: + results_dict = {} + # build results_dict + for m, r in zip(retriever_methods, result): + results_dict[m.varname] = rtorrent.rpc.process_result(m, r) + + self.peers.append(Peer( + self._rt_obj, self.info_hash, **results_dict)) + + return(self.peers) + + def get_trackers(self): + """Get list of Tracker instances for given torrent. + + @return: L{Tracker} instances + @rtype: list + + @note: also assigns return value to self.trackers + """ + self.trackers = [] + retriever_methods = [m for m in rtorrent.tracker.methods + if m.is_retriever() and m.is_available(self._rt_obj)] + + # need to leave 2nd arg empty (dunno why) + m = rtorrent.rpc.Multicall(self) + m.add("t.multicall", self.info_hash, "", + *[method.rpc_call + "=" for method in retriever_methods]) + + results = m.call()[0] # only sent one call, only need first result + + for result in results: + results_dict = {} + # build results_dict + for m, r in zip(retriever_methods, result): + results_dict[m.varname] = rtorrent.rpc.process_result(m, r) + + self.trackers.append(Tracker( + self._rt_obj, self.info_hash, **results_dict)) + + return(self.trackers) + + def get_files(self): + """Get list of File instances for given torrent. + + @return: L{File} instances + @rtype: list + + @note: also assigns return value to self.files + """ + + self.files = [] + retriever_methods = [m for m in rtorrent.file.methods + if m.is_retriever() and m.is_available(self._rt_obj)] + # 2nd arg can be anything, but it'll return all files in torrent + # regardless + m = rtorrent.rpc.Multicall(self) + m.add("f.multicall", self.info_hash, "", + *[method.rpc_call + "=" for method in retriever_methods]) + + results = m.call()[0] # only sent one call, only need first result + + offset_method_index = retriever_methods.index( + rtorrent.rpc.find_method("f.get_offset")) + + # make a list of the offsets of all the files, sort appropriately + offset_list = sorted([r[offset_method_index] for r in results]) + + for result in results: + results_dict = {} + # build results_dict + for m, r in zip(retriever_methods, result): + results_dict[m.varname] = rtorrent.rpc.process_result(m, r) + + # get proper index positions for each file (based on the file + # offset) + f_index = offset_list.index(results_dict["offset"]) + + self.files.append(File(self._rt_obj, self.info_hash, + f_index, **results_dict)) + + return(self.files) + + def set_directory(self, d): + """Modify download directory + + @note: Needs to stop torrent in order to change the directory. + Also doesn't restart after directory is set, that must be called + separately. + """ + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, "d.try_stop") + self.multicall_add(m, "d.set_directory", d) + + self.directory = m.call()[-1] + + def start(self): + """Start the torrent""" + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, "d.try_start") + self.multicall_add(m, "d.is_active") + + self.active = m.call()[-1] + return(self.active) + + def stop(self): + """"Stop the torrent""" + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, "d.try_stop") + self.multicall_add(m, "d.is_active") + + self.active = m.call()[-1] + return(self.active) + + def close(self): + """Close the torrent and it's files""" + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, "d.close") + + return(m.call()[-1]) + + def erase(self): + """Delete the torrent + + @note: doesn't delete the downloaded files""" + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, "d.erase") + + return(m.call()[-1]) + + def check_hash(self): + """(Re)hash check the torrent""" + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, "d.check_hash") + + return(m.call()[-1]) + + def poll(self): + """poll rTorrent to get latest peer/tracker/file information""" + self.get_peers() + self.get_trackers() + self.get_files() + + def update(self): + """Refresh torrent data + + @note: All fields are stored as attributes to self. + + @return: None + """ + multicall = rtorrent.rpc.Multicall(self) + retriever_methods = [m for m in methods + if m.is_retriever() and m.is_available(self._rt_obj)] + for method in retriever_methods: + multicall.add(method, self.rpc_id) + + multicall.call() + + # custom functions (only call private methods, since they only check + # local variables and are therefore faster) + self._call_custom_methods() + + def accept_seeders(self, accept_seeds): + """Enable/disable whether the torrent connects to seeders + + @param accept_seeds: enable/disable accepting seeders + @type accept_seeds: bool""" + if accept_seeds: + call = "d.accepting_seeders.enable" + else: + call = "d.accepting_seeders.disable" + + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, call) + + return(m.call()[-1]) + + def announce(self): + """Announce torrent info to tracker(s)""" + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, "d.tracker_announce") + + return(m.call()[-1]) + + @staticmethod + def _assert_custom_key_valid(key): + assert type(key) == int and key > 0 and key < 6, \ + "key must be an integer between 1-5" + + def get_custom(self, key): + """ + Get custom value + + @param key: the index for the custom field (between 1-5) + @type key: int + + @rtype: str + """ + + self._assert_custom_key_valid(key) + m = rtorrent.rpc.Multicall(self) + + field = "custom{0}".format(key) + self.multicall_add(m, "d.get_{0}".format(field)) + setattr(self, field, m.call()[-1]) + + return (getattr(self, field)) + + def set_custom(self, key, value): + """ + Set custom value + + @param key: the index for the custom field (between 1-5) + @type key: int + + @param value: the value to be stored + @type value: str + + @return: if successful, value will be returned + @rtype: str + """ + + self._assert_custom_key_valid(key) + m = rtorrent.rpc.Multicall(self) + + self.multicall_add(m, "d.set_custom{0}".format(key), value) + + return(m.call()[-1]) + + ############################################################################ + # CUSTOM METHODS (Not part of the official rTorrent API) + ########################################################################## + def _is_hash_checking_queued(self): + """Only checks instance variables, shouldn't be called directly""" + # if hashing == 3, then torrent is marked for hash checking + # if hash_checking == False, then torrent is waiting to be checked + self.hash_checking_queued = (self.hashing == 3 and + self.hash_checking is False) + + return(self.hash_checking_queued) + + def is_hash_checking_queued(self): + """Check if torrent is waiting to be hash checked + + @note: Variable where the result for this method is stored Torrent.hash_checking_queued""" + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, "d.get_hashing") + self.multicall_add(m, "d.is_hash_checking") + results = m.call() + + setattr(self, "hashing", results[0]) + setattr(self, "hash_checking", results[1]) + + return(self._is_hash_checking_queued()) + + def _is_paused(self): + """Only checks instance variables, shouldn't be called directly""" + self.paused = (self.state == 0) + return(self.paused) + + def is_paused(self): + """Check if torrent is paused + + @note: Variable where the result for this method is stored: Torrent.paused""" + self.get_state() + return(self._is_paused()) + + def _is_started(self): + """Only checks instance variables, shouldn't be called directly""" + self.started = (self.state == 1) + return(self.started) + + def is_started(self): + """Check if torrent is started + + @note: Variable where the result for this method is stored: Torrent.started""" + self.get_state() + return(self._is_started()) + + +methods = [ + # RETRIEVERS + Method(Torrent, 'is_hash_checked', 'd.is_hash_checked', + boolean=True, + ), + Method(Torrent, 'is_hash_checking', 'd.is_hash_checking', + boolean=True, + ), + Method(Torrent, 'get_peers_max', 'd.get_peers_max'), + Method(Torrent, 'get_tracker_focus', 'd.get_tracker_focus'), + Method(Torrent, 'get_skip_total', 'd.get_skip_total'), + Method(Torrent, 'get_state', 'd.get_state'), + Method(Torrent, 'get_peer_exchange', 'd.get_peer_exchange'), + Method(Torrent, 'get_down_rate', 'd.get_down_rate'), + Method(Torrent, 'get_connection_seed', 'd.get_connection_seed'), + Method(Torrent, 'get_uploads_max', 'd.get_uploads_max'), + Method(Torrent, 'get_priority_str', 'd.get_priority_str'), + Method(Torrent, 'is_open', 'd.is_open', + boolean=True, + ), + Method(Torrent, 'get_peers_min', 'd.get_peers_min'), + Method(Torrent, 'get_peers_complete', 'd.get_peers_complete'), + Method(Torrent, 'get_tracker_numwant', 'd.get_tracker_numwant'), + Method(Torrent, 'get_connection_current', 'd.get_connection_current'), + Method(Torrent, 'is_complete', 'd.get_complete', + boolean=True, + ), + Method(Torrent, 'get_peers_connected', 'd.get_peers_connected'), + Method(Torrent, 'get_chunk_size', 'd.get_chunk_size'), + Method(Torrent, 'get_state_counter', 'd.get_state_counter'), + Method(Torrent, 'get_base_filename', 'd.get_base_filename'), + Method(Torrent, 'get_state_changed', 'd.get_state_changed'), + Method(Torrent, 'get_peers_not_connected', 'd.get_peers_not_connected'), + Method(Torrent, 'get_directory', 'd.get_directory'), + Method(Torrent, 'is_incomplete', 'd.incomplete', + boolean=True, + ), + Method(Torrent, 'get_tracker_size', 'd.get_tracker_size'), + Method(Torrent, 'is_multi_file', 'd.is_multi_file', + boolean=True, + ), + Method(Torrent, 'get_local_id', 'd.get_local_id'), + Method(Torrent, 'get_ratio', 'd.get_ratio', + post_process_func=lambda x: x / 1000.0, + ), + Method(Torrent, 'get_loaded_file', 'd.get_loaded_file'), + Method(Torrent, 'get_max_file_size', 'd.get_max_file_size'), + Method(Torrent, 'get_size_chunks', 'd.get_size_chunks'), + Method(Torrent, 'is_pex_active', 'd.is_pex_active', + boolean=True, + ), + Method(Torrent, 'get_hashing', 'd.get_hashing'), + Method(Torrent, 'get_bitfield', 'd.get_bitfield'), + Method(Torrent, 'get_local_id_html', 'd.get_local_id_html'), + Method(Torrent, 'get_connection_leech', 'd.get_connection_leech'), + Method(Torrent, 'get_peers_accounted', 'd.get_peers_accounted'), + Method(Torrent, 'get_message', 'd.get_message'), + Method(Torrent, 'is_active', 'd.is_active', + boolean=True, + ), + Method(Torrent, 'get_size_bytes', 'd.get_size_bytes'), + Method(Torrent, 'get_ignore_commands', 'd.get_ignore_commands'), + Method(Torrent, 'get_creation_date', 'd.get_creation_date'), + Method(Torrent, 'get_base_path', 'd.get_base_path'), + Method(Torrent, 'get_left_bytes', 'd.get_left_bytes'), + Method(Torrent, 'get_size_files', 'd.get_size_files'), + Method(Torrent, 'get_size_pex', 'd.get_size_pex'), + Method(Torrent, 'is_private', 'd.is_private', + boolean=True, + ), + Method(Torrent, 'get_max_size_pex', 'd.get_max_size_pex'), + Method(Torrent, 'get_num_chunks_hashed', 'd.get_chunks_hashed', + aliases=("get_chunks_hashed",)), + Method(Torrent, 'get_num_chunks_wanted', 'd.wanted_chunks'), + Method(Torrent, 'get_priority', 'd.get_priority'), + Method(Torrent, 'get_skip_rate', 'd.get_skip_rate'), + Method(Torrent, 'get_completed_bytes', 'd.get_completed_bytes'), + Method(Torrent, 'get_name', 'd.get_name'), + Method(Torrent, 'get_completed_chunks', 'd.get_completed_chunks'), + Method(Torrent, 'get_throttle_name', 'd.get_throttle_name'), + Method(Torrent, 'get_free_diskspace', 'd.get_free_diskspace'), + Method(Torrent, 'get_directory_base', 'd.get_directory_base'), + Method(Torrent, 'get_hashing_failed', 'd.get_hashing_failed'), + Method(Torrent, 'get_tied_to_file', 'd.get_tied_to_file'), + Method(Torrent, 'get_down_total', 'd.get_down_total'), + Method(Torrent, 'get_bytes_done', 'd.get_bytes_done'), + Method(Torrent, 'get_up_rate', 'd.get_up_rate'), + Method(Torrent, 'get_up_total', 'd.get_up_total'), + Method(Torrent, 'is_accepting_seeders', 'd.accepting_seeders', + boolean=True, + ), + Method(Torrent, "get_chunks_seen", "d.chunks_seen", + min_version=(0, 9, 1), + ), + Method(Torrent, "is_partially_done", "d.is_partially_done", + boolean=True, + ), + Method(Torrent, "is_not_partially_done", "d.is_not_partially_done", + boolean=True, + ), + Method(Torrent, "get_time_started", "d.timestamp.started"), + Method(Torrent, "get_custom1", "d.get_custom1"), + Method(Torrent, "get_custom2", "d.get_custom2"), + Method(Torrent, "get_custom3", "d.get_custom3"), + Method(Torrent, "get_custom4", "d.get_custom4"), + Method(Torrent, "get_custom5", "d.get_custom5"), + + # MODIFIERS + Method(Torrent, 'set_uploads_max', 'd.set_uploads_max'), + Method(Torrent, 'set_tied_to_file', 'd.set_tied_to_file'), + Method(Torrent, 'set_tracker_numwant', 'd.set_tracker_numwant'), + Method(Torrent, 'set_priority', 'd.set_priority'), + Method(Torrent, 'set_peers_max', 'd.set_peers_max'), + Method(Torrent, 'set_hashing_failed', 'd.set_hashing_failed'), + Method(Torrent, 'set_message', 'd.set_message'), + Method(Torrent, 'set_throttle_name', 'd.set_throttle_name'), + Method(Torrent, 'set_peers_min', 'd.set_peers_min'), + Method(Torrent, 'set_ignore_commands', 'd.set_ignore_commands'), + Method(Torrent, 'set_max_file_size', 'd.set_max_file_size'), + Method(Torrent, 'set_custom5', 'd.set_custom5'), + Method(Torrent, 'set_custom4', 'd.set_custom4'), + Method(Torrent, 'set_custom2', 'd.set_custom2'), + Method(Torrent, 'set_custom1', 'd.set_custom1'), + Method(Torrent, 'set_custom3', 'd.set_custom3'), + Method(Torrent, 'set_connection_current', 'd.set_connection_current'), +] diff --git a/libs/rtorrent/tracker.py b/libs/rtorrent/tracker.py new file mode 100755 index 00000000..81af2e49 --- /dev/null +++ b/libs/rtorrent/tracker.py @@ -0,0 +1,138 @@ +# Copyright (c) 2013 Chris Lucas, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# from rtorrent.rpc import Method +import rtorrent.rpc + +from rtorrent.common import safe_repr + +Method = rtorrent.rpc.Method + + +class Tracker: + """Represents an individual tracker within a L{Torrent} instance.""" + + def __init__(self, _rt_obj, info_hash, **kwargs): + self._rt_obj = _rt_obj + self.info_hash = info_hash # : info hash for the torrent using this tracker + for k in kwargs.keys(): + setattr(self, k, kwargs.get(k, None)) + + # for clarity's sake... + self.index = self.group # : position of tracker within the torrent's tracker list + self.rpc_id = "{0}:t{1}".format( + self.info_hash, self.index) # : unique id to pass to rTorrent + + def __repr__(self): + return safe_repr("Tracker(index={0}, url=\"{1}\")", + self.index, self.url) + + def enable(self): + """Alias for set_enabled("yes")""" + self.set_enabled("yes") + + def disable(self): + """Alias for set_enabled("no")""" + self.set_enabled("no") + + def update(self): + """Refresh tracker data + + @note: All fields are stored as attributes to self. + + @return: None + """ + multicall = rtorrent.rpc.Multicall(self) + retriever_methods = [m for m in methods + if m.is_retriever() and m.is_available(self._rt_obj)] + for method in retriever_methods: + multicall.add(method, self.rpc_id) + + multicall.call() + +methods = [ + # RETRIEVERS + Method(Tracker, 'is_enabled', 't.is_enabled', boolean=True), + Method(Tracker, 'get_id', 't.get_id'), + Method(Tracker, 'get_scrape_incomplete', 't.get_scrape_incomplete'), + Method(Tracker, 'is_open', 't.is_open', boolean=True), + Method(Tracker, 'get_min_interval', 't.get_min_interval'), + Method(Tracker, 'get_scrape_downloaded', 't.get_scrape_downloaded'), + Method(Tracker, 'get_group', 't.get_group'), + Method(Tracker, 'get_scrape_time_last', 't.get_scrape_time_last'), + Method(Tracker, 'get_type', 't.get_type'), + Method(Tracker, 'get_normal_interval', 't.get_normal_interval'), + Method(Tracker, 'get_url', 't.get_url'), + Method(Tracker, 'get_scrape_complete', 't.get_scrape_complete', + min_version=(0, 8, 9), + ), + Method(Tracker, 'get_activity_time_last', 't.activity_time_last', + min_version=(0, 8, 9), + ), + Method(Tracker, 'get_activity_time_next', 't.activity_time_next', + min_version=(0, 8, 9), + ), + Method(Tracker, 'get_failed_time_last', 't.failed_time_last', + min_version=(0, 8, 9), + ), + Method(Tracker, 'get_failed_time_next', 't.failed_time_next', + min_version=(0, 8, 9), + ), + Method(Tracker, 'get_success_time_last', 't.success_time_last', + min_version=(0, 8, 9), + ), + Method(Tracker, 'get_success_time_next', 't.success_time_next', + min_version=(0, 8, 9), + ), + Method(Tracker, 'can_scrape', 't.can_scrape', + min_version=(0, 9, 1), + boolean=True + ), + Method(Tracker, 'get_failed_counter', 't.failed_counter', + min_version=(0, 8, 9) + ), + Method(Tracker, 'get_scrape_counter', 't.scrape_counter', + min_version=(0, 8, 9) + ), + Method(Tracker, 'get_success_counter', 't.success_counter', + min_version=(0, 8, 9) + ), + Method(Tracker, 'is_usable', 't.is_usable', + min_version=(0, 9, 1), + boolean=True + ), + Method(Tracker, 'is_busy', 't.is_busy', + min_version=(0, 9, 1), + boolean=True + ), + Method(Tracker, 'is_extra_tracker', 't.is_extra_tracker', + min_version=(0, 9, 1), + boolean=True, + ), + Method(Tracker, "get_latest_sum_peers", "t.latest_sum_peers", + min_version=(0, 9, 0) + ), + Method(Tracker, "get_latest_new_peers", "t.latest_new_peers", + min_version=(0, 9, 0) + ), + + # MODIFIERS + Method(Tracker, 'set_enabled', 't.set_enabled'), +] From d851be41d3fb6065e499a6ad3ddfd5e88a4a5275 Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Sat, 27 Jul 2013 21:06:58 +1200 Subject: [PATCH 14/58] Updated rtorrent-python library. --- libs/rtorrent/rpc/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/rtorrent/rpc/__init__.py b/libs/rtorrent/rpc/__init__.py index 8190de46..f83446ca 100755 --- a/libs/rtorrent/rpc/__init__.py +++ b/libs/rtorrent/rpc/__init__.py @@ -67,8 +67,8 @@ class BasicAuthTransport(xmlrpclib.Transport): raise #discard any response data and raise exception - #if (response.getheader("content-length", 0)): - # response.read() + if (response.getheader("content-length", 0)): + response.read() raise xmlrpclib.ProtocolError( host + handler, response.status, response.reason, From bf6265353146fa36468ded856b5e8c11b67305a0 Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Sun, 28 Jul 2013 00:36:10 +1200 Subject: [PATCH 15/58] Added missing 'folder' parameter on the rtorrent downloader to fix moving/linking issues. --- couchpotato/core/downloaders/rtorrent/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py index 5da64cb7..a06ebe5e 100755 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -87,11 +87,11 @@ class rTorrent(Downloader): 'original_status': item.state, 'timeleft': str(timedelta(seconds=float(item.left_bytes) / item.down_rate)) if item.down_rate > 0 else -1, - 'folder': '' + 'folder': item.directory }) return statuses except Exception, err: - log.error('Failed to send torrent to rTorrent: %s', err) + log.error('Failed to get status from rTorrent: %s', err) return False From 38e204dfe837457303e84e7831e0e6861d5901d7 Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Sun, 28 Jul 2013 01:27:39 +1200 Subject: [PATCH 16/58] Added support for labels on the rtorrent downloader. --- .../core/downloaders/rtorrent/__init__.py | 2 +- couchpotato/core/downloaders/rtorrent/main.py | 24 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/couchpotato/core/downloaders/rtorrent/__init__.py b/couchpotato/core/downloaders/rtorrent/__init__.py index d0047893..f3944c7e 100755 --- a/couchpotato/core/downloaders/rtorrent/__init__.py +++ b/couchpotato/core/downloaders/rtorrent/__init__.py @@ -33,7 +33,7 @@ config = [{ }, { 'name': 'label', - 'description': 'Label to add torrent as.', + 'description': 'Label to apply on added torrents.', }, { 'name': 'paused', diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py index a06ebe5e..8bd86f9e 100755 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -25,11 +25,14 @@ class rTorrent(Downloader): rtorrent_api = None def get_conn(self): - return RTorrent( - self.conf('url'), - self.conf('username'), - self.conf('password') - ) + if self.conf('username') and self.conf('password'): + return RTorrent( + self.conf('url'), + self.conf('username'), + self.conf('password') + ) + + return RTorrent(self.conf('url')) def download(self, data, movie, filedata=None): log.debug('Sending "%s" (%s) to rTorrent.', (data.get('name'), data.get('type'))) @@ -59,7 +62,16 @@ class rTorrent(Downloader): if not self.rtorrent_api: self.rtorrent_api = self.get_conn() - torrent = self.rtorrent_api.load_torrent(filedata, not self.conf('paused', default=0)) + # Send torrent to rTorrent + torrent = self.rtorrent_api.load_torrent(filedata) + + # Set label + if self.conf('label'): + torrent.set_custom(1, self.conf('label')) + + # Start torrent + if not self.conf('paused', default=0): + torrent.start() return self.downloadReturnId(torrent_hash) except Exception, err: From 0fadbd52a31216e2e5b69677a64e993f4e32f2d3 Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Mon, 29 Jul 2013 20:49:34 +1200 Subject: [PATCH 17/58] Cleaned up imports and added support for downloading magnet torrents via sources. --- couchpotato/core/downloaders/rtorrent/main.py | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py index 8bd86f9e..7544af89 100755 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -1,18 +1,11 @@ from base64 import b16encode, b32decode -from bencode import bencode, bdecode -from couchpotato.core.downloaders.base import Downloader, StatusList -from couchpotato.core.helpers.encoding import isInt, ss -from couchpotato.core.logger import CPLog from datetime import timedelta from hashlib import sha1 -from multipartpost import MultipartPostHandler -import cookielib -import httplib -import json -import re -import time -import urllib -import urllib2 +import traceback + +from bencode import bencode, bdecode +from couchpotato.core.downloaders.base import Downloader, StatusList +from couchpotato.core.logger import CPLog from rtorrent import RTorrent @@ -45,13 +38,17 @@ class rTorrent(Downloader): log.error('Failed sending torrent, no data') return False + # Try download magnet torrents if data.get('type') == 'torrent_magnet': - log.info('magnet torrents are not supported') - return False + filedata = self.magnetToTorrent(data.get('url')) + + if filedata is False: + return False + + data['type'] = 'torrent' info = bdecode(filedata)["info"] torrent_hash = sha1(bencode(info)).hexdigest().upper() - torrent_filename = self.createFileName(data, filedata, movie) # Convert base 32 to hex if len(torrent_hash) == 32: From 7c680cac10b98861427407bee7af7c612113afe8 Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Wed, 31 Jul 2013 22:41:20 +1200 Subject: [PATCH 18/58] Updated rTorrent downloader to set ratio stop action, added new seeding methods and updated the rTorrent library --- .../core/downloaders/rtorrent/__init__.py | 24 ++++ couchpotato/core/downloaders/rtorrent/main.py | 109 +++++++++++++++--- libs/rtorrent/__init__.py | 21 ++++ libs/rtorrent/group.py | 88 ++++++++++++++ libs/rtorrent/rpc/__init__.py | 19 ++- libs/rtorrent/torrent.py | 22 ++++ 6 files changed, 266 insertions(+), 17 deletions(-) create mode 100755 libs/rtorrent/group.py diff --git a/couchpotato/core/downloaders/rtorrent/__init__.py b/couchpotato/core/downloaders/rtorrent/__init__.py index f3944c7e..b28f5808 100755 --- a/couchpotato/core/downloaders/rtorrent/__init__.py +++ b/couchpotato/core/downloaders/rtorrent/__init__.py @@ -35,6 +35,30 @@ config = [{ 'name': 'label', 'description': 'Label to apply on added torrents.', }, + { + 'name': 'stop_complete', + 'label': 'Stop torrent', + 'default': False, + 'advanced': True, + 'type': 'bool', + 'description': 'Stop the torrent after it finishes seeding' + }, + { + 'name': 'remove_complete', + 'label': 'Remove torrent', + 'default': False, + 'advanced': True, + 'type': 'bool', + 'description': 'Remove the torrent after it finishes seeding.', + }, + { + 'name': 'delete_files', + 'label': 'Remove files', + 'default': True, + 'type': 'bool', + 'advanced': True, + 'description': 'Also remove the leftover files.', + }, { 'name': 'paused', 'type': 'bool', diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py index 7544af89..33243fea 100755 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -15,21 +15,64 @@ log = CPLog(__name__) class rTorrent(Downloader): type = ['torrent', 'torrent_magnet'] - rtorrent_api = None + rt = None + + def connect(self): + # Already connected? + if self.rt is not None: + return self.rt + + # Ensure url is set + if not self.conf('url'): + log.error('Config properties are not filled in correctly, url is missing.') + return False - def get_conn(self): if self.conf('username') and self.conf('password'): - return RTorrent( + self.rt = RTorrent( self.conf('url'), self.conf('username'), self.conf('password') ) + else: + self.rt = RTorrent(self.conf('url')) + + return self.rt + + def _update_provider_group(self, name, data): + if data.get('seed_time') is not None: + log.info('seeding time ignored, not supported') + + if name is None or data.get('seed_ratio') is None: + return False + + if not self.connect(): + return False + + views = self.rt.get_views() + + if name not in views: + self.rt.create_group(name) + + log.debug('Updating provider ratio to %s, group name: %s', (data.get('seed_ratio'), name)) + + group = self.rt.get_group(name) + group.get_min(data.get('seed_ratio') * 100) + + if self.conf('stop_complete'): + group.set_command('d.stop') + else: + group.set_command() - return RTorrent(self.conf('url')) def download(self, data, movie, filedata=None): log.debug('Sending "%s" (%s) to rTorrent.', (data.get('name'), data.get('type'))) + if not self.connect(): + return False + + group_name = 'cp_' + data.get('provider').lower() + self._update_provider_group(group_name, data) + torrent_params = {} if self.conf('label'): torrent_params['label'] = self.conf('label') @@ -56,16 +99,16 @@ class rTorrent(Downloader): # Send request to rTorrent try: - if not self.rtorrent_api: - self.rtorrent_api = self.get_conn() - # Send torrent to rTorrent - torrent = self.rtorrent_api.load_torrent(filedata) + torrent = self.rt.load_torrent(filedata) # Set label if self.conf('label'): torrent.set_custom(1, self.conf('label')) + # Set Ratio Group + torrent.set_visible(group_name) + # Start torrent if not self.conf('paused', default=0): torrent.start() @@ -75,24 +118,30 @@ class rTorrent(Downloader): log.error('Failed to send torrent to rTorrent: %s', err) return False - def getAllDownloadStatus(self): - log.debug('Checking rTorrent download status.') - try: - if not self.rtorrent_api: - self.rtorrent_api = self.get_conn() + if not self.connect(): + return False - torrents = self.rtorrent_api.get_torrents() + try: + torrents = self.rt.get_torrents() statuses = StatusList(self) for item in torrents: + status = 'busy' + if item.complete: + if item.active: + status = 'seeding' + else: + status = 'completed' + statuses.append({ 'id': item.info_hash, 'name': item.name, - 'status': 'completed' if item.complete else 'busy', + 'status': status, + 'seed_ratio': item.ratio, 'original_status': item.state, 'timeleft': str(timedelta(seconds=float(item.left_bytes) / item.down_rate)) if item.down_rate > 0 else -1, @@ -104,3 +153,33 @@ class rTorrent(Downloader): except Exception, err: log.error('Failed to get status from rTorrent: %s', err) return False + + def pause(self, download_info, pause = True): + if not self.connect(): + return False + + torrent = self.rt.find_torrent(download_info['id']) + if torrent is None: + return False + + if pause: + return torrent.pause() + return torrent.resume() + + def removeFailed(self, item): + log.info('%s failed downloading, deleting...', item['name']) + return self.processComplete(item, delete_files=True) + + def processComplete(self, item, delete_files): + log.debug('Requesting rTorrent to remove the torrent %s%s.', (item['name'], ' and cleanup the downloaded files' if delete_files else '')) + if not self.connect(): + return False + + torrent = self.rt.find_torrent(item['id']) + if torrent is None: + return False + + if delete_files: + log.info('not deleting files, not supported') + + return torrent.erase() # just removes the torrent, doesn't delete data diff --git a/libs/rtorrent/__init__.py b/libs/rtorrent/__init__.py index e427b65e..d19c78b4 100755 --- a/libs/rtorrent/__init__.py +++ b/libs/rtorrent/__init__.py @@ -24,6 +24,7 @@ from rtorrent.lib.torrentparser import TorrentParser from rtorrent.lib.xmlrpc.http import HTTPServerProxy from rtorrent.rpc import Method, BasicAuthTransport from rtorrent.torrent import Torrent +from rtorrent.group import Group import os.path import rtorrent.rpc # @UnresolvedImport import time @@ -286,6 +287,26 @@ class RTorrent: getattr(p, func_name)(finput) + def get_views(self): + p = self._get_conn() + return p.view_list() + + def create_group(self, name, persistent=True, view=None): + p = self._get_conn() + + if persistent is True: + p.group.insert_persistent_view('', name) + else: + assert view is not None, "view parameter required on non-persistent groups" + p.group.insert('', name, view) + + def get_group(self, name): + assert name is not None, "group name required" + + group = Group(self, name) + group.update() + return group + def set_dht_port(self, port): """Set DHT port diff --git a/libs/rtorrent/group.py b/libs/rtorrent/group.py new file mode 100755 index 00000000..01f6bb3c --- /dev/null +++ b/libs/rtorrent/group.py @@ -0,0 +1,88 @@ +# Copyright (c) 2013 Dean Gardiner, +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +import rtorrent.rpc + +Method = rtorrent.rpc.Method + + +class Group: + __name__ = 'Group' + + def __init__(self, _rt_obj, name): + self._rt_obj = _rt_obj + self.name = name + + self.methods = [ + # RETRIEVERS + Method(Group, 'get_max', 'group.' + self.name + '.ratio.max', varname='max'), + Method(Group, 'get_min', 'group.' + self.name + '.ratio.min', varname='min'), + Method(Group, 'get_upload', 'group.' + self.name + '.ratio.upload', varname='upload'), + + # MODIFIERS + Method(Group, 'set_max', 'group.' + self.name + '.ratio.max.set', varname='max'), + Method(Group, 'set_min', 'group.' + self.name + '.ratio.min.set', varname='min'), + Method(Group, 'set_upload', 'group.' + self.name + '.ratio.upload.set', varname='upload') + ] + + rtorrent.rpc._build_rpc_methods(self, self.methods) + + # Setup multicall_add method + caller = lambda multicall, method, *args: \ + multicall.add(method, *args) + setattr(self, "multicall_add", caller) + + def _get_prefix(self): + return 'group.' + self.name + '.ratio.' + + def update(self): + multicall = rtorrent.rpc.Multicall(self) + + retriever_methods = [m for m in self.methods + if m.is_retriever() and m.is_available(self._rt_obj)] + + for method in retriever_methods: + multicall.add(method) + + multicall.call() + + def enable(self): + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, self._get_prefix() + 'enable') + + return(m.call()[-1]) + + def disable(self): + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, self._get_prefix() + 'disable') + + return(m.call()[-1]) + + def set_command(self, *methods): + methods = [m + '=' for m in methods] + + m = rtorrent.rpc.Multicall(self) + self.multicall_add( + m, 'system.method.set', + self._get_prefix() + 'command', + *methods + ) + + return(m.call()[-1]) diff --git a/libs/rtorrent/rpc/__init__.py b/libs/rtorrent/rpc/__init__.py index f83446ca..034f4eef 100755 --- a/libs/rtorrent/rpc/__init__.py +++ b/libs/rtorrent/rpc/__init__.py @@ -19,6 +19,7 @@ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. from base64 import encodestring import httplib +import inspect import string import rtorrent @@ -223,7 +224,9 @@ class Multicall: result = process_result(method, r) results_processed.append(result) # assign result to class_obj - setattr(self.class_obj, method.varname, result) + exists = hasattr(self.class_obj, method.varname) + if not exists or not inspect.ismethod(getattr(self.class_obj, method.varname)): + setattr(self.class_obj, method.varname, result) return(tuple(results_processed)) @@ -315,6 +318,11 @@ def process_result(method, result): def _build_rpc_methods(class_, method_list): """Build glorified aliases to raw RPC methods""" + instance = None + if not inspect.isclass(class_): + instance = class_ + class_ = instance.__class__ + for m in method_list: class_name = m.class_name if class_name != class_.__name__: @@ -337,6 +345,10 @@ def _build_rpc_methods(class_, method_list): call_method(self, method, self.rpc_id, bool_to_int(arg)) + elif class_name == "Group": + caller = lambda arg = None, method = m: \ + call_method(instance, method, bool_to_int(arg)) + if m.docstring is None: m.docstring = "" @@ -351,4 +363,7 @@ def _build_rpc_methods(class_, method_list): caller.__doc__ = docstring for method_name in [m.method_name] + list(m.aliases): - setattr(class_, method_name, caller) + if instance is None: + setattr(class_, method_name, caller) + else: + setattr(instance, method_name, caller) diff --git a/libs/rtorrent/torrent.py b/libs/rtorrent/torrent.py index 1e06e1c2..c610e368 100755 --- a/libs/rtorrent/torrent.py +++ b/libs/rtorrent/torrent.py @@ -190,6 +190,20 @@ class Torrent: self.active = m.call()[-1] return(self.active) + def pause(self): + """Pause the torrent""" + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, "d.pause") + + return(m.call()[-1]) + + def resume(self): + """Resume the torrent""" + m = rtorrent.rpc.Multicall(self) + self.multicall_add(m, "d.resume") + + return(m.call()[-1]) + def close(self): """Close the torrent and it's files""" m = rtorrent.rpc.Multicall(self) @@ -305,6 +319,14 @@ class Torrent: return(m.call()[-1]) + def set_visible(self, view, visible=True): + p = self._rt_obj._get_conn() + + if visible: + return p.view.set_visible(self.info_hash, view) + else: + return p.view.set_not_visible(self.info_hash, view) + ############################################################################ # CUSTOM METHODS (Not part of the official rTorrent API) ########################################################################## From 577baeca5938fe8eca0bc0f783f1ab8e4aefc6d1 Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Thu, 1 Aug 2013 00:03:56 +1200 Subject: [PATCH 19/58] Hiding remove files in the rTorrent downloader until it's implemented. --- couchpotato/core/downloaders/rtorrent/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/couchpotato/core/downloaders/rtorrent/__init__.py b/couchpotato/core/downloaders/rtorrent/__init__.py index b28f5808..877ff2d4 100755 --- a/couchpotato/core/downloaders/rtorrent/__init__.py +++ b/couchpotato/core/downloaders/rtorrent/__init__.py @@ -54,7 +54,8 @@ config = [{ { 'name': 'delete_files', 'label': 'Remove files', - 'default': True, + 'default': False, + 'hidden': True, 'type': 'bool', 'advanced': True, 'description': 'Also remove the leftover files.', From 317c3afb7a52026f3f96312d59b0f4c8d531d4f0 Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Thu, 1 Aug 2013 17:02:58 +1200 Subject: [PATCH 20/58] Few minor fixes and implemented delete_files option via shutil.rmtree --- couchpotato/core/downloaders/rtorrent/__init__.py | 3 +-- couchpotato/core/downloaders/rtorrent/main.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/couchpotato/core/downloaders/rtorrent/__init__.py b/couchpotato/core/downloaders/rtorrent/__init__.py index 877ff2d4..b28f5808 100755 --- a/couchpotato/core/downloaders/rtorrent/__init__.py +++ b/couchpotato/core/downloaders/rtorrent/__init__.py @@ -54,8 +54,7 @@ config = [{ { 'name': 'delete_files', 'label': 'Remove files', - 'default': False, - 'hidden': True, + 'default': True, 'type': 'bool', 'advanced': True, 'description': 'Also remove the leftover files.', diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py index 33243fea..e885b20f 100755 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -1,6 +1,7 @@ from base64 import b16encode, b32decode from datetime import timedelta from hashlib import sha1 +import shutil import traceback from bencode import bencode, bdecode @@ -39,10 +40,10 @@ class rTorrent(Downloader): return self.rt def _update_provider_group(self, name, data): - if data.get('seed_time') is not None: + if data.get('seed_time'): log.info('seeding time ignored, not supported') - if name is None or data.get('seed_ratio') is None: + if not name or not data.get('seed_ratio'): return False if not self.connect(): @@ -179,7 +180,9 @@ class rTorrent(Downloader): if torrent is None: return False - if delete_files: - log.info('not deleting files, not supported') + torrent.erase() # just removes the torrent, doesn't delete data - return torrent.erase() # just removes the torrent, doesn't delete data + if delete_files: + shutil.rmtree(item['folder'], True) + + return True From 7202fbf084e1b73fb0b8fe9380121b74c1b47cc1 Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Thu, 1 Aug 2013 18:08:49 +1200 Subject: [PATCH 21/58] Removed stop_complete option, Can instead be disabled by setting seed_ratio to zero on the provider. --- couchpotato/core/downloaders/rtorrent/__init__.py | 8 -------- couchpotato/core/downloaders/rtorrent/main.py | 9 ++++++--- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/couchpotato/core/downloaders/rtorrent/__init__.py b/couchpotato/core/downloaders/rtorrent/__init__.py index b28f5808..db50eba3 100755 --- a/couchpotato/core/downloaders/rtorrent/__init__.py +++ b/couchpotato/core/downloaders/rtorrent/__init__.py @@ -35,14 +35,6 @@ config = [{ 'name': 'label', 'description': 'Label to apply on added torrents.', }, - { - 'name': 'stop_complete', - 'label': 'Stop torrent', - 'default': False, - 'advanced': True, - 'type': 'bool', - 'description': 'Stop the torrent after it finishes seeding' - }, { 'name': 'remove_complete', 'label': 'Remove torrent', diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py index e885b20f..b6d2fbe9 100755 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -43,7 +43,7 @@ class rTorrent(Downloader): if data.get('seed_time'): log.info('seeding time ignored, not supported') - if not name or not data.get('seed_ratio'): + if not name: return False if not self.connect(): @@ -57,13 +57,16 @@ class rTorrent(Downloader): log.debug('Updating provider ratio to %s, group name: %s', (data.get('seed_ratio'), name)) group = self.rt.get_group(name) - group.get_min(data.get('seed_ratio') * 100) - if self.conf('stop_complete'): + if data.get('seed_ratio'): + group.set_min(int(data.get('seed_ratio') * 100)) group.set_command('d.stop') else: + # Reset group action group.set_command() + return True + def download(self, data, movie, filedata=None): log.debug('Sending "%s" (%s) to rTorrent.', (data.get('name'), data.get('type'))) From 0bdffc5036c4dd4fa19dae3c8b936fe255ce7457 Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Fri, 2 Aug 2013 01:40:31 +1200 Subject: [PATCH 22/58] Change to ratio group setup to ensure everything is set correctly. --- couchpotato/core/downloaders/rtorrent/main.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py index b6d2fbe9..d27a3a03 100755 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -59,11 +59,16 @@ class rTorrent(Downloader): group = self.rt.get_group(name) if data.get('seed_ratio'): + # Explicitly set all group options to ensure it is setup correctly + group.set_upload('1M') group.set_min(int(data.get('seed_ratio') * 100)) + group.set_max(int(data.get('seed_ratio') * 100)) group.set_command('d.stop') + group.enable() else: - # Reset group action + # Reset group action and disable it group.set_command() + group.disable() return True From 2bb2e28f91534cde5cc537146f069da50986572d Mon Sep 17 00:00:00 2001 From: Dean Gardiner Date: Sun, 4 Aug 2013 15:26:00 +1200 Subject: [PATCH 23/58] Updated rTorrent library and fixed some issues with ratio setup. --- couchpotato/core/downloaders/rtorrent/main.py | 36 +++++++++++-------- libs/rtorrent/group.py | 12 +++---- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/couchpotato/core/downloaders/rtorrent/main.py b/couchpotato/core/downloaders/rtorrent/main.py index d27a3a03..97ef3e17 100755 --- a/couchpotato/core/downloaders/rtorrent/main.py +++ b/couchpotato/core/downloaders/rtorrent/main.py @@ -2,7 +2,7 @@ from base64 import b16encode, b32decode from datetime import timedelta from hashlib import sha1 import shutil -import traceback +from rtorrent.err import MethodError from bencode import bencode, bdecode from couchpotato.core.downloaders.base import Downloader, StatusList @@ -54,21 +54,26 @@ class rTorrent(Downloader): if name not in views: self.rt.create_group(name) - log.debug('Updating provider ratio to %s, group name: %s', (data.get('seed_ratio'), name)) - group = self.rt.get_group(name) - if data.get('seed_ratio'): - # Explicitly set all group options to ensure it is setup correctly - group.set_upload('1M') - group.set_min(int(data.get('seed_ratio') * 100)) - group.set_max(int(data.get('seed_ratio') * 100)) - group.set_command('d.stop') - group.enable() - else: - # Reset group action and disable it - group.set_command() - group.disable() + try: + if data.get('seed_ratio'): + ratio = int(float(data.get('seed_ratio')) * 100) + log.debug('Updating provider ratio to %s, group name: %s', (ratio, name)) + + # Explicitly set all group options to ensure it is setup correctly + group.set_upload('1M') + group.set_min(ratio) + group.set_max(ratio) + group.set_command('d.stop') + group.enable() + else: + # Reset group action and disable it + group.set_command() + group.disable() + except MethodError, err: + log.error('Unable to set group options: %s', err.message) + return False return True @@ -80,7 +85,8 @@ class rTorrent(Downloader): return False group_name = 'cp_' + data.get('provider').lower() - self._update_provider_group(group_name, data) + if not self._update_provider_group(group_name, data): + return False torrent_params = {} if self.conf('label'): diff --git a/libs/rtorrent/group.py b/libs/rtorrent/group.py index 01f6bb3c..e8246aa8 100755 --- a/libs/rtorrent/group.py +++ b/libs/rtorrent/group.py @@ -64,16 +64,12 @@ class Group: multicall.call() def enable(self): - m = rtorrent.rpc.Multicall(self) - self.multicall_add(m, self._get_prefix() + 'enable') - - return(m.call()[-1]) + p = self._rt_obj._get_conn() + return getattr(p, self._get_prefix() + 'enable')() def disable(self): - m = rtorrent.rpc.Multicall(self) - self.multicall_add(m, self._get_prefix() + 'disable') - - return(m.call()[-1]) + p = self._rt_obj._get_conn() + return getattr(p, self._get_prefix() + 'disable')() def set_command(self, *methods): methods = [m + '=' for m in methods] From 3af6623a919769bfde5e407bfd60fff7c777ba93 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sun, 18 Aug 2013 00:22:36 +0200 Subject: [PATCH 24/58] Move registerPlugin to __new__ magic --- couchpotato/core/loader.py | 7 +------ couchpotato/core/plugins/base.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/couchpotato/core/loader.py b/couchpotato/core/loader.py index 8aef89a4..9d04632b 100644 --- a/couchpotato/core/loader.py +++ b/couchpotato/core/loader.py @@ -111,12 +111,7 @@ class Loader(object): def loadPlugins(self, module, name): try: - klass = module.start() - klass.registerPlugin() - - if klass and getattr(klass, 'auto_register_static'): - klass.registerStatic(module.__file__) - + module.start() return True except Exception, e: log.error('Failed loading plugin "%s": %s', (module.__file__, traceback.format_exc())) diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index f81d8a1b..9e97c804 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -12,6 +12,7 @@ from urlparse import urlparse import cookielib import glob import gzip +import inspect import math import os.path import re @@ -35,11 +36,20 @@ class Plugin(object): http_failed_request = {} http_failed_disabled = {} + def __new__(typ, *args, **kwargs): + new_plugin = super(Plugin, typ).__new__(typ, *args, **kwargs) + new_plugin.registerPlugin() + + return new_plugin + def registerPlugin(self): addEvent('app.do_shutdown', self.doShutdown) addEvent('plugin.running', self.isRunning) self._running = [] + if self.auto_register_static: + self.registerStatic(inspect.getfile(self.__class__)) + def conf(self, attr, value = None, default = None, section = None): return Env.setting(attr, section = section if section else self.getName().lower(), value = value, default = default) From 62b571d5f1f232dab633201b1ed0f256fe5e1a38 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sun, 18 Aug 2013 11:44:00 +0200 Subject: [PATCH 25/58] Rename type to protocol --- couchpotato/core/downloaders/base.py | 20 ++++++------- .../core/downloaders/blackhole/main.py | 24 ++++++++-------- couchpotato/core/downloaders/nzbget/main.py | 2 +- .../core/downloaders/nzbvortex/main.py | 2 +- .../core/downloaders/pneumatic/main.py | 4 +-- couchpotato/core/downloaders/sabnzbd/main.py | 2 +- couchpotato/core/downloaders/synology/main.py | 28 +++++++++---------- .../core/downloaders/transmission/main.py | 8 +++--- couchpotato/core/downloaders/utorrent/main.py | 10 +++---- couchpotato/core/media/_base/searcher/main.py | 22 +++++++-------- couchpotato/core/media/movie/searcher/main.py | 18 ++++++------ couchpotato/core/providers/base.py | 17 ++++++----- couchpotato/core/providers/nzb/base.py | 3 +- couchpotato/core/providers/torrent/base.py | 4 +-- 14 files changed, 84 insertions(+), 80 deletions(-) diff --git a/couchpotato/core/downloaders/base.py b/couchpotato/core/downloaders/base.py index b820b9ff..cc0d59ea 100644 --- a/couchpotato/core/downloaders/base.py +++ b/couchpotato/core/downloaders/base.py @@ -11,7 +11,7 @@ log = CPLog(__name__) class Downloader(Provider): - type = [] + protocol = [] http_time_between_calls = 0 torrent_sources = [ @@ -36,16 +36,16 @@ class Downloader(Provider): def __init__(self): addEvent('download', self._download) addEvent('download.enabled', self._isEnabled) - addEvent('download.enabled_types', self.getEnabledDownloadType) + addEvent('download.enabled_protocols', self.getEnabledProtocol) addEvent('download.status', self._getAllDownloadStatus) addEvent('download.remove_failed', self._removeFailed) addEvent('download.pause', self._pause) addEvent('download.process_complete', self._processComplete) - def getEnabledDownloadType(self): - for download_type in self.type: - if self.isEnabled(manual = True, data = {'type': download_type}): - return self.type + def getEnabledProtocol(self): + for download_protocol in self.protocol: + if self.isEnabled(manual = True, data = {'protocol': download_protocol}): + return self.protocol return [] @@ -91,11 +91,11 @@ class Downloader(Provider): def processComplete(self, item, delete_files): return - def isCorrectType(self, item_type): - is_correct = item_type in self.type + def isCorrectProtocol(self, item_protocol): + is_correct = item_protocol in self.protocol if not is_correct: - log.debug("Downloader doesn't support this type") + log.debug("Downloader doesn't support this protocol") return is_correct @@ -140,7 +140,7 @@ class Downloader(Provider): d_manual = self.conf('manual', default = False) return super(Downloader, self).isEnabled() and \ ((d_manual and manual) or (d_manual is False)) and \ - (not data or self.isCorrectType(data.get('type'))) + (not data or self.isCorrectProtocol(data.get('protocol'))) def _pause(self, item, pause = True): if self.isDisabled(manual = True, data = {}): diff --git a/couchpotato/core/downloaders/blackhole/main.py b/couchpotato/core/downloaders/blackhole/main.py index 82b07276..9d2a5261 100644 --- a/couchpotato/core/downloaders/blackhole/main.py +++ b/couchpotato/core/downloaders/blackhole/main.py @@ -10,20 +10,20 @@ log = CPLog(__name__) class Blackhole(Downloader): - type = ['nzb', 'torrent', 'torrent_magnet'] + protocol = ['nzb', 'torrent', 'torrent_magnet'] def download(self, data = {}, movie = {}, filedata = None): directory = self.conf('directory') if not directory or not os.path.isdir(directory): - log.error('No directory set for blackhole %s download.', data.get('type')) + log.error('No directory set for blackhole %s download.', data.get('protocol')) else: try: if not filedata or len(filedata) < 50: try: - if data.get('type') == 'torrent_magnet': + if data.get('protocol') == 'torrent_magnet': filedata = self.magnetToTorrent(data.get('url')) - data['type'] = 'torrent' + data['protocol'] = 'torrent' except: log.error('Failed download torrent via magnet url: %s', traceback.format_exc()) @@ -35,7 +35,7 @@ class Blackhole(Downloader): try: if not os.path.isfile(fullPath): - log.info('Downloading %s to %s.', (data.get('type'), fullPath)) + log.info('Downloading %s to %s.', (data.get('protocol'), fullPath)) with open(fullPath, 'wb') as f: f.write(filedata) os.chmod(fullPath, Env.getPermission('file')) @@ -54,20 +54,20 @@ class Blackhole(Downloader): return False - def getEnabledDownloadType(self): + def getEnabledProtocol(self): if self.conf('use_for') == 'both': - return super(Blackhole, self).getEnabledDownloadType() + return super(Blackhole, self).getEnabledProtocol() elif self.conf('use_for') == 'torrent': return ['torrent', 'torrent_magnet'] else: return ['nzb'] def isEnabled(self, manual, data = {}): - for_type = ['both'] - if data and 'torrent' in data.get('type'): - for_type.append('torrent') + for_protocol = ['both'] + if data and 'torrent' in data.get('protocol'): + for_protocol.append('torrent') elif data: - for_type.append(data.get('type')) + for_protocol.append(data.get('protocol')) return super(Blackhole, self).isEnabled(manual, data) and \ - ((self.conf('use_for') in for_type)) + ((self.conf('use_for') in for_protocol)) diff --git a/couchpotato/core/downloaders/nzbget/main.py b/couchpotato/core/downloaders/nzbget/main.py index ef9d4efa..ba3b2618 100644 --- a/couchpotato/core/downloaders/nzbget/main.py +++ b/couchpotato/core/downloaders/nzbget/main.py @@ -15,7 +15,7 @@ log = CPLog(__name__) class NZBGet(Downloader): - type = ['nzb'] + protocol = ['nzb'] url = 'http://%(username)s:%(password)s@%(host)s/xmlrpc' diff --git a/couchpotato/core/downloaders/nzbvortex/main.py b/couchpotato/core/downloaders/nzbvortex/main.py index 805c4598..b8817aca 100644 --- a/couchpotato/core/downloaders/nzbvortex/main.py +++ b/couchpotato/core/downloaders/nzbvortex/main.py @@ -19,7 +19,7 @@ log = CPLog(__name__) class NZBVortex(Downloader): - type = ['nzb'] + protocol = ['nzb'] api_level = None session_id = None diff --git a/couchpotato/core/downloaders/pneumatic/main.py b/couchpotato/core/downloaders/pneumatic/main.py index 5564dca7..25923e08 100644 --- a/couchpotato/core/downloaders/pneumatic/main.py +++ b/couchpotato/core/downloaders/pneumatic/main.py @@ -9,7 +9,7 @@ log = CPLog(__name__) class Pneumatic(Downloader): - type = ['nzb'] + protocol = ['nzb'] strm_syntax = 'plugin://plugin.program.pneumatic/?mode=strm&type=add_file&nzb=%s&nzbname=%s' def download(self, data = {}, movie = {}, filedata = None): @@ -27,7 +27,7 @@ class Pneumatic(Downloader): try: if not os.path.isfile(fullPath): - log.info('Downloading %s to %s.', (data.get('type'), fullPath)) + log.info('Downloading %s to %s.', (data.get('protocol'), fullPath)) with open(fullPath, 'wb') as f: f.write(filedata) diff --git a/couchpotato/core/downloaders/sabnzbd/main.py b/couchpotato/core/downloaders/sabnzbd/main.py index 776749be..468f30b3 100644 --- a/couchpotato/core/downloaders/sabnzbd/main.py +++ b/couchpotato/core/downloaders/sabnzbd/main.py @@ -13,7 +13,7 @@ log = CPLog(__name__) class Sabnzbd(Downloader): - type = ['nzb'] + protocol = ['nzb'] def download(self, data = {}, movie = {}, filedata = None): diff --git a/couchpotato/core/downloaders/synology/main.py b/couchpotato/core/downloaders/synology/main.py index 87212749..362577fa 100644 --- a/couchpotato/core/downloaders/synology/main.py +++ b/couchpotato/core/downloaders/synology/main.py @@ -9,13 +9,13 @@ log = CPLog(__name__) class Synology(Downloader): - type = ['nzb', 'torrent', 'torrent_magnet'] + protocol = ['nzb', 'torrent', 'torrent_magnet'] log = CPLog(__name__) def download(self, data, movie, filedata = None): response = False - log.error('Sending "%s" (%s) to Synology.', (data['name'], data['type'])) + log.error('Sending "%s" (%s) to Synology.', (data['name'], data['protocol'])) # Load host from config and split out port. host = self.conf('host').split(':') @@ -26,38 +26,38 @@ class Synology(Downloader): try: # Send request to Synology srpc = SynologyRPC(host[0], host[1], self.conf('username'), self.conf('password')) - if data['type'] == 'torrent_magnet': + if data['protocol'] == 'torrent_magnet': log.info('Adding torrent URL %s', data['url']) response = srpc.create_task(url = data['url']) - elif data['type'] in ['nzb', 'torrent']: - log.info('Adding %s' % data['type']) + elif data['protocol'] in ['nzb', 'torrent']: + log.info('Adding %s' % data['protocol']) if not filedata: - log.error('No %s data found' % data['type']) + log.error('No %s data found' % data['protocol']) else: - filename = data['name'] + '.' + data['type'] + filename = data['name'] + '.' + data['protocol'] response = srpc.create_task(filename = filename, filedata = filedata) except Exception, err: log.error('Exception while adding torrent: %s', err) finally: return response - def getEnabledDownloadType(self): + def getEnabledProtocol(self): if self.conf('use_for') == 'both': - return super(Synology, self).getEnabledDownloadType() + return super(Synology, self).getEnabledProtocol() elif self.conf('use_for') == 'torrent': return ['torrent', 'torrent_magnet'] else: return ['nzb'] def isEnabled(self, manual, data = {}): - for_type = ['both'] - if data and 'torrent' in data.get('type'): - for_type.append('torrent') + for_protocol = ['both'] + if data and 'torrent' in data.get('protocol'): + for_protocol.append('torrent') elif data: - for_type.append(data.get('type')) + for_protocol.append(data.get('protocol')) return super(Synology, self).isEnabled(manual, data) and\ - ((self.conf('use_for') in for_type)) + ((self.conf('use_for') in for_protocol)) class SynologyRPC(object): diff --git a/couchpotato/core/downloaders/transmission/main.py b/couchpotato/core/downloaders/transmission/main.py index a619d411..89c7099b 100644 --- a/couchpotato/core/downloaders/transmission/main.py +++ b/couchpotato/core/downloaders/transmission/main.py @@ -16,7 +16,7 @@ log = CPLog(__name__) class Transmission(Downloader): - type = ['torrent', 'torrent_magnet'] + protocol = ['torrent', 'torrent_magnet'] log = CPLog(__name__) trpc = None @@ -34,12 +34,12 @@ class Transmission(Downloader): def download(self, data, movie, filedata = None): - log.info('Sending "%s" (%s) to Transmission.', (data.get('name'), data.get('type'))) + log.info('Sending "%s" (%s) to Transmission.', (data.get('name'), data.get('protocol'))) if not self.connect(): return False - if not filedata and data.get('type') == 'torrent': + if not filedata and data.get('protocol') == 'torrent': log.error('Failed sending torrent, no data') return False @@ -64,7 +64,7 @@ class Transmission(Downloader): torrent_params['seedIdleMode'] = 1 # Send request to Transmission - if data.get('type') == 'torrent_magnet': + if data.get('protocol') == 'torrent_magnet': remote_torrent = self.trpc.add_torrent_uri(data.get('url'), arguments = params) torrent_params['trackerAdd'] = self.torrent_trackers else: diff --git a/couchpotato/core/downloaders/utorrent/main.py b/couchpotato/core/downloaders/utorrent/main.py index be6ff107..d5cc64f7 100644 --- a/couchpotato/core/downloaders/utorrent/main.py +++ b/couchpotato/core/downloaders/utorrent/main.py @@ -20,7 +20,7 @@ log = CPLog(__name__) class uTorrent(Downloader): - type = ['torrent', 'torrent_magnet'] + protocol = ['torrent', 'torrent_magnet'] utorrent_api = None def connect(self): @@ -36,7 +36,7 @@ class uTorrent(Downloader): def download(self, data, movie, filedata = None): - log.debug('Sending "%s" (%s) to uTorrent.', (data.get('name'), data.get('type'))) + log.debug('Sending "%s" (%s) to uTorrent.', (data.get('name'), data.get('protocol'))) if not self.connect(): return False @@ -63,11 +63,11 @@ class uTorrent(Downloader): if self.conf('label'): torrent_params['label'] = self.conf('label') - if not filedata and data.get('type') == 'torrent': + if not filedata and data.get('protocol') == 'torrent': log.error('Failed sending torrent, no data') return False - if data.get('type') == 'torrent_magnet': + if data.get('protocol') == 'torrent_magnet': torrent_hash = re.findall('urn:btih:([\w]{32,40})', data.get('url'))[0].upper() torrent_params['trackers'] = '%0D%0A%0D%0A'.join(self.torrent_trackers) else: @@ -88,7 +88,7 @@ class uTorrent(Downloader): torrent_hash = b16encode(b32decode(torrent_hash)) # Send request to uTorrent - if data.get('type') == 'torrent_magnet': + if data.get('protocol') == 'torrent_magnet': self.utorrent_api.add_torrent_uri(data.get('url')) else: self.utorrent_api.add_torrent_file(torrent_filename, filedata) diff --git a/couchpotato/core/media/_base/searcher/main.py b/couchpotato/core/media/_base/searcher/main.py index 55dfe3e3..7d84a58c 100644 --- a/couchpotato/core/media/_base/searcher/main.py +++ b/couchpotato/core/media/_base/searcher/main.py @@ -19,7 +19,7 @@ log = CPLog(__name__) class Searcher(SearcherBase): def __init__(self): - addEvent('searcher.get_types', self.getSearchTypes) + addEvent('searcher.protocols', self.getSearchProtocols) addEvent('searcher.contains_other_quality', self.containsOtherQuality) addEvent('searcher.correct_year', self.correctYear) addEvent('searcher.correct_name', self.correctName) @@ -122,29 +122,29 @@ class Searcher(SearcherBase): return True - log.info('Tried to download, but none of the "%s" downloaders are enabled or gave an error', (data.get('type', ''))) + log.info('Tried to download, but none of the "%s" downloaders are enabled or gave an error', (data.get('protocol', ''))) return False - def getSearchTypes(self): + def getSearchProtocols(self): - download_types = fireEvent('download.enabled_types', merge = True) - provider_types = fireEvent('provider.enabled_types', merge = True) + download_protocols = fireEvent('download.enabled_protocols', merge = True) + provider_protocols = fireEvent('provider.enabled_protocols', merge = True) - if download_types and len(list(set(provider_types) & set(download_types))) == 0: - log.error('There aren\'t any providers enabled for your downloader (%s). Check your settings.', ','.join(download_types)) + if download_protocols and len(list(set(provider_protocols) & set(download_protocols))) == 0: + log.error('There aren\'t any providers enabled for your downloader (%s). Check your settings.', ','.join(download_protocols)) return [] - for useless_provider in list(set(provider_types) - set(download_types)): + for useless_provider in list(set(provider_protocols) - set(download_protocols)): log.debug('Provider for "%s" enabled, but no downloader.', useless_provider) - search_types = download_types + search_protocols = download_protocols - if len(search_types) == 0: + if len(search_protocols) == 0: log.error('There aren\'t any downloaders enabled. Please pick one in settings.') return [] - return search_types + return search_protocols def containsOtherQuality(self, nzb, movie_year = None, preferred_quality = {}): diff --git a/couchpotato/core/media/movie/searcher/main.py b/couchpotato/core/media/movie/searcher/main.py index 30f27d7f..8fb4acf1 100644 --- a/couchpotato/core/media/movie/searcher/main.py +++ b/couchpotato/core/media/movie/searcher/main.py @@ -85,7 +85,7 @@ class MovieSearcher(SearcherBase, MovieTypeBase): } try: - search_types = fireEvent('searcher.get_types', single = True) + search_protocols = fireEvent('searcher.protocols', single = True) for movie in movies: movie_dict = movie.to_dict({ @@ -97,7 +97,7 @@ class MovieSearcher(SearcherBase, MovieTypeBase): }) try: - self.single(movie_dict, search_types) + self.single(movie_dict, search_protocols) except IndexError: log.error('Forcing library update for %s, if you see this often, please report: %s', (movie_dict['library']['identifier'], traceback.format_exc())) fireEvent('library.update', movie_dict['library']['identifier'], force = True) @@ -115,12 +115,12 @@ class MovieSearcher(SearcherBase, MovieTypeBase): self.in_progress = False - def single(self, movie, search_types = None): + def single(self, movie, search_protocols = None): # Find out search type try: - if not search_types: - search_types = fireEvent('searcher.get_types', single = True) + if not search_protocols: + search_protocols = fireEvent('searcher.protocols', single = True) except SearchSetupError: return @@ -168,10 +168,10 @@ class MovieSearcher(SearcherBase, MovieTypeBase): quality = fireEvent('quality.single', identifier = quality_type['quality']['identifier'], single = True) results = [] - for search_type in search_types: - type_results = fireEvent('%s.search' % search_type, movie, quality, merge = True) - if type_results: - results += type_results + for search_protocol in search_protocols: + protocol_results = fireEvent('provider.search.%s.movie' % search_protocol, movie, quality, merge = True) + if protocol_results: + results += protocol_results sorted_results = sorted(results, key = lambda k: k['score'], reverse = True) if len(sorted_results) == 0: diff --git a/couchpotato/core/providers/base.py b/couchpotato/core/providers/base.py index d7ac7d16..08b4c6e5 100644 --- a/couchpotato/core/providers/base.py +++ b/couchpotato/core/providers/base.py @@ -19,7 +19,7 @@ log = CPLog(__name__) class Provider(Plugin): - type = None # movie, nzb, torrent, subtitle, trailer + type = None # movie, show, subtitle, trailer, ... http_time_between_calls = 10 # Default timeout for url requests last_available_check = {} @@ -79,7 +79,10 @@ class Provider(Plugin): class YarrProvider(Provider): - cat_ids = [] + protocol = None # nzb, torrent, torrent_magnet + + cat_ids = {} + cat_backup_id = None sizeGb = ['gb', 'gib'] sizeMb = ['mb', 'mib'] @@ -89,14 +92,13 @@ class YarrProvider(Provider): last_login_check = 0 def __init__(self): - addEvent('provider.enabled_types', self.getEnabledProviderType) + addEvent('provider.enabled_protocols', self.getEnabledProtocol) addEvent('provider.belongs_to', self.belongsTo) - addEvent('yarr.search', self.search) - addEvent('%s.search' % self.type, self.search) + addEvent('provider.search.%s.%s' % (self.protocol, self.type), self.search) - def getEnabledProviderType(self): + def getEnabledProtocol(self): if self.isEnabled(): - return self.type + return self.protocol else: return [] @@ -273,6 +275,7 @@ class ResultList(list): defaults = { 'id': 0, + 'protocol': self.provider.protocol, 'type': self.provider.type, 'provider': self.provider.getName(), 'download': self.provider.loginDownload if self.provider.urls.get('login') else self.provider.download, diff --git a/couchpotato/core/providers/nzb/base.py b/couchpotato/core/providers/nzb/base.py index f11382ba..53c73af0 100644 --- a/couchpotato/core/providers/nzb/base.py +++ b/couchpotato/core/providers/nzb/base.py @@ -3,7 +3,8 @@ import time class NZBProvider(YarrProvider): - type = 'nzb' + + protocol = 'nzb' def calculateAge(self, unix): return int(time.time() - unix) / 24 / 60 / 60 diff --git a/couchpotato/core/providers/torrent/base.py b/couchpotato/core/providers/torrent/base.py index 453954c9..3e7ddde8 100644 --- a/couchpotato/core/providers/torrent/base.py +++ b/couchpotato/core/providers/torrent/base.py @@ -7,7 +7,7 @@ log = CPLog(__name__) class TorrentProvider(YarrProvider): - type = 'torrent' + protocol = 'torrent' def imdbMatch(self, url, imdbId): if getImdb(url) == imdbId: @@ -27,6 +27,6 @@ class TorrentProvider(YarrProvider): class TorrentMagnetProvider(TorrentProvider): - type = 'torrent_magnet' + protocol = 'torrent_magnet' download = None From 3dff598d03e9feb456ee639ede6128529c730a7e Mon Sep 17 00:00:00 2001 From: Ruud Date: Sun, 18 Aug 2013 11:45:45 +0200 Subject: [PATCH 26/58] Add multiprovider for provider grouping --- couchpotato/core/plugins/base.py | 10 ++++++++-- couchpotato/core/providers/base.py | 21 ++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 9e97c804..84ecc451 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -25,6 +25,8 @@ log = CPLog(__name__) class Plugin(object): + _class_name = None + enabled_option = 'enabled' auto_register_static = True @@ -51,10 +53,14 @@ class Plugin(object): self.registerStatic(inspect.getfile(self.__class__)) def conf(self, attr, value = None, default = None, section = None): - return Env.setting(attr, section = section if section else self.getName().lower(), value = value, default = default) + class_name = self.getName().lower().split(':') + return Env.setting(attr, section = section if section else class_name[0].lower(), value = value, default = default) def getName(self): - return self.__class__.__name__ + return self._class_name or self.__class__.__name__ + + def setName(self, name): + self._class_name = name def renderTemplate(self, parent_file, templ, **params): diff --git a/couchpotato/core/providers/base.py b/couchpotato/core/providers/base.py index 08b4c6e5..f2db8da6 100644 --- a/couchpotato/core/providers/base.py +++ b/couchpotato/core/providers/base.py @@ -13,10 +13,29 @@ import traceback import urllib2 import xml.etree.ElementTree as XMLTree - log = CPLog(__name__) +class MultiProvider(Plugin): + + def __init__(self): + self._classes = [] + + for Type in self.getTypes(): + klass = Type() + + # Overwrite name so logger knows what we're talking about + klass.setName('%s:%s' % (self.getName(), klass.getName())) + + self._classes.append(klass) + + def getTypes(self): + return [] + + def getClasses(self): + return self._classes + + class Provider(Plugin): type = None # movie, show, subtitle, trailer, ... From 9860a1c138f42e8b776714800fc76b68c10806d7 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sun, 18 Aug 2013 13:17:40 +0200 Subject: [PATCH 27/58] Default to movie type --- couchpotato/core/providers/base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/couchpotato/core/providers/base.py b/couchpotato/core/providers/base.py index f2db8da6..e6a9cb00 100644 --- a/couchpotato/core/providers/base.py +++ b/couchpotato/core/providers/base.py @@ -99,6 +99,7 @@ class Provider(Plugin): class YarrProvider(Provider): protocol = None # nzb, torrent, torrent_magnet + type = 'movie' cat_ids = {} cat_backup_id = None From 8a298edd4e442e7d3d68ef1907d6eab524f051a7 Mon Sep 17 00:00:00 2001 From: Techmunk Date: Wed, 21 Aug 2013 23:52:54 +1000 Subject: [PATCH 28/58] Implementation of Deluge downloader. --- .../core/downloaders/deluge/__init__.py | 89 ++++ couchpotato/core/downloaders/deluge/main.py | 241 ++++++++++ .../deluge/synchronousdeluge/__init__.py | 24 + .../deluge/synchronousdeluge/client.py | 135 ++++++ .../deluge/synchronousdeluge/exceptions.py | 11 + .../deluge/synchronousdeluge/protocol.py | 38 ++ .../deluge/synchronousdeluge/rencode.py | 433 ++++++++++++++++++ .../deluge/synchronousdeluge/transfer.py | 54 +++ 8 files changed, 1025 insertions(+) create mode 100644 couchpotato/core/downloaders/deluge/__init__.py create mode 100644 couchpotato/core/downloaders/deluge/main.py create mode 100644 couchpotato/core/downloaders/deluge/synchronousdeluge/__init__.py create mode 100644 couchpotato/core/downloaders/deluge/synchronousdeluge/client.py create mode 100644 couchpotato/core/downloaders/deluge/synchronousdeluge/exceptions.py create mode 100644 couchpotato/core/downloaders/deluge/synchronousdeluge/protocol.py create mode 100644 couchpotato/core/downloaders/deluge/synchronousdeluge/rencode.py create mode 100644 couchpotato/core/downloaders/deluge/synchronousdeluge/transfer.py diff --git a/couchpotato/core/downloaders/deluge/__init__.py b/couchpotato/core/downloaders/deluge/__init__.py new file mode 100644 index 00000000..4b122b38 --- /dev/null +++ b/couchpotato/core/downloaders/deluge/__init__.py @@ -0,0 +1,89 @@ +from .main import Deluge + +def start(): + return Deluge() + +config = [{ + 'name': 'deluge', + 'groups': [ + { + 'tab': 'downloaders', + 'list': 'download_providers', + 'name': 'deluge', + 'label': 'Deluge', + 'description': 'Use Deluge to download torrents.', + 'wizard': True, + 'options': [ + { + 'name': 'enabled', + 'default': 0, + 'type': 'enabler', + 'radio_group': 'torrent', + }, + { + 'name': 'host', + 'default': 'localhost:58846', + 'description': 'Hostname with port. Usually localhost:58846', + }, + { + 'name': 'username', + }, + { + 'name': 'password', + 'type': 'password', + }, + { + 'name': 'paused', + 'type': 'bool', + 'default': False, + 'description': 'Add the torrent paused.', + }, + { + 'name': 'directory', + 'type': 'directory', + 'description': 'Download to this directory. Keep empty for default Deluge download directory.', + }, + { + 'name': 'completed_directory', + 'type': 'directory', + 'description': 'Move completed torrent to this directory. Keep empty for default Deluge options.', + 'advanced': True, + }, + { + 'name': 'label', + 'description': 'Label to add to torrents in the Deluge UI.', + }, + { + 'name': 'remove_complete', + 'label': 'Remove torrent', + 'type': 'bool', + 'default': True, + 'advanced': True, + 'description': 'Remove the torrent from Deluge after it has finished seeding.', + }, + { + 'name': 'delete_files', + 'label': 'Remove files', + 'default': True, + 'type': 'bool', + 'advanced': True, + 'description': 'Also remove the leftover files.', + }, + { + 'name': 'manual', + 'default': 0, + 'type': 'bool', + 'advanced': True, + 'description': 'Disable this downloader for automated searches, but use it when I manually send a release.', + }, + { + 'name': 'delete_failed', + 'default': True, + 'advanced': True, + 'type': 'bool', + 'description': 'Delete a release after the download has failed.', + }, + ], + } + ], +}] diff --git a/couchpotato/core/downloaders/deluge/main.py b/couchpotato/core/downloaders/deluge/main.py new file mode 100644 index 00000000..a990b175 --- /dev/null +++ b/couchpotato/core/downloaders/deluge/main.py @@ -0,0 +1,241 @@ +from base64 import b64encode +from couchpotato.core.helpers.variable import tryInt, tryFloat +from couchpotato.core.downloaders.base import Downloader, StatusList +from couchpotato.core.helpers.encoding import isInt +from couchpotato.core.logger import CPLog +from couchpotato.environment import Env +from datetime import timedelta + +from synchronousdeluge import DelugeClient + +import os.path +import traceback + +log = CPLog(__name__) + +class Deluge(Downloader): + + protocol = ['torrent', 'torrent_magnet'] + log = CPLog(__name__) + drpc = None + + def connect(self): + # Load host from config and split out port. + host = self.conf('host').split(':') + if not isInt(host[1]): + log.error('Config properties are not filled in correctly, port is missing.') + return False + + if not self.drpc: + self.drpc = DelugeRPC(host[0], port = host[1], username = self.conf('username'), password = self.conf('password')) + + return self.drpc + + def download(self, data, movie, filedata = None): + + log.info('Sending "%s" (%s) to Deluge.', (data.get('name'), data.get('protocol'))) + + if not self.connect(): + return False + + if not filedata and data.get('protocol') == 'torrent': + log.error('Failed sending torrent, no data') + return False + + # Set parameters for Deluge + options = { + 'add_paused': self.conf('paused', default = 0), + 'label': self.conf('label') + } + + if self.conf('directory'): + if os.path.isdir(self.conf('directory')): + options['download_location'] = self.conf('directory') + else: + log.error('Download directory from Deluge settings: %s doesn\'t exist', self.conf('directory')) + + if self.conf('completed_directory'): + if os.path.isdir(self.conf('completed_directory')): + options['move_completed'] = 1 + options['move_completed_path'] = self.conf('completed_directory') + else: + log.error('Download directory from Deluge settings: %s doesn\'t exist', self.conf('directory')) + + if data.get('seed_ratio'): + options['stop_at_ratio'] = 1 + options['stop_ratio'] = tryFloat(data.get('seed_ratio')) + +# Deluge only has seed time as a global option. Might be added in +# in a future API release. +# if data.get('seed_time'): + + # Send request to Deluge + if data.get('protocol') == 'torrent_magnet': + remote_torrent = self.drpc.add_torrent_magnet(data.get('url'), options) + else: + remote_torrent = self.drpc.add_torrent_file(movie, b64encode(filedata), options) + + if not remote_torrent: + log.error('Failed sending torrent to Deluge') + return False + + log.info('Torrent sent to Deluge successfully.') + return self.downloadReturnId(remote_torrent) + + def getAllDownloadStatus(self): + + log.debug('Checking Deluge download status.') + + if not self.connect(): + return False + + statuses = StatusList(self) + + queue = self.drpc.get_alltorrents() + + if not (queue and queue.get('torrents')): + log.debug('Nothing in queue or error') + return False + + for torrent_id in queue: + item = queue[torrent_id] + log.debug('name=%s / id=%s / save_path=%s / hash=%s / progress=%s / state=%s / eta=%s / ratio=%s / conf_ratio=%s/ is_seed=%s / is_finished=%s', (item['name'], item['hash'], item['save_path'], item['hash'], item['progress'], item['state'], item['eta'], item['ratio'], self.conf('ratio'), item['is_seed'], item['is_finished'])) + + if not os.path.isdir(Env.setting('from', 'renamer')): + log.error('Renamer "from" folder doesn\'t to exist.') + return + + status = 'busy' + # Deluge seems to set both is_seed and is_finished once everything has been downloaded. + if item['is_seed'] or item['is_finished']: + status = 'seeding' + elif item['is_seed'] and item['is_finished'] and item['paused']: + status = 'completed' + + download_dir = item['save_path'] + if item['move_on_completed']: + download_dir = item['move_completed_path'] + + statuses.append({ + 'id': item['hash'], + 'name': item['name'], + 'status': status, + 'original_status': item['state'], + 'seed_ratio': item['ratio'], + 'timeleft': str(timedelta(seconds = item['eta'])), + 'folder': os.path.join(download_dir, item['name']), + }) + + return statuses + + def pause(self, item, pause = True): + if pause: + return self.drpc.pause_torrent([item['id']]) + else: + return self.drpc.resume_torrent([item['id']]) + + def removeFailed(self, item): + log.info('%s failed downloading, deleting...', item['name']) + return self.drpc.remove_torrent(item['id'], True) + + def processComplete(self, item, delete_files = False): + log.debug('Requesting Deluge to remove the torrent %s%s.', (item['name'], ' and cleanup the downloaded files' if delete_files else '')) + return self.drpc.remove_torrent(item['id'], remove_local_data = delete_files) + +class DelugeRPC(object): + + host = 'localhost' + port = 58846 + username = None + password = None + client = None + + def __init__(self, host = 'localhost', port = 58846, username = None, password = None): + super(DelugeRPC, self).__init__() + + self.host = host + self.port = port + self.username = username + self.password = password + + def connect(self): + self.client = DelugeClient() + self.client.connect(self.host, int(self.port), self.username, self.password) + + def add_torrent_magnet(self, torrent, options): + torrent_id = False + try: + self.connect() + torrent_id = self.client.core.add_torrent_magnet(torrent, options).get() + if options['label']: + self.client.label.set_torrent(torrent_id, options['label']).get() + except Exception, err: + log.error('Failed to add torrent magnet: %s %s', err, traceback.format_exc()) + finally: + if self.client: + self.disconnect() + + return torrent_id + + def add_torrent_file(self, movie, torrent, options): + torrent_id = False + try: + self.connect() + torrent_id = self.client.core.add_torrent_file(movie, torrent, options).get() + if options['label']: + self.client.label.set_torrent(torrent_id, options['label']).get() + except Exception, err: + log.error('Failed to add torrent file: %s %s', err, traceback.format_exc()) + finally: + if self.client: + self.disconnect() + + return torrent_id + + def get_alltorrents(self): + ret = False + try: + self.connect() + ret = self.client.core.get_torrents_status({}, {}).get() + except Exception, err: + log.error('Failed to get all torrents: %s %s', err, traceback.format_exc()) + finally: + if self.client: + self.disconnect() + return ret + + def pause_torrent(self, torrent_ids): + try: + self.connect() + self.client.core.pause_torrent(torrent_ids).get() + except Exception, err: + log.error('Failed to pause torrent: %s %s', err, traceback.format_exc()) + finally: + if self.client: + self.disconnect() + + def resume_torrent(self, torrent_ids): + try: + self.connect() + self.client.core.resume_torrent(torrent_ids).get() + except Exception, err: + log.error('Failed to resume torrent: %s %s', err, traceback.format_exc()) + finally: + if self.client: + self.disconnect() + + def remove_torrent(self, torrent_id, remove_local_data): + ret = False + try: + self.connect() + ret = self.client.core.remove_torrent(torrent_id, remove_local_data).get() + except Exception, err: + log.error('Failed to remove torrent: %s %s', err, traceback.format_exc()) + finally: + if self.client: + self.disconnect() + return ret + + def disconnect(self): + self.client.disconnect() + diff --git a/couchpotato/core/downloaders/deluge/synchronousdeluge/__init__.py b/couchpotato/core/downloaders/deluge/synchronousdeluge/__init__.py new file mode 100644 index 00000000..a6fbcdd8 --- /dev/null +++ b/couchpotato/core/downloaders/deluge/synchronousdeluge/__init__.py @@ -0,0 +1,24 @@ +"""A synchronous implementation of the Deluge RPC protocol + based on gevent-deluge by Christopher Rosell. + + https://github.com/chrippa/gevent-deluge + +Example usage: + + from synchronousdeluge import DelgueClient + + client = DelugeClient() + client.connect() + + # Wait for value + download_location = client.core.get_config_value("download_location").get() +""" + + +__title__ = "synchronous-deluge" +__version__ = "0.1" +__author__ = "Christian Dale" + +from .client import DelugeClient +from .exceptions import DelugeRPCError + diff --git a/couchpotato/core/downloaders/deluge/synchronousdeluge/client.py b/couchpotato/core/downloaders/deluge/synchronousdeluge/client.py new file mode 100644 index 00000000..363bd855 --- /dev/null +++ b/couchpotato/core/downloaders/deluge/synchronousdeluge/client.py @@ -0,0 +1,135 @@ +import os + +from collections import defaultdict +from itertools import imap + +from .exceptions import DelugeRPCError +from .protocol import DelugeRPCRequest, DelugeRPCResponse +from .transfer import DelugeTransfer + +__all__ = ["DelugeClient"] + + +RPC_RESPONSE = 1 +RPC_ERROR = 2 +RPC_EVENT = 3 + + +class DelugeClient(object): + def __init__(self): + """A deluge client session.""" + self.transfer = DelugeTransfer() + self.modules = [] + self._request_counter = 0 + + def _get_local_auth(self): + xdg_config = os.path.expanduser(os.environ.get("XDG_CONFIG_HOME", "~/.config")) + config_home = os.path.join(xdg_config, "deluge") + auth_file = os.path.join(config_home, "auth") + + username = password = "" + with open(auth_file) as fd: + for line in fd: + if line.startswith("#"): + continue + + auth = line.split(":") + if len(auth) >= 2 and auth[0] == "localclient": + username, password = auth[0], auth[1] + break + + return username, password + + def _create_module_method(self, module, method): + fullname = "{0}.{1}".format(module, method) + + def func(obj, *args, **kwargs): + return self.remote_call(fullname, *args, **kwargs) + + func.__name__ = method + + return func + + def _introspect(self): + self.modules = [] + + methods = self.remote_call("daemon.get_method_list").get() + methodmap = defaultdict(dict) + splitter = lambda v: v.split(".") + + for module, method in imap(splitter, methods): + methodmap[module][method] = self._create_module_method(module, method) + + for module, methods in methodmap.items(): + clsname = "DelugeModule{0}".format(module.capitalize()) + cls = type(clsname, (), methods) + setattr(self, module, cls()) + self.modules.append(module) + + def remote_call(self, method, *args, **kwargs): + req = DelugeRPCRequest(self._request_counter, method, *args, **kwargs) + message = next(self.transfer.send_request(req)) + + response = DelugeRPCResponse() + + if not isinstance(message, tuple): + return + + if len(message) < 3: + return + + message_type = message[0] + +# if message_type == RPC_EVENT: +# event = message[1] +# values = message[2] +# +# if event in self._event_handlers: +# for handler in self._event_handlers[event]: +# gevent.spawn(handler, *values) +# +# elif message_type in (RPC_RESPONSE, RPC_ERROR): + if message_type in (RPC_RESPONSE, RPC_ERROR): + request_id = message[1] + value = message[2] + + if request_id == self._request_counter : + if message_type == RPC_RESPONSE: + response.set(value) + elif message_type == RPC_ERROR: + err = DelugeRPCError(*value) + response.set_exception(err) + + self._request_counter += 1 + return response + + def connect(self, host="127.0.0.1", port=58846, username="", password=""): + """Connects to a daemon process. + + :param host: str, the hostname of the daemon + :param port: int, the port of the daemon + :param username: str, the username to login with + :param password: str, the password to login with + """ + + # Connect transport + self.transfer.connect((host, port)) + + # Attempt to fetch local auth info if needed + if not username and host in ("127.0.0.1", "localhost"): + username, password = self._get_local_auth() + + # Authenticate + self.remote_call("daemon.login", username, password).get() + + # Introspect available methods + self._introspect() + + @property + def connected(self): + return self.transfer.connected + + def disconnect(self): + """Disconnects from the daemon.""" + self.transfer.disconnect() + diff --git a/couchpotato/core/downloaders/deluge/synchronousdeluge/exceptions.py b/couchpotato/core/downloaders/deluge/synchronousdeluge/exceptions.py new file mode 100644 index 00000000..da6cf022 --- /dev/null +++ b/couchpotato/core/downloaders/deluge/synchronousdeluge/exceptions.py @@ -0,0 +1,11 @@ +__all__ = ["DelugeRPCError"] + +class DelugeRPCError(Exception): + def __init__(self, name, msg, traceback): + self.name = name + self.msg = msg + self.traceback = traceback + + def __str__(self): + return "{0}: {1}: {2}".format(self.__class__.__name__, self.name, self.msg) + diff --git a/couchpotato/core/downloaders/deluge/synchronousdeluge/protocol.py b/couchpotato/core/downloaders/deluge/synchronousdeluge/protocol.py new file mode 100644 index 00000000..756d4dfc --- /dev/null +++ b/couchpotato/core/downloaders/deluge/synchronousdeluge/protocol.py @@ -0,0 +1,38 @@ +__all__ = ["DelugeRPCRequest", "DelugeRPCResponse"] + +class DelugeRPCRequest(object): + def __init__(self, request_id, method, *args, **kwargs): + self.request_id = request_id + self.method = method + self.args = args + self.kwargs = kwargs + + def format(self): + return (self.request_id, self.method, self.args, self.kwargs) + +class DelugeRPCResponse(object): + def __init__(self): + self.value = None + self._exception = None + + def successful(self): + return self._exception is None + + @property + def exception(self): + if self._exception is not None: + return self._exception + + def set(self, value=None): + self.value = value + self._exception = None + + def set_exception(self, exception): + self._exception = exception + + def get(self): + if self._exception is None: + return self.value + else: + raise self._exception + diff --git a/couchpotato/core/downloaders/deluge/synchronousdeluge/rencode.py b/couchpotato/core/downloaders/deluge/synchronousdeluge/rencode.py new file mode 100644 index 00000000..e58c7154 --- /dev/null +++ b/couchpotato/core/downloaders/deluge/synchronousdeluge/rencode.py @@ -0,0 +1,433 @@ + +""" +rencode -- Web safe object pickling/unpickling. + +Public domain, Connelly Barnes 2006-2007. + +The rencode module is a modified version of bencode from the +BitTorrent project. For complex, heterogeneous data structures with +many small elements, r-encodings take up significantly less space than +b-encodings: + + >>> len(rencode.dumps({'a':0, 'b':[1,2], 'c':99})) + 13 + >>> len(bencode.bencode({'a':0, 'b':[1,2], 'c':99})) + 26 + +The rencode format is not standardized, and may change with different +rencode module versions, so you should check that you are using the +same rencode version throughout your project. +""" + +__version__ = '1.0.1' +__all__ = ['dumps', 'loads'] + +# Original bencode module by Petru Paler, et al. +# +# Modifications by Connelly Barnes: +# +# - Added support for floats (sent as 32-bit or 64-bit in network +# order), bools, None. +# - Allowed dict keys to be of any serializable type. +# - Lists/tuples are always decoded as tuples (thus, tuples can be +# used as dict keys). +# - Embedded extra information in the 'typecodes' to save some space. +# - Added a restriction on integer length, so that malicious hosts +# cannot pass us large integers which take a long time to decode. +# +# Licensed by Bram Cohen under the "MIT license": +# +# "Copyright (C) 2001-2002 Bram Cohen +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# The Software is provided "AS IS", without warranty of any kind, +# express or implied, including but not limited to the warranties of +# merchantability, fitness for a particular purpose and +# noninfringement. In no event shall the authors or copyright holders +# be liable for any claim, damages or other liability, whether in an +# action of contract, tort or otherwise, arising from, out of or in +# connection with the Software or the use or other dealings in the +# Software." +# +# (The rencode module is licensed under the above license as well). +# + +import struct +import string +from threading import Lock + +# Default number of bits for serialized floats, either 32 or 64 (also a parameter for dumps()). +DEFAULT_FLOAT_BITS = 32 + +# Maximum length of integer when written as base 10 string. +MAX_INT_LENGTH = 64 + +# The bencode 'typecodes' such as i, d, etc have been extended and +# relocated on the base-256 character set. +CHR_LIST = chr(59) +CHR_DICT = chr(60) +CHR_INT = chr(61) +CHR_INT1 = chr(62) +CHR_INT2 = chr(63) +CHR_INT4 = chr(64) +CHR_INT8 = chr(65) +CHR_FLOAT32 = chr(66) +CHR_FLOAT64 = chr(44) +CHR_TRUE = chr(67) +CHR_FALSE = chr(68) +CHR_NONE = chr(69) +CHR_TERM = chr(127) + +# Positive integers with value embedded in typecode. +INT_POS_FIXED_START = 0 +INT_POS_FIXED_COUNT = 44 + +# Dictionaries with length embedded in typecode. +DICT_FIXED_START = 102 +DICT_FIXED_COUNT = 25 + +# Negative integers with value embedded in typecode. +INT_NEG_FIXED_START = 70 +INT_NEG_FIXED_COUNT = 32 + +# Strings with length embedded in typecode. +STR_FIXED_START = 128 +STR_FIXED_COUNT = 64 + +# Lists with length embedded in typecode. +LIST_FIXED_START = STR_FIXED_START+STR_FIXED_COUNT +LIST_FIXED_COUNT = 64 + +def decode_int(x, f): + f += 1 + newf = x.index(CHR_TERM, f) + if newf - f >= MAX_INT_LENGTH: + raise ValueError('overflow') + try: + n = int(x[f:newf]) + except (OverflowError, ValueError): + n = long(x[f:newf]) + if x[f] == '-': + if x[f + 1] == '0': + raise ValueError + elif x[f] == '0' and newf != f+1: + raise ValueError + return (n, newf+1) + +def decode_intb(x, f): + f += 1 + return (struct.unpack('!b', x[f:f+1])[0], f+1) + +def decode_inth(x, f): + f += 1 + return (struct.unpack('!h', x[f:f+2])[0], f+2) + +def decode_intl(x, f): + f += 1 + return (struct.unpack('!l', x[f:f+4])[0], f+4) + +def decode_intq(x, f): + f += 1 + return (struct.unpack('!q', x[f:f+8])[0], f+8) + +def decode_float32(x, f): + f += 1 + n = struct.unpack('!f', x[f:f+4])[0] + return (n, f+4) + +def decode_float64(x, f): + f += 1 + n = struct.unpack('!d', x[f:f+8])[0] + return (n, f+8) + +def decode_string(x, f): + colon = x.index(':', f) + try: + n = int(x[f:colon]) + except (OverflowError, ValueError): + n = long(x[f:colon]) + if x[f] == '0' and colon != f+1: + raise ValueError + colon += 1 + s = x[colon:colon+n] + try: + t = s.decode("utf8") + if len(t) != len(s): + s = t + except UnicodeDecodeError: + pass + return (s, colon+n) + +def decode_list(x, f): + r, f = [], f+1 + while x[f] != CHR_TERM: + v, f = decode_func[x[f]](x, f) + r.append(v) + return (tuple(r), f + 1) + +def decode_dict(x, f): + r, f = {}, f+1 + while x[f] != CHR_TERM: + k, f = decode_func[x[f]](x, f) + r[k], f = decode_func[x[f]](x, f) + return (r, f + 1) + +def decode_true(x, f): + return (True, f+1) + +def decode_false(x, f): + return (False, f+1) + +def decode_none(x, f): + return (None, f+1) + +decode_func = {} +decode_func['0'] = decode_string +decode_func['1'] = decode_string +decode_func['2'] = decode_string +decode_func['3'] = decode_string +decode_func['4'] = decode_string +decode_func['5'] = decode_string +decode_func['6'] = decode_string +decode_func['7'] = decode_string +decode_func['8'] = decode_string +decode_func['9'] = decode_string +decode_func[CHR_LIST ] = decode_list +decode_func[CHR_DICT ] = decode_dict +decode_func[CHR_INT ] = decode_int +decode_func[CHR_INT1 ] = decode_intb +decode_func[CHR_INT2 ] = decode_inth +decode_func[CHR_INT4 ] = decode_intl +decode_func[CHR_INT8 ] = decode_intq +decode_func[CHR_FLOAT32] = decode_float32 +decode_func[CHR_FLOAT64] = decode_float64 +decode_func[CHR_TRUE ] = decode_true +decode_func[CHR_FALSE ] = decode_false +decode_func[CHR_NONE ] = decode_none + +def make_fixed_length_string_decoders(): + def make_decoder(slen): + def f(x, f): + s = x[f+1:f+1+slen] + try: + t = s.decode("utf8") + if len(t) != len(s): + s = t + except UnicodeDecodeError: + pass + return (s, f+1+slen) + return f + for i in range(STR_FIXED_COUNT): + decode_func[chr(STR_FIXED_START+i)] = make_decoder(i) + +make_fixed_length_string_decoders() + +def make_fixed_length_list_decoders(): + def make_decoder(slen): + def f(x, f): + r, f = [], f+1 + for i in range(slen): + v, f = decode_func[x[f]](x, f) + r.append(v) + return (tuple(r), f) + return f + for i in range(LIST_FIXED_COUNT): + decode_func[chr(LIST_FIXED_START+i)] = make_decoder(i) + +make_fixed_length_list_decoders() + +def make_fixed_length_int_decoders(): + def make_decoder(j): + def f(x, f): + return (j, f+1) + return f + for i in range(INT_POS_FIXED_COUNT): + decode_func[chr(INT_POS_FIXED_START+i)] = make_decoder(i) + for i in range(INT_NEG_FIXED_COUNT): + decode_func[chr(INT_NEG_FIXED_START+i)] = make_decoder(-1-i) + +make_fixed_length_int_decoders() + +def make_fixed_length_dict_decoders(): + def make_decoder(slen): + def f(x, f): + r, f = {}, f+1 + for j in range(slen): + k, f = decode_func[x[f]](x, f) + r[k], f = decode_func[x[f]](x, f) + return (r, f) + return f + for i in range(DICT_FIXED_COUNT): + decode_func[chr(DICT_FIXED_START+i)] = make_decoder(i) + +make_fixed_length_dict_decoders() + +def encode_dict(x,r): + r.append(CHR_DICT) + for k, v in x.items(): + encode_func[type(k)](k, r) + encode_func[type(v)](v, r) + r.append(CHR_TERM) + + +def loads(x): + try: + r, l = decode_func[x[0]](x, 0) + except (IndexError, KeyError): + raise ValueError + if l != len(x): + raise ValueError + return r + +from types import StringType, IntType, LongType, DictType, ListType, TupleType, FloatType, NoneType, UnicodeType + +def encode_int(x, r): + if 0 <= x < INT_POS_FIXED_COUNT: + r.append(chr(INT_POS_FIXED_START+x)) + elif -INT_NEG_FIXED_COUNT <= x < 0: + r.append(chr(INT_NEG_FIXED_START-1-x)) + elif -128 <= x < 128: + r.extend((CHR_INT1, struct.pack('!b', x))) + elif -32768 <= x < 32768: + r.extend((CHR_INT2, struct.pack('!h', x))) + elif -2147483648 <= x < 2147483648: + r.extend((CHR_INT4, struct.pack('!l', x))) + elif -9223372036854775808 <= x < 9223372036854775808: + r.extend((CHR_INT8, struct.pack('!q', x))) + else: + s = str(x) + if len(s) >= MAX_INT_LENGTH: + raise ValueError('overflow') + r.extend((CHR_INT, s, CHR_TERM)) + +def encode_float32(x, r): + r.extend((CHR_FLOAT32, struct.pack('!f', x))) + +def encode_float64(x, r): + r.extend((CHR_FLOAT64, struct.pack('!d', x))) + +def encode_bool(x, r): + r.extend({False: CHR_FALSE, True: CHR_TRUE}[bool(x)]) + +def encode_none(x, r): + r.extend(CHR_NONE) + +def encode_string(x, r): + if len(x) < STR_FIXED_COUNT: + r.extend((chr(STR_FIXED_START + len(x)), x)) + else: + r.extend((str(len(x)), ':', x)) + +def encode_unicode(x, r): + encode_string(x.encode("utf8"), r) + +def encode_list(x, r): + if len(x) < LIST_FIXED_COUNT: + r.append(chr(LIST_FIXED_START + len(x))) + for i in x: + encode_func[type(i)](i, r) + else: + r.append(CHR_LIST) + for i in x: + encode_func[type(i)](i, r) + r.append(CHR_TERM) + +def encode_dict(x,r): + if len(x) < DICT_FIXED_COUNT: + r.append(chr(DICT_FIXED_START + len(x))) + for k, v in x.items(): + encode_func[type(k)](k, r) + encode_func[type(v)](v, r) + else: + r.append(CHR_DICT) + for k, v in x.items(): + encode_func[type(k)](k, r) + encode_func[type(v)](v, r) + r.append(CHR_TERM) + +encode_func = {} +encode_func[IntType] = encode_int +encode_func[LongType] = encode_int +encode_func[StringType] = encode_string +encode_func[ListType] = encode_list +encode_func[TupleType] = encode_list +encode_func[DictType] = encode_dict +encode_func[NoneType] = encode_none +encode_func[UnicodeType] = encode_unicode + +lock = Lock() + +try: + from types import BooleanType + encode_func[BooleanType] = encode_bool +except ImportError: + pass + +def dumps(x, float_bits=DEFAULT_FLOAT_BITS): + """ + Dump data structure to str. + + Here float_bits is either 32 or 64. + """ + lock.acquire() + try: + if float_bits == 32: + encode_func[FloatType] = encode_float32 + elif float_bits == 64: + encode_func[FloatType] = encode_float64 + else: + raise ValueError('Float bits (%d) is not 32 or 64' % float_bits) + r = [] + encode_func[type(x)](x, r) + finally: + lock.release() + return ''.join(r) + +def test(): + f1 = struct.unpack('!f', struct.pack('!f', 25.5))[0] + f2 = struct.unpack('!f', struct.pack('!f', 29.3))[0] + f3 = struct.unpack('!f', struct.pack('!f', -0.6))[0] + L = (({'a':15, 'bb':f1, 'ccc':f2, '':(f3,(),False,True,'')},('a',10**20),tuple(range(-100000,100000)),'b'*31,'b'*62,'b'*64,2**30,2**33,2**62,2**64,2**30,2**33,2**62,2**64,False,False, True, -1, 2, 0),) + assert loads(dumps(L)) == L + d = dict(zip(range(-100000,100000),range(-100000,100000))) + d.update({'a':20, 20:40, 40:41, f1:f2, f2:f3, f3:False, False:True, True:False}) + L = (d, {}, {5:6}, {7:7,True:8}, {9:10, 22:39, 49:50, 44: ''}) + assert loads(dumps(L)) == L + L = ('', 'a'*10, 'a'*100, 'a'*1000, 'a'*10000, 'a'*100000, 'a'*1000000, 'a'*10000000) + assert loads(dumps(L)) == L + L = tuple([dict(zip(range(n),range(n))) for n in range(100)]) + ('b',) + assert loads(dumps(L)) == L + L = tuple([dict(zip(range(n),range(-n,0))) for n in range(100)]) + ('b',) + assert loads(dumps(L)) == L + L = tuple([tuple(range(n)) for n in range(100)]) + ('b',) + assert loads(dumps(L)) == L + L = tuple(['a'*n for n in range(1000)]) + ('b',) + assert loads(dumps(L)) == L + L = tuple(['a'*n for n in range(1000)]) + (None,True,None) + assert loads(dumps(L)) == L + assert loads(dumps(None)) == None + assert loads(dumps({None:None})) == {None:None} + assert 1e-10 Date: Sat, 20 Jul 2013 13:55:07 +0200 Subject: [PATCH 29/58] Fix untagDir and hastagDir Changes in commit 8a252bff64b2bb2d376673a0b00e9624d44aaf4c broke the tagging functionality --- couchpotato/core/plugins/renamer/main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index d1daf183..1121691f 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -517,22 +517,22 @@ Remove it if you want it to be renamed (again, or at least let it try again) if ignore_file: self.createFile(ignore_file, text) - def untagDir(self, folder, tag = None): + def untagDir(self, folder, tag = ''): if not os.path.isdir(folder): return # Remove any .ignore files for root, dirnames, filenames in os.walk(folder): - for filename in fnmatch.filter(filenames, '%s.ignore' % tag if tag else '*'): + for filename in fnmatch.filter(filenames, '*%s.ignore' % tag): os.remove((os.path.join(root, filename))) - def hastagDir(self, folder, tag = None): + def hastagDir(self, folder, tag = ''): if not os.path.isdir(folder): return False # Find any .ignore files for root, dirnames, filenames in os.walk(folder): - if fnmatch.filter(filenames, '%s.ignore' % tag if tag else '*'): + if fnmatch.filter(filenames, '*%s.ignore' % tag): return True return False From d0735a6d5885d821ace5743b9ccc1bf334f5bf3b Mon Sep 17 00:00:00 2001 From: mano3m <-> Date: Sat, 20 Jul 2013 16:01:37 +0200 Subject: [PATCH 30/58] Add failsafe for symlink errors E.g. on Windows you need Admin rights to symlink... --- couchpotato/core/plugins/renamer/main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index 1121691f..d068c483 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -552,7 +552,11 @@ Remove it if you want it to be renamed (again, or at least let it try again) shutil.copy(old, dest) elif self.conf('file_action') == 'move_symlink': shutil.move(old, dest) - symlink(dest, old) + try: + symlink(dest, old) + except: + log.error('Couldn\'t symlink file "%s" to "%s". Copying the file back. Error: %s. ', (old, dest, traceback.format_exc())) + shutil.copy(dest, old) else: shutil.move(old, dest) From 695cdea4476aea8bc462b5adb0b40286f3c98138 Mon Sep 17 00:00:00 2001 From: mano3m <-> Date: Sat, 3 Aug 2013 01:13:36 +0200 Subject: [PATCH 31/58] Remove 'move' exception No need to remove files when 'move' is selected as the downloaders do this themselves now when cleaning up --- couchpotato/core/downloaders/transmission/__init__.py | 2 +- couchpotato/core/downloaders/utorrent/__init__.py | 2 +- couchpotato/core/plugins/renamer/main.py | 10 ++++------ 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/couchpotato/core/downloaders/transmission/__init__.py b/couchpotato/core/downloaders/transmission/__init__.py index d0e8279e..f96e628e 100644 --- a/couchpotato/core/downloaders/transmission/__init__.py +++ b/couchpotato/core/downloaders/transmission/__init__.py @@ -47,7 +47,7 @@ config = [{ { 'name': 'remove_complete', 'label': 'Remove torrent', - 'default': False, + 'default': True, 'advanced': True, 'type': 'bool', 'description': 'Remove the torrent from Transmission after it finished seeding.', diff --git a/couchpotato/core/downloaders/utorrent/__init__.py b/couchpotato/core/downloaders/utorrent/__init__.py index 6a1da36b..d45e2e6c 100644 --- a/couchpotato/core/downloaders/utorrent/__init__.py +++ b/couchpotato/core/downloaders/utorrent/__init__.py @@ -39,7 +39,7 @@ config = [{ { 'name': 'remove_complete', 'label': 'Remove torrent', - 'default': False, + 'default': True, 'advanced': True, 'type': 'bool', 'description': 'Remove the torrent from uTorrent after it finished seeding.', diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index d068c483..dfe1b621 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -204,7 +204,7 @@ class Renamer(Plugin): # Move nfo depending on settings if file_type is 'nfo' and not self.conf('rename_nfo'): log.debug('Skipping, renaming of %s disabled', file_type) - if self.conf('cleanup') and not (self.conf('file_action') != 'move' and self.downloadIsTorrent(download_info)): + if self.conf('cleanup') and not self.downloadIsTorrent(download_info): for current_file in group['files'][file_type]: remove_files.append(current_file) continue @@ -387,7 +387,7 @@ class Renamer(Plugin): # Remove leftover files if self.conf('cleanup') and not self.conf('move_leftover') and remove_leftovers and \ - not (self.conf('file_action') != 'move' and self.downloadIsTorrent(download_info)): + not self.downloadIsTorrent(download_info): log.debug('Removing leftover files') for current_file in group['files']['leftover']: remove_files.append(current_file) @@ -444,8 +444,7 @@ class Renamer(Plugin): self.tagDir(group, 'failed_rename') # Tag folder if it is in the 'from' folder and it will not be removed because it is a torrent - if self.movieInFromFolder(movie_folder) and \ - self.conf('file_action') != 'move' and self.downloadIsTorrent(download_info): + if self.movieInFromFolder(movie_folder) and self.downloadIsTorrent(download_info): self.tagDir(group, 'renamed_already') # Remove matching releases @@ -456,8 +455,7 @@ class Renamer(Plugin): except: log.error('Failed removing %s: %s', (release.identifier, traceback.format_exc())) - if group['dirname'] and group['parentdir'] and \ - not (self.conf('file_action') != 'move' and self.downloadIsTorrent(download_info)): + if group['dirname'] and group['parentdir'] and not self.downloadIsTorrent(download_info): try: log.info('Deleting folder: %s', group['parentdir']) self.deleteEmptyFolder(group['parentdir']) From 70bc2a6656427fa5405889e3c219e7256e55fecc Mon Sep 17 00:00:00 2001 From: mano3m <-> Date: Wed, 21 Aug 2013 20:49:01 +0200 Subject: [PATCH 32/58] use right variable for pause fixes #2049 --- couchpotato/core/downloaders/transmission/main.py | 4 ++-- couchpotato/core/downloaders/utorrent/main.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/couchpotato/core/downloaders/transmission/main.py b/couchpotato/core/downloaders/transmission/main.py index a619d411..12082f64 100644 --- a/couchpotato/core/downloaders/transmission/main.py +++ b/couchpotato/core/downloaders/transmission/main.py @@ -129,9 +129,9 @@ class Transmission(Downloader): def pause(self, item, pause = True): if pause: - return self.trpc.stop_torrent(item['hashString']) + return self.trpc.stop_torrent(item['id']) else: - return self.trpc.start_torrent(item['hashString']) + return self.trpc.start_torrent(item['id']) def removeFailed(self, item): log.info('%s failed downloading, deleting...', item['name']) diff --git a/couchpotato/core/downloaders/utorrent/main.py b/couchpotato/core/downloaders/utorrent/main.py index 79f9f5b9..588d7587 100644 --- a/couchpotato/core/downloaders/utorrent/main.py +++ b/couchpotato/core/downloaders/utorrent/main.py @@ -149,10 +149,10 @@ class uTorrent(Downloader): return statuses - def pause(self, download_info, pause = True): + def pause(self, item, pause = True): if not self.connect(): return False - return self.utorrent_api.pause_torrent(download_info['id'], pause) + return self.utorrent_api.pause_torrent(item['id'], pause) def removeFailed(self, item): log.info('%s failed downloading, deleting...', item['name']) From bf6bcaed723f9930b7594559d8f2e84f8278804e Mon Sep 17 00:00:00 2001 From: mano3m <-> Date: Thu, 22 Aug 2013 21:20:02 +0200 Subject: [PATCH 33/58] provide more info in case no movie is found Several users reported an issue with "more than one group found (0)", and it was unclear to them what it meant. This might help. --- couchpotato/core/plugins/scanner/main.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/couchpotato/core/plugins/scanner/main.py b/couchpotato/core/plugins/scanner/main.py index e48d2747..743b1a56 100644 --- a/couchpotato/core/plugins/scanner/main.py +++ b/couchpotato/core/plugins/scanner/main.py @@ -329,14 +329,17 @@ class Scanner(Plugin): del movie_files + total_found = len(valid_files) + # Make sure only one movie was found if a download ID is provided - if download_info and not len(valid_files) == 1: + if download_info and total_found == 0: + log.info('Download ID provided (%s), but no groups found! Make sure the download contains valid media files (fully extracted).', download_info.get('imdb_id')) + elif download_info and total_found > 1: log.info('Download ID provided (%s), but more than one group found (%s). Ignoring Download ID...', (download_info.get('imdb_id'), len(valid_files))) download_info = None # Determine file types processed_movies = {} - total_found = len(valid_files) while True and not self.shuttingDown(): try: identifier, group = valid_files.popitem() From 6aec5a9a606447526cc6169ddeca723ff4e937af Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 24 Aug 2013 12:13:45 +0200 Subject: [PATCH 34/58] Cleanup IMDB provider --- .../providers/automation/imdb/__init__.py | 12 +- .../core/providers/automation/imdb/main.py | 131 ++++++++---------- 2 files changed, 67 insertions(+), 76 deletions(-) diff --git a/couchpotato/core/providers/automation/imdb/__init__.py b/couchpotato/core/providers/automation/imdb/__init__.py index ee804af1..546cba97 100644 --- a/couchpotato/core/providers/automation/imdb/__init__.py +++ b/couchpotato/core/providers/automation/imdb/__init__.py @@ -38,21 +38,23 @@ config = [{ 'description': 'Import movies from IMDB Charts', 'options': [ { - 'name': 'automation_enabled', + 'name': 'automation_providers_enabled', 'default': False, 'type': 'enabler', }, { - 'name': 'automation_charts_theaters_use', - 'type': 'checkbox', + 'name': 'automation_charts_theater', + 'type': 'bool', 'label': 'In Theaters', 'description': 'New Movies In-Theaters chart', + 'default': True, }, { - 'name': 'automation_charts_top250_use', - 'type': 'checkbox', + 'name': 'automation_charts_top250', + 'type': 'bool', 'label': 'TOP 250', 'description': 'IMDB TOP 250 chart', + 'default': True, }, ], }, diff --git a/couchpotato/core/providers/automation/imdb/main.py b/couchpotato/core/providers/automation/imdb/main.py index 0d494949..c4aef7f1 100644 --- a/couchpotato/core/providers/automation/imdb/main.py +++ b/couchpotato/core/providers/automation/imdb/main.py @@ -1,90 +1,41 @@ +import traceback + from bs4 import BeautifulSoup +from couchpotato import fireEvent from couchpotato.core.helpers.rss import RSS from couchpotato.core.helpers.variable import getImdb, splitString, tryInt + from couchpotato.core.logger import CPLog from couchpotato.core.providers.automation.base import Automation -import re -import traceback + +from couchpotato.core.providers.base import MultiProvider + log = CPLog(__name__) -class IMDB(Automation, RSS): +class IMDB(MultiProvider): + + def getTypes(self): + return [IMDBWatchlist, IMDBAutomation] + + +class IMDBBase(Automation, RSS): interval = 1800 - chart_urls = { - 'theater': 'http://www.imdb.com/movies-in-theaters/', - 'top250': 'http://www.imdb.com/chart/top', - } + def getInfo(self, imdb_id): + return fireEvent('movie.info', identifier = imdb_id, merge = True) +class IMDBWatchlist(IMDBBase): + + enabled_option = 'automation_enabled' + def getIMDBids(self): movies = [] - # Handle Chart URLs - if self.conf('automation_charts_theaters_use'): - log.debug('Started IMDB chart: %s', self.chart_urls['theater']) - data = self.getHTMLData(self.chart_urls['theater']) - if data: - html = BeautifulSoup(data) - - try: - result_div = html.find('div', attrs = {'id': 'main'}) - - entries = result_div.find_all('div', attrs = {'itemtype': 'http://schema.org/Movie'}) - - for entry in entries: - title = entry.find('h4', attrs = {'itemprop': 'name'}).getText() - - log.debug('Identified title: %s', title) - result = re.search('(.*) \((.*)\)', title) - - if result: - name = result.group(1) - year = result.group(2) - - imdb = self.search(name, year) - - if imdb and self.isMinimalMovie(imdb): - movies.append(imdb['imdb']) - - except: - log.error('Failed loading IMDB chart results from %s: %s', (self.chart_urls['theater'], traceback.format_exc())) - - if self.conf('automation_charts_top250_use'): - log.debug('Started IMDB chart: %s', self.chart_urls['top250']) - data = self.getHTMLData(self.chart_urls['top250']) - if data: - html = BeautifulSoup(data) - - try: - result_div = html.find('div', attrs = {'id': 'main'}) - - result_table = result_div.find_all('table')[1] - entries = result_table.find_all('tr') - - for entry in entries[1:]: - title = entry.find_all('td')[2].getText() - - log.debug('Identified title: %s', title) - result = re.search('(.*) \((.*)\)', title) - - if result: - name = result.group(1) - year = result.group(2) - - imdb = self.search(name, year) - - if imdb and self.isMinimalMovie(imdb): - movies.append(imdb['imdb']) - - except: - log.error('Failed loading IMDB chart results from %s: %s', (self.chart_urls['theater'], traceback.format_exc())) - - - # Handle Watchlists watchlist_enablers = [tryInt(x) for x in splitString(self.conf('automation_urls_use'))] watchlist_urls = splitString(self.conf('automation_urls')) @@ -103,9 +54,47 @@ class IMDB(Automation, RSS): for imdb in imdbs: movies.append(imdb) + if self.shuttingDown(): + break + except: log.error('Failed loading IMDB watchlist: %s %s', (url, traceback.format_exc())) - - # Return the combined resultset + return movies + + +class IMDBAutomation(IMDBBase): + + enabled_option = 'automation_providers_enabled' + + chart_urls = { + 'theater': 'http://www.imdb.com/movies-in-theaters/', + 'top250': 'http://www.imdb.com/chart/top', + } + + def getIMDBids(self): + + movies = [] + + for url in self.chart_urls: + if self.conf('automation_charts_%s' % url): + data = self.getHTMLData(self.chart_urls[url]) + if data: + html = BeautifulSoup(data) + + try: + result_div = html.find('div', attrs = {'id': 'main'}) + imdb_ids = getImdb(str(result_div), multiple = True) + + for imdb_id in imdb_ids: + info = self.getInfo(imdb_id) + if info and self.isMinimalMovie(info): + movies.append(imdb_id) + + if self.shuttingDown(): + break + + except: + log.error('Failed loading IMDB chart results from %s: %s', (url, traceback.format_exc())) + return movies From 7e44af936d7186473e5c5fb781df97f102fd5243 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 24 Aug 2013 12:14:02 +0200 Subject: [PATCH 35/58] Watch shutdown when adding automation movies --- couchpotato/core/plugins/automation/main.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/couchpotato/core/plugins/automation/main.py b/couchpotato/core/plugins/automation/main.py index 80e12850..92547cb0 100644 --- a/couchpotato/core/plugins/automation/main.py +++ b/couchpotato/core/plugins/automation/main.py @@ -26,6 +26,10 @@ class Automation(Plugin): movie_ids = [] for imdb_id in movies: + + if self.shuttingDown(): + break + prop_name = 'automation.added.%s' % imdb_id added = Env.prop(prop_name, default = False) if not added: @@ -35,5 +39,11 @@ class Automation(Plugin): Env.prop(prop_name, True) for movie_id in movie_ids: + + if self.shuttingDown(): + break + movie_dict = fireEvent('movie.get', movie_id, single = True) fireEvent('movie.searcher.single', movie_dict) + + return True \ No newline at end of file From cef5b04eb1633b1edde308286f9d8484e2edb1d8 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 24 Aug 2013 12:14:15 +0200 Subject: [PATCH 36/58] Return unique imdb list --- couchpotato/core/helpers/variable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/helpers/variable.py b/couchpotato/core/helpers/variable.py index 381889c0..90caf848 100644 --- a/couchpotato/core/helpers/variable.py +++ b/couchpotato/core/helpers/variable.py @@ -128,7 +128,7 @@ def getImdb(txt, check_inside = True, multiple = False): try: ids = re.findall('(tt\d{7})', txt) if multiple: - return ids if len(ids) > 0 else [] + return list(set(ids)) if len(ids) > 0 else [] return ids[0] except IndexError: pass From ed0e5ef497d3bef71bca0288ea8db8af48522da1 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 24 Aug 2013 12:24:15 +0200 Subject: [PATCH 37/58] XMBC notification, better remote folder description --- couchpotato/core/notifications/xbmc/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/core/notifications/xbmc/__init__.py b/couchpotato/core/notifications/xbmc/__init__.py index f0167ce8..dafa0f63 100644 --- a/couchpotato/core/notifications/xbmc/__init__.py +++ b/couchpotato/core/notifications/xbmc/__init__.py @@ -44,7 +44,7 @@ config = [{ 'default': 0, 'type': 'bool', 'advanced': True, - 'description': 'Scan new movie folder at remote XBMC servers, only works if movie location is the same.', + 'description': 'Only scan new movie folder at remote XBMC servers. Works if movie location is the same.', }, { 'name': 'on_snatch', From e2bd6a91cd467e7464ab3c6503bebf072abefc58 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 24 Aug 2013 13:21:39 +0200 Subject: [PATCH 38/58] MPAA rating for renamer --- couchpotato/core/plugins/renamer/__init__.py | 1 + couchpotato/core/plugins/renamer/main.py | 1 + couchpotato/core/providers/movie/_modifier/main.py | 1 + couchpotato/core/providers/movie/omdbapi/main.py | 1 + couchpotato/core/providers/movie/themoviedb/main.py | 1 + 5 files changed, 5 insertions(+) mode change 100644 => 100755 couchpotato/core/plugins/renamer/__init__.py mode change 100644 => 100755 couchpotato/core/plugins/renamer/main.py mode change 100644 => 100755 couchpotato/core/providers/movie/omdbapi/main.py diff --git a/couchpotato/core/plugins/renamer/__init__.py b/couchpotato/core/plugins/renamer/__init__.py old mode 100644 new mode 100755 index 04cd970d..50cda078 --- a/couchpotato/core/plugins/renamer/__init__.py +++ b/couchpotato/core/plugins/renamer/__init__.py @@ -27,6 +27,7 @@ rename_options = { 'imdb_id': 'IMDB id (tt0123456)', 'cd': 'CD number (cd1)', 'cd_nr': 'Just the cd nr. (1)', + 'mpaa': 'MPAA Rating', }, } diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py old mode 100644 new mode 100755 index 8d1a8186..4f435882 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -205,6 +205,7 @@ class Renamer(Plugin): 'imdb_id': library['identifier'], 'cd': '', 'cd_nr': '', + 'mpaa': library['info'].get('mpaa', ''), } for file_type in group['files']: diff --git a/couchpotato/core/providers/movie/_modifier/main.py b/couchpotato/core/providers/movie/_modifier/main.py index e4d70221..835cce04 100644 --- a/couchpotato/core/providers/movie/_modifier/main.py +++ b/couchpotato/core/providers/movie/_modifier/main.py @@ -28,6 +28,7 @@ class MovieResultModifier(Plugin): 'tagline': '', 'imdb': '', 'genres': [], + 'mpaa': None } def __init__(self): diff --git a/couchpotato/core/providers/movie/omdbapi/main.py b/couchpotato/core/providers/movie/omdbapi/main.py old mode 100644 new mode 100755 index 89990747..c9f4d927 --- a/couchpotato/core/providers/movie/omdbapi/main.py +++ b/couchpotato/core/providers/movie/omdbapi/main.py @@ -95,6 +95,7 @@ class OMDBAPI(MovieProvider): #'rotten': (tryFloat(movie.get('tomatoRating', 0)), tryInt(movie.get('tomatoReviews', '').replace(',', ''))), }, 'imdb': str(movie.get('imdbID', '')), + 'mpaa': str(movie.get('Rated', '')), 'runtime': self.runtimeToMinutes(movie.get('Runtime', '')), 'released': movie.get('Released'), 'year': year if isinstance(year, (int)) else None, diff --git a/couchpotato/core/providers/movie/themoviedb/main.py b/couchpotato/core/providers/movie/themoviedb/main.py index 735419c3..241fc6b0 100644 --- a/couchpotato/core/providers/movie/themoviedb/main.py +++ b/couchpotato/core/providers/movie/themoviedb/main.py @@ -167,6 +167,7 @@ class TheMovieDb(MovieProvider): 'backdrop_original': [backdrop_original] if backdrop_original else [], }, 'imdb': movie.get('imdb_id'), + 'mpaa': movie.get('certification', ''), 'runtime': movie.get('runtime'), 'released': movie.get('released'), 'year': year, From 08554889fd63f54e46f9cca4ef34e765c15b2bed Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 24 Aug 2013 13:34:45 +0200 Subject: [PATCH 39/58] Add the old rottentomatoes to default enabled list --- .../core/providers/automation/rottentomatoes/__init__.py | 4 +++- couchpotato/core/providers/automation/rottentomatoes/main.py | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/couchpotato/core/providers/automation/rottentomatoes/__init__.py b/couchpotato/core/providers/automation/rottentomatoes/__init__.py index 52b1c882..4675fac2 100644 --- a/couchpotato/core/providers/automation/rottentomatoes/__init__.py +++ b/couchpotato/core/providers/automation/rottentomatoes/__init__.py @@ -21,19 +21,21 @@ config = [{ { 'name': 'automation_urls_use', 'label': 'Use', + 'default': '1', }, { 'name': 'automation_urls', 'label': 'url', 'type': 'combined', 'combine': ['automation_urls_use', 'automation_urls'], + 'default': 'http://www.rottentomatoes.com/syndication/rss/in_theaters.xml', }, { 'name': 'tomatometer_percent', 'default': '80', 'label': 'Tomatometer', 'description': 'Use as extra scoring requirement', - } + }, ], }, ], diff --git a/couchpotato/core/providers/automation/rottentomatoes/main.py b/couchpotato/core/providers/automation/rottentomatoes/main.py index 40f72a5c..69611705 100644 --- a/couchpotato/core/providers/automation/rottentomatoes/main.py +++ b/couchpotato/core/providers/automation/rottentomatoes/main.py @@ -12,8 +12,6 @@ class Rottentomatoes(Automation, RSS): interval = 1800 - - def getIMDBids(self): movies = [] From 8e9e7b49eabfa40f8479d081e3e78a52f84587e0 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 24 Aug 2013 14:03:17 +0200 Subject: [PATCH 40/58] Simplify linking Thanks @mano3m --- couchpotato/core/plugins/renamer/__init__.py | 6 ++-- couchpotato/core/plugins/renamer/main.py | 30 +++++++++++--------- 2 files changed, 19 insertions(+), 17 deletions(-) mode change 100755 => 100644 couchpotato/core/plugins/renamer/main.py diff --git a/couchpotato/core/plugins/renamer/__init__.py b/couchpotato/core/plugins/renamer/__init__.py index 50cda078..56672b8e 100755 --- a/couchpotato/core/plugins/renamer/__init__.py +++ b/couchpotato/core/plugins/renamer/__init__.py @@ -120,10 +120,10 @@ config = [{ { 'name': 'file_action', 'label': 'Torrent File Action', - 'default': 'move', + 'default': 'link', 'type': 'dropdown', - 'values': [('Move', 'move'), ('Copy', 'copy'), ('Hard link', 'hardlink'), ('Move & Sym link', 'move_symlink')], - 'description': 'Define which kind of file operation you want to use for torrents. Before you start using hard links or sym links, PLEASE read about their possible drawbacks.', + 'values': [('Link', 'link'), ('Copy', 'copy'), ('Move', 'move')], + 'description': 'Link or Copy after downloading completed (and allow for seeding), or Move after seeding completed. Link first tries hard link, then sym link and falls back to Copy.', 'advanced': True, }, { diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py old mode 100755 new mode 100644 index 508eab24..2b73590b --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -548,21 +548,23 @@ Remove it if you want it to be renamed (again, or at least let it try again) try: if forcemove: shutil.move(old, dest) - elif self.conf('file_action') == 'hardlink': - try: - link(old, dest) - except: - log.error('Couldn\'t hardlink file "%s" to "%s". Copying instead. Error: %s. ', (old, dest, traceback.format_exc())) - shutil.copy(old, dest) elif self.conf('file_action') == 'copy': shutil.copy(old, dest) - elif self.conf('file_action') == 'move_symlink': - shutil.move(old, dest) + elif self.conf('file_action') == 'link': + # First try to hardlink try: - symlink(dest, old) + log.debug('Hardlinking file "%s" to "%s"...', (old, dest)) + link(old, dest) except: - log.error('Couldn\'t symlink file "%s" to "%s". Copying the file back. Error: %s. ', (old, dest, traceback.format_exc())) - shutil.copy(dest, old) + # Try to simlink next + log.debug('Couldn\'t hardlink file "%s" to "%s". Simlinking instead. Error: %s. ', (old, dest, traceback.format_exc())) + shutil.copy(old, dest) + try: + symlink(dest, old + '.link') + os.unlink(old) + os.rename(old + '.link', old) + except: + log.error('Couldn\'t symlink file "%s" to "%s". Copied instead. Error: %s. ', (old, dest, traceback.format_exc())) else: shutil.move(old, dest) @@ -767,10 +769,10 @@ Remove it if you want it to be renamed (again, or at least let it try again) for item in scan_items: # Ask the renamer to scan the item if item['scan']: - if item['pause'] and self.conf('file_action') == 'move_symlink': + if item['pause'] and self.conf('file_action') == 'link': fireEvent('download.pause', item = item, pause = True, single = True) fireEvent('renamer.scan', download_info = item) - if item['pause'] and self.conf('file_action') == 'move_symlink': + if item['pause'] and self.conf('file_action') == 'link': fireEvent('download.pause', item = item, pause = False, single = True) if item['process_complete']: #First make sure the files were succesfully processed @@ -829,6 +831,6 @@ Remove it if you want it to be renamed (again, or at least let it try again) def statusInfoComplete(self, item): return item['id'] and item['downloader'] and item['folder'] - + def movieInFromFolder(self, movie_folder): return movie_folder and self.conf('from') in movie_folder or not movie_folder From 770590e4f2361feaac7fb03106c9f7af17060438 Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 24 Aug 2013 14:08:05 +0200 Subject: [PATCH 41/58] Match default ports Thanks @cpg --- couchpotato/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/couchpotato/runner.py b/couchpotato/runner.py index 0c0127fa..f49ba3e5 100644 --- a/couchpotato/runner.py +++ b/couchpotato/runner.py @@ -212,7 +212,7 @@ def runCouchPotato(options, base_path, args, data_dir = None, log_dir = None, En # app.debug = development config = { 'use_reloader': reloader, - 'port': tryInt(Env.setting('port', default = 5000)), + 'port': tryInt(Env.setting('port', default = 5050)), 'host': host if host and len(host) > 0 else '0.0.0.0', 'ssl_cert': Env.setting('ssl_cert', default = None), 'ssl_key': Env.setting('ssl_key', default = None), From 20aa78105f56a74ba3ad83e2282cd1f27d6e3eae Mon Sep 17 00:00:00 2001 From: Ruud Date: Sat, 24 Aug 2013 14:22:15 +0200 Subject: [PATCH 42/58] Do window size check inside load event --- couchpotato/templates/index.html | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/couchpotato/templates/index.html b/couchpotato/templates/index.html index f9bc4634..d45dcb9b 100644 --- a/couchpotato/templates/index.html +++ b/couchpotato/templates/index.html @@ -22,17 +22,18 @@