diff --git a/.pep8 b/.pep8 index 9dc3362c..a9fb14c7 100644 --- a/.pep8 +++ b/.pep8 @@ -1,5 +1,4 @@ [pep8] -# E111 indentation is not a multiple of four # E121 continuation line under-indented for hanging indent # E122 continuation line missing indentation or outdented # E124 closing bracket does not match visual indentation @@ -10,8 +9,7 @@ # E261 at least two spaces before inline comment # E262 inline comment should start with '# ' # E265 block comment should start with '# ' -# E302 expected 2 blank lines, found 1 # E501 line too long (312 > 160 characters) # E502 the backslash is redundant between brackets -ignore = E111,E121,E122,E123,E124,E125,E126,E127,E128,E261,E262,E265,E302,E501,E502 -max-line-length = 160 \ No newline at end of file +ignore = E121,E122,E124,E125,E126,E127,E128,E261,E262,E265,E501,E502 +max-line-length = 160 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5decff36..66fbfdbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v0.5.12 +Released 25 February 2016 + +This is mostly a hotfix update + +Highlights: +* Added: Experimental Deluge Support +* Fixed: Some pep8 stuff +* Improved: Use curly braces for pathrender optional variables + +The full list of commits can be found [here](https://github.com/rembo10/headphones/compare/v0.5.11...v0.5.12). + ## v0.5.11 Released 20 February 2016 diff --git a/README.md b/README.md index f82e75a5..87eb2f36 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ **Master Branch:** [![Build Status](https://travis-ci.org/rembo10/headphones.svg?branch=master)](https://travis-ci.org/rembo10/headphones) **Develop Branch:** [![Build Status](https://travis-ci.org/rembo10/headphones.svg?branch=develop)](https://travis-ci.org/rembo10/headphones) -Headphones is an automated music downloader for NZB and Torrent, written in Python. It supports SABnzbd, NZBget, Transmission, µTorrent and Blackhole. +Headphones is an automated music downloader for NZB and Torrent, written in Python. It supports SABnzbd, NZBget, Transmission, µTorrent, Deluge and Blackhole. ## Support & Discuss You are free to join the Headphones support community on IRC where you can ask questions, hang around and discuss anything related to HP. diff --git a/data/interfaces/default/config.html b/data/interfaces/default/config.html index 05109089..9ba9f2aa 100644 --- a/data/interfaces/default/config.html +++ b/data/interfaces/default/config.html @@ -311,6 +311,7 @@ Black Hole Transmission uTorrent (Beta) + Deluge (Beta)
@@ -385,6 +386,35 @@
+
+
+ + + Usually http://localhost:8112 (requires WebUI plugin) +
+
+ + +
+
+ Note: With Deluge, you can specify a different download directory for downloads sent from Headphones. + Set it in the Music Download Directory below +
+
+ + + Labels shouldn't contain spaces (requires Label plugin) +
+
+ + + Directory where Deluge should move completed downloads +
+
+ + +
+
@@ -858,7 +888,7 @@
as .jpg
- Use $Artist/$artist, $Album/$album, $Year/$year + Use $Artist/$artist, $Album/$album, $Year/$year, put optional variables in square brackets, use single-quote marks to escape square brackets literally ('[', ']').
+
+
+ +
+
+
+ Contact @BotFather to create a bot and get its token +
+
+ Contact @myidbot to get your user ID +
+
+ +
+
+
+ @@ -1236,14 +1283,13 @@
- Use: $Artist/$artist, $SortArtist/$sortartist, $Album/$album, $Year/$year, $Type/$type (release type) and $First/$first (first letter in artist name), $OriginalFolder/$originalfolder (downloaded directory name) - E.g.: $Type/$First/$artist/$album [$year] = Album/G/girl talk/all day [2010] + Use: $Artist/$artist, $SortArtist/$sortartist, $Album/$album, $Year/$year, $Type/$type (release type) and $First/$first (first letter in artist name), $OriginalFolder/$originalfolder (downloaded directory name). Put optional variables in square brackets, use single-quote marks to escape square brackets literally ('[', ']').
E.g.: $Type/$First/$artist/$album '['$year']' = Album/G/girl talk/all day [2010]
- Use: $Disc/$disc (disc #), $Track/$track (track #), $Title/$title, $Artist/$artist, $Album/$album and $Year/$year + Use: $Disc/$disc (disc #), $Track/$track (track #), $Title/$title, $Artist/$artist, $Album/$album and $Year/$year. Put optional variables in square brackets, use single-quote marks to escape square brackets literally ('[', ']').
@@ -2003,6 +2049,26 @@ } }); + if ($("#telegram").is(":checked")) + { + $("#telegramoptions").show(); + } + else + { + $("#telegramoptions").hide(); + } + + $("#telegram").click(function(){ + if ($("#telegram").is(":checked")) + { + $("#telegramoptions").slideDown(); + } + else + { + $("#telegramoptions").slideUp(); + } + }); + if ($("#osx_notify").is(":checked")) { $("#osx_notify_options").show(); @@ -2159,19 +2225,25 @@ if ($("#torrent_downloader_blackhole").is(":checked")) { - $("#transmission_options,#utorrent_options").hide(); + $("#transmission_options,#utorrent_options,#deluge_options").hide(); $("#torrent_blackhole_options").show(); } if ($("#torrent_downloader_transmission").is(":checked")) { - $("#torrent_blackhole_options,#utorrent_options").hide(); + $("#torrent_blackhole_options,#utorrent_options,#deluge_options").hide(); $("#transmission_options").show(); } if ($("#torrent_downloader_utorrent").is(":checked")) { - $("#torrent_blackhole_options,#transmission_options").hide(); + $("#torrent_blackhole_options,#transmission_options,#deluge_options").hide(); $("#utorrent_options").show(); } + if ($("#torrent_downloader_deluge").is(":checked")) + { + $("#torrent_blackhole_options,#transmission_options,#utorrent_options").hide(); + $("#deluge_options").show(); + } + $('input[type=radio]').change(function(){ if ($("#preferred_bitrate").is(":checked")) @@ -2208,15 +2280,19 @@ } if ($("#torrent_downloader_blackhole").is(":checked")) { - $("#transmission_options,#utorrent_options").fadeOut("fast", function() { $("#torrent_blackhole_options").fadeIn() }); + $("#transmission_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#torrent_blackhole_options").fadeIn() }); } if ($("#torrent_downloader_transmission").is(":checked")) { - $("#torrent_blackhole_options,#utorrent_options").fadeOut("fast", function() { $("#transmission_options").fadeIn() }); + $("#torrent_blackhole_options,#utorrent_options,#deluge_options").fadeOut("fast", function() { $("#transmission_options").fadeIn() }); } if ($("#torrent_downloader_utorrent").is(":checked")) { - $("#torrent_blackhole_options,#transmission_options").fadeOut("fast", function() { $("#utorrent_options").fadeIn() }); + $("#torrent_blackhole_options,#transmission_options,#deluge_options").fadeOut("fast", function() { $("#utorrent_options").fadeIn() }); + } + if ($("#torrent_downloader_deluge").is(":checked")) + { + $("#torrent_blackhole_options,#utorrent_options,#transmission_options").fadeOut("fast", function() { $("#deluge_options").fadeIn() }); } }); diff --git a/headphones/__init__.py b/headphones/__init__.py index 92b274a9..4b36acf7 100644 --- a/headphones/__init__.py +++ b/headphones/__init__.py @@ -94,6 +94,7 @@ MIRRORLIST = ["musicbrainz.org", "headphones", "custom"] UMASK = None + def initialize(config_file): with INIT_LOCK: diff --git a/headphones/albumart_test.py b/headphones/albumart_test.py index f18b11e3..e1b80c02 100644 --- a/headphones/albumart_test.py +++ b/headphones/albumart_test.py @@ -4,6 +4,7 @@ from headphones.unittestcompat import TestCase import headphones.albumart + # no tests... class AlbumArtTest(TestCase): def test_nothing(self): diff --git a/headphones/config.py b/headphones/config.py index 97675305..dd1ae9b6 100644 --- a/headphones/config.py +++ b/headphones/config.py @@ -15,6 +15,7 @@ def bool_int(value): value = 0 return int(bool(value)) + class path(str): """Internal 'marker' type for paths in config.""" @@ -66,7 +67,12 @@ _CONFIG_DEFINITIONS = { 'CUSTOMSLEEP': (int, 'General', 1), 'CUSTOMUSER': (str, 'General', ''), 'DELETE_LOSSLESS_FILES': (int, 'General', 1), - 'DESTINATION_DIR': (path, 'General', ''), + 'DELUGE_HOST': (str, 'Deluge', ''), + 'DELUGE_PASSWORD': (str, 'Deluge', ''), + 'DELUGE_LABEL': (str, 'Deluge', ''), + 'DELUGE_DONE_DIRECTORY': (str, 'Deluge', ''), + 'DELUGE_PAUSED': (int, 'Deluge', 0), + 'DESTINATION_DIR': (str, 'General', ''), 'DETECT_BITRATE': (int, 'General', 0), 'DO_NOT_PROCESS_UNMATCHED': (int, 'General', 0), 'DOWNLOAD_DIR': (path, 'General', ''), @@ -248,7 +254,11 @@ _CONFIG_DEFINITIONS = { 'SUBSONIC_PASSWORD': (str, 'Subsonic', ''), 'SUBSONIC_USERNAME': (str, 'Subsonic', ''), 'SYNOINDEX_ENABLED': (int, 'Synoindex', 0), - 'TORRENTBLACKHOLE_DIR': (path, 'General', ''), + 'TELEGRAM_TOKEN': (str, 'Telegram', ''), + 'TELEGRAM_USERID': (str, 'Telegram', ''), + 'TELEGRAM_ENABLED': (int, 'Telegram', 0), + 'TELEGRAM_ONSNATCH': (int, 'Telegram', 0), + 'TORRENTBLACKHOLE_DIR': (str, 'General', ''), 'TORRENT_DOWNLOADER': (int, 'General', 0), 'TORRENT_REMOVAL_INTERVAL': (int, 'General', 720), 'TORZNAB': (int, 'Torznab', 0), diff --git a/headphones/config_test.py b/headphones/config_test.py index e321d4af..1cd5367f 100644 --- a/headphones/config_test.py +++ b/headphones/config_test.py @@ -5,6 +5,7 @@ import re import unittestcompat from unittestcompat import TestCase, TestArgs + class ConfigApiTest(TestCase): """ Common tests for headphones.Config diff --git a/headphones/deluge.py b/headphones/deluge.py new file mode 100644 index 00000000..5f79d29e --- /dev/null +++ b/headphones/deluge.py @@ -0,0 +1,460 @@ +# This file is part of Headphones. +# +# Headphones is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Headphones is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Headphones. If not, see . + +# Parts of this file are a part of SickRage. +# Author: Mr_Orange +# URL: http://code.google.com/p/sickbeard/ +# Adapted for Headphones by +# URL: https://github.com/noam09 +# +# SickRage is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# SickRage is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with SickRage. If not, see . + +from __future__ import unicode_literals + +from headphones import logger +#from headphones import request + +import time +import re +import os +import json +import headphones +import requests +from base64 import b64encode +import traceback + +delugeweb_auth = {} +delugeweb_url = '' + + +def addTorrent(link, data=None): + try: + result = {} + retid = False + + if link.startswith('magnet:'): + logger.debug('Deluge: Got a magnet link: %s' % link) + result = {'type': 'magnet', + 'url': link} + retid = _add_torrent_magnet(result) + + elif link.startswith('http://') or link.startswith('https://'): + logger.debug('Deluge: Got a URL: %s' % link) + user_agent = 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2243.2 Safari/537.36' + headers = {'User-Agent': user_agent} + torrentfile = '' + logger.debug('Deluge: Trying to download (GET)') + try: + r = requests.get(link, headers=headers) + if r.status_code == 200: + logger.debug('Deluge: 200 OK') + torrentfile = r.text + #for chunk in r.iter_content(chunk_size=1024): + # if chunk: # filter out keep-alive new chunks + # torrentfile = torrentfile + chunk + else: + logger.debug('Deluge: Trying to GET %s returned status %d' % (link, r.status_code)) + return False + except Exception as e: + logger.debug('Deluge: Download failed: %s' % str(e)) + if 'announce' not in torrentfile[:40]: + logger.debug('Deluge: Contents of %s doesn\'t look like a torrent file' % link) + return False + # Extract torrent name from .torrent + try: + logger.debug('Deluge: Getting torrent name length') + name_length = int(re.findall('name([0-9]*)\:.*?\:', torrentfile)[0]) + logger.debug('Deluge: Getting torrent name') + name = re.findall('name[0-9]*\:(.*?)\:', torrentfile)[0][:name_length] + except Exception as e: + logger.debug('Deluge: Could not get torrent name, getting file name') + # get last part of link/path (name only) + name = link.split('\\')[-1].split('/')[-1] + # remove '.torrent' suffix + if name[-len('.torrent'):] == '.torrent': + name = name[:-len('.torrent')] + logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, torrentfile[:40])) + result = {'type': 'torrent', + 'name': name, + 'content': torrentfile} + retid = _add_torrent_file(result) + + # elif link.endswith('.torrent') or data: + elif not (link.startswith('http://') or link.startswith('https://')): + if data: + logger.debug('Deluge: Getting .torrent data') + torrentfile = data + else: + logger.debug('Deluge: Getting .torrent file') + with open(link, 'rb') as f: + torrentfile = f.read() + # Extract torrent name from .torrent + try: + logger.debug('Deluge: Getting torrent name length') + name_length = int(re.findall('name([0-9]*)\:.*?\:', torrentfile)[0]) + logger.debug('Deluge: Getting torrent name') + name = re.findall('name[0-9]*\:(.*?)\:', torrentfile)[0][:name_length] + except Exception as e: + logger.debug('Deluge: Could not get torrent name, getting file name') + # get last part of link/path (name only) + name = link.split('\\')[-1].split('/')[-1] + # remove '.torrent' suffix + if name[-len('.torrent'):] == '.torrent': + name = name[:-len('.torrent')] + logger.debug('Deluge: Sending Deluge torrent with name %s and content [%s...]' % (name, torrentfile[:40])) + result = {'type': 'torrent', + 'name': name, + 'content': torrentfile} + retid = _add_torrent_file(result) + + else: + logger.error('Deluge: Unknown file type: %s' % link) + + if retid: + logger.info('Deluge: Torrent sent to Deluge successfully (%s)' % retid) + return retid + else: + logger.info('Deluge returned status %s' % retid) + return False + + except Exception as e: + logger.error(str(e)) + formatted_lines = traceback.format_exc().splitlines() + logger.error('; '.join(formatted_lines)) + + +def getTorrentFolder(result): + logger.debug('Deluge: Get torrent folder name') + if not any(delugeweb_auth): + _get_auth() + + try: + post_data = json.dumps({"method": "web.get_torrent_status", + "params": [ + result['hash'], + ["total_done"] + ], + "id": 22}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + result['total_done'] = json.loads(response.text)['result']['total_done'] + + tries = 0 + while result['total_done'] == 0 and tries < 10: + tries += 1 + time.sleep(5) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + result['total_done'] = json.loads(response.text)['result']['total_done'] + + post_data = json.dumps({"method": "web.get_torrent_status", + "params": [ + result['hash'], + [ + "name", + "save_path", + "total_size", + "num_files", + "message", + "tracker", + "comment" + ] + ], + "id": 23}) + + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + + result['save_path'] = json.loads(response.text)['result']['save_path'] + result['name'] = json.loads(response.text)['result']['name'] + + return json.loads(response.text)['result']['name'] + except Exception as e: + logger.debug('Deluge: Could not get torrent folder name: %s' % str(e)) + + +def removeTorrent(torrentid, remove_data=False): + + if not any(delugeweb_auth): + _get_auth() + + result = False + post_data = json.dumps({"method": "core.remove_torrent", + "params": [ + torrentid, + remove_data + ], + "id": 25}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + result = json.loads(response.text)['result'] + + return result + + +def _get_auth(): + logger.debug('Deluge: Authenticating...') + global delugeweb_auth, delugeweb_url + delugeweb_auth = {} + + delugeweb_host = headphones.CONFIG.DELUGE_HOST + delugeweb_password = headphones.CONFIG.DELUGE_PASSWORD + + if not delugeweb_host.startswith('http'): + delugeweb_host = 'http://%s' % delugeweb_host + + if delugeweb_host.endswith('/'): + delugeweb_host = delugeweb_host[:-1] + + delugeweb_url = delugeweb_host + '/json' + + post_data = json.dumps({"method": "auth.login", + "params": [delugeweb_password], + "id": 1}) + try: + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + # , verify=TORRENT_VERIFY_CERT) + except Exception: + return None + + auth = json.loads(response.text)["result"] + delugeweb_auth = response.cookies + + post_data = json.dumps({"method": "web.connected", + "params": [], + "id": 10}) + try: + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + # , verify=TORRENT_VERIFY_CERT) + except Exception: + return None + + connected = json.loads(response.text)['result'] + + if not connected: + post_data = json.dumps({"method": "web.get_hosts", + "params": [], + "id": 11}) + try: + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + # , verify=TORRENT_VERIFY_CERT) + except Exception: + return None + + delugeweb_hosts = json.loads(response.text)['result'] + if len(delugeweb_hosts) == 0: + logger.error('Deluge: WebUI does not contain daemons') + return None + + post_data = json.dumps({"method": "web.connect", + "params": [delugeweb_hosts[0][0]], + "id": 11}) + + try: + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + # , verify=TORRENT_VERIFY_CERT) + except Exception: + return None + + post_data = json.dumps({"method": "web.connected", + "params": [], + "id": 10}) + + try: + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + # , verify=TORRENT_VERIFY_CERT) + except Exception: + return None + + connected = json.loads(response.text)['result'] + + if not connected: + logger.error('Deluge: WebUI could not connect to daemon') + return None + + return auth + + +def _add_torrent_magnet(result): + logger.debug('Deluge: Adding magnet') + if not any(delugeweb_auth): + _get_auth() + try: + post_data = json.dumps({"method": "core.add_torrent_magnet", + "params": [result['url'], {}], + "id": 2}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + result['hash'] = json.loads(response.text)['result'] + logger.debug('Deluge: Response was %s' % str(json.loads(response.text)['result'])) + return json.loads(response.text)['result'] + except Exception as e: + logger.error('Deluge: Adding torrent magnet failed: %s' % str(e)) + +''' +def _add_torrent_url(result): + logger.debug('Deluge: Adding URL') + if not any(delugeweb_auth): + _get_auth() + try: + post_data = json.dumps({"method": "web.download_torrent_from_url", + "params": [result['url'], {}], + "id": 2}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + result['hash'] = json.loads(response.text)['result'] + logger.debug('Deluge: Response was %s' % str(json.loads(response.text)['result'])) + return json.loads(response.text)['result'] + except Exception as e: + logger.error('Deluge: Adding torrent URL failed: %s' % str(e)) +''' + + +def _add_torrent_file(result): + logger.debug('Deluge: Adding file') + if not any(delugeweb_auth): + _get_auth() + try: + # content is torrent file contents that needs to be encoded to base64 + post_data = json.dumps({"method": "core.add_torrent_file", + "params": [result['name'] + '.torrent', b64encode(result['content'].encode('utf8')), {}], + "id": 2}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + result['hash'] = json.loads(response.text)['result'] + logger.debug('Deluge: Response was %s' % str(json.loads(response.text)['result'])) + return json.loads(response.text)['result'] + except Exception as e: + logger.error('Deluge: Adding torrent file failed: %s' % str(e)) + formatted_lines = traceback.format_exc().splitlines() + logger.error('; '.join(formatted_lines)) + + +def setTorrentLabel(result): + logger.debug('Deluge: Setting label') + label = headphones.CONFIG.DELUGE_LABEL + + if not any(delugeweb_auth): + _get_auth() + + if ' ' in label: + logger.error('Deluge: Invalid label. Label can\'t contain spaces - replacing with underscores') + label = label.replace(' ', '_') + if label: + # check if label already exists and create it if not + post_data = json.dumps({"method": 'label.get_labels', + "params": [], + "id": 3}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + labels = json.loads(response.text)['result'] + + if labels is not None: + if label not in labels: + try: + logger.debug('Deluge: %s label doesn\'t exist in Deluge, let\'s add it' % label) + post_data = json.dumps({"method": 'label.add', + "params": [label], + "id": 4}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + logger.debug('Deluge: %s label added to Deluge' % label) + except Exception as e: + logger.error('Deluge: Setting label failed: %s' % str(e)) + formatted_lines = traceback.format_exc().splitlines() + logger.error('; '.join(formatted_lines)) + + # add label to torrent + post_data = json.dumps({"method": 'label.set_torrent', + "params": [result['hash'], label], + "id": 5}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + logger.debug('Deluge: %s label added to torrent' % label) + else: + logger.debug('Deluge: Label plugin not detected') + return False + + return not json.loads(response.text)['error'] + + +def setSeedRatio(result): + logger.debug('Deluge: Setting seed ratio') + if not any(delugeweb_auth): + _get_auth() + + ratio = None + if result['ratio']: + ratio = result['ratio'] + + if ratio: + post_data = json.dumps({"method": "core.set_torrent_stop_at_ratio", + "params": [result['hash'], True], + "id": 5}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + post_data = json.dumps({"method": "core.set_torrent_stop_ratio", + "params": [result['hash'], float(ratio)], + "id": 6}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + + return not json.loads(response.text)['error'] + + return True + + +def setTorrentPath(result): + logger.debug('Deluge: Setting download path') + if not any(delugeweb_auth): + _get_auth() + + if headphones.CONFIG.DELUGE_DONE_DIRECTORY or headphones.CONFIG.DOWNLOAD_TORRENT_DIR: + post_data = json.dumps({"method": "core.set_torrent_move_completed", + "params": [result['hash'], True], + "id": 7}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + + if headphones.CONFIG.DELUGE_DONE_DIRECTORY: + move_to = headphones.CONFIG.DELUGE_DONE_DIRECTORY + else: + move_to = headphones.CONFIG.DOWNLOAD_TORRENT_DIR + + if not os.path.exists(move_to): + logger.debug('Deluge: %s directory doesn\'t exist, let\'s create it' % move_to) + os.makedirs(move_to) + post_data = json.dumps({"method": "core.set_torrent_move_completed_path", + "params": [result['hash'], move_to], + "id": 8}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + + return not json.loads(response.text)['error'] + + return True + + +def setTorrentPause(result): + logger.debug('Deluge: Pausing torrent') + if not any(delugeweb_auth): + _get_auth() + + if headphones.CONFIG.DELUGE_PAUSED: + post_data = json.dumps({"method": "core.pause_torrent", + "params": [[result['hash']]], + "id": 9}) + response = requests.post(delugeweb_url, data=post_data.encode('utf-8'), cookies=delugeweb_auth) + + return not json.loads(response.text)['error'] + + return True diff --git a/headphones/exceptions.py b/headphones/exceptions.py index 107535c5..562dc8c3 100644 --- a/headphones/exceptions.py +++ b/headphones/exceptions.py @@ -25,6 +25,7 @@ class NewzbinAPIThrottled(HeadphonesException): Newzbin has throttled us, deal with it """ + class SoftChrootError(HeadphonesException): """ Fatal errors in SoftChroot module diff --git a/headphones/notifiers.py b/headphones/notifiers.py index 540107a7..d22303cc 100644 --- a/headphones/notifiers.py +++ b/headphones/notifiers.py @@ -856,3 +856,36 @@ class Email(object): except Exception, e: logger.warn('Error sending Email: %s' % e) return False + + +class TELEGRAM(object): + + def notify(self, message, status): + if not headphones.CONFIG.TELEGRAM_ENABLED: + return + + import requests + + TELEGRAM_API = "https://api.telegram.org/bot%s/%s" + + # Get configuration data + token = headphones.CONFIG.TELEGRAM_TOKEN + userid = headphones.CONFIG.TELEGRAM_USERID + + # Construct message + payload = {'chat_id': userid, 'text': status + ': ' + message} + + # Send message to user using Telegram's Bot API + try: + response = requests.post(TELEGRAM_API % (token, "sendMessage"), data=payload) + except Exception, e: + logger.info(u'Telegram notify failed: ' + str(e)) + + # Error logging + sent_successfuly = True + if not response.status_code == 200: + logger.info(u'Could not send notification to TelegramBot (token=%s). Response: [%s]', (token, response.text)) + sent_successfuly = False + + logger.info(u"Telegram notifications sent.") + return sent_successfuly diff --git a/headphones/pathrender.py b/headphones/pathrender.py index a00a1192..162c6956 100644 --- a/headphones/pathrender.py +++ b/headphones/pathrender.py @@ -25,15 +25,16 @@ * substitution variables, which start with dollar sign ($) and extend until next non-alphanumeric+underscore character (like $This and $5_that). - * optional elements enclosed in square brackets, which render + * optional elements enclosed in curly braces, which render nonempty value only if any variable or optional inside returned - nonempty value, ignoring literals (like [\'[\'$That\']\' ]). + nonempty value, ignoring literals (like {\'[\'$That\']\'}). ''' from __future__ import print_function from enum import Enum __author__ = "Andrzej Ciarkowski " + class _PatternElement(object): '''ABC for hierarchy of path name renderer pattern elements.''' def render(self, replacement): @@ -41,11 +42,13 @@ class _PatternElement(object): '''Format this _PatternElement into string using provided substitution dictionary.''' raise NotImplementedError() + class _Generator(_PatternElement): # pylint: disable=abstract-method '''Tagging interface for "content-generating" elements like replacement or optional block.''' pass + class _Replacement(_Generator): '''Replacement variable, eg. $title.''' def __init__(self, pattern): @@ -90,20 +93,23 @@ class _OptionalBlock(_Generator): return u"" -_OPTIONAL_START = u'[' -_OPTIONAL_END = u']' +_OPTIONAL_START = u'{' +_OPTIONAL_END = u'}' _ESCAPE_CHAR = u'\'' _REPLACEMENT_START = u'$' + def _is_replacement_valid(c): # type: (str) -> bool return c.isalnum() or c == u'_' + class _State(Enum): LITERAL = 0 ESCAPE = 1 REPLACEMENT = 2 + def _append_literal(scope, text): # type: ([_PatternElement], str) -> None '''Append literal text to the scope BUT ONLY if it's not an empty string.''' @@ -111,11 +117,13 @@ def _append_literal(scope, text): return scope.append(_LiteralText(text)) + class Warnings(Enum): '''Pattern parsing warnings, as stored withing warnings property of Pattern object after parsing.''' UNCLOSED_ESCAPE = 'Warnings.UNCLOSED_ESCAPE' UNCLOSED_OPTIONAL = 'Warnings.UNCLOSED_OPTIONAL' + def _parse_pattern(pattern, warnings): # type: (str,MutableSet[Warnings]) -> [_PatternElement] '''Parse path pattern text into list of _PatternElements, put warnings into the provided set.''' @@ -188,6 +196,7 @@ def _parse_pattern(pattern, warnings): _append_literal(root_scope, pattern[start:]) return root_scope + class Pattern(object): '''Stores preparsed rename pattern for repeated use. diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py index 125b02d7..507a5571 100755 --- a/headphones/postprocessor.py +++ b/headphones/postprocessor.py @@ -27,7 +27,7 @@ from beets import autotag from beets import config as beetsconfig from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError from beetsplug import lyrics as beetslyrics -from headphones import notifiers, utorrent, transmission +from headphones import notifiers, utorrent, transmission, deluge from headphones import db, albumart, librarysync from headphones import logger, helpers, request, mb, music_encoder @@ -46,7 +46,10 @@ def checkFolder(): if album['Kind'] == 'nzb': download_dir = headphones.CONFIG.DOWNLOAD_DIR else: - download_dir = headphones.CONFIG.DOWNLOAD_TORRENT_DIR + if headphones.CONFIG.DELUGE_DONE_DIRECTORY: + download_dir = headphones.CONFIG.DELUGE_DONE_DIRECTORY + else: + download_dir = headphones.CONFIG.DOWNLOAD_TORRENT_DIR album_path = os.path.join(download_dir, album['FolderName']).encode( headphones.SYS_ENCODING, 'replace') @@ -454,6 +457,8 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, release['ArtistName'], release['AlbumTitle'])) if headphones.CONFIG.TORRENT_DOWNLOADER == 1: torrent_removed = transmission.removeTorrent(hash, True) + elif headphones.CONFIG.TORRENT_DOWNLOADER == 3: # Deluge + torrent_removed = deluge.removeTorrent(hash, True) else: torrent_removed = utorrent.removeTorrent(hash, True) @@ -533,6 +538,11 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list, pushbullet = notifiers.PUSHBULLET() pushbullet.notify(pushmessage, statusmessage) + if headphones.CONFIG.TELEGRAM_ENABLED: + logger.info(u"Telegram request") + telegram = notifiers.TELEGRAM() + telegram.notify(pushmessage, statusmessage) + if headphones.CONFIG.TWITTER_ENABLED: logger.info(u"Sending Twitter notification") twitter = notifiers.TwitterNotifier() @@ -666,9 +676,9 @@ def renameNFO(albumpath): def moveFiles(albumpath, release, tracks): logger.info("Moving files: %s" % albumpath) try: - date = release['ReleaseDate'] + date = release['ReleaseDate'] except TypeError: - date = u'' + date = u'' year = date[:4] artist = release['ArtistName'].replace('/', '_') album = release['AlbumTitle'].replace('/', '_') diff --git a/headphones/searcher.py b/headphones/searcher.py index 91489a23..5c8b4144 100644 --- a/headphones/searcher.py +++ b/headphones/searcher.py @@ -33,7 +33,7 @@ from pygazelle import format as gazelleformat import headphones from headphones.common import USER_AGENT from headphones import logger, db, helpers, classes, sab, nzbget, request -from headphones import utorrent, transmission, notifiers, rutracker +from headphones import utorrent, transmission, notifiers, rutracker, deluge from bencode import bencode, bdecode @@ -851,7 +851,7 @@ def send_to_downloader(data, bestqual, album): else: logger.error("Cannot save magnet link in blackhole. " \ "Please switch your torrent downloader to " \ - "Transmission or uTorrent, or allow Headphones " \ + "Transmission, uTorrent or Deluge, or allow Headphones " \ "to open or convert magnet links") return else: @@ -889,6 +889,48 @@ def send_to_downloader(data, bestqual, album): if seed_ratio is not None: transmission.setSeedRatio(torrentid, seed_ratio) + elif headphones.CONFIG.TORRENT_DOWNLOADER == 3: # Deluge + logger.info("Sending torrent to Deluge") + + try: + # Add torrent + if bestqual[3] == 'rutracker.org': + torrentid = deluge.addTorrent('', data) + else: + torrentid = deluge.addTorrent(bestqual[2]) + + if not torrentid: + logger.error("Error sending torrent to Deluge. Are you sure it's running? Maybe the torrent already exists?") + return + + # This pauses the torrent right after it is added + if headphones.CONFIG.DELUGE_PAUSED: + deluge.setTorrentPause({'hash': torrentid}) + + # Set Label + if headphones.CONFIG.DELUGE_LABEL: + deluge.setTorrentLabel({'hash': torrentid}) + + # Set Seed Ratio + seed_ratio = get_seed_ratio(bestqual[3]) + if seed_ratio is not None: + deluge.setSeedRatio({'hash': torrentid, 'ratio': seed_ratio}) + + # Set move-to directory + if headphones.CONFIG.DELUGE_DONE_DIRECTORY: + deluge.setTorrentPath({'hash': torrentid}) + + # I only just realized this function is useless... + folder_name = deluge.getTorrentFolder({'hash': torrentid}) + if folder_name: + logger.info('Torrent folder name: %s' % folder_name) + else: + logger.error('Torrent folder name could not be determined') + return + + except Exception as e: + logger.error('Error sending torrent to Deluge: %s' % str(e)) + else: # if headphones.CONFIG.TORRENT_DOWNLOADER == 2: logger.info("Sending torrent to uTorrent") @@ -960,6 +1002,10 @@ def send_to_downloader(data, bestqual, album): logger.info(u"Sending PushBullet notification") pushbullet = notifiers.PUSHBULLET() pushbullet.notify(name, "Download started") + if headphones.CONFIG.TELEGRAM_ENABLED and headphones.CONFIG.TELEGRAM_ONSNATCH: + logger.info(u"Sending Telegram notification") + telegram = notifiers.TELEGRAM() + telegram.notify(name, "Download started") if headphones.CONFIG.TWITTER_ENABLED and headphones.CONFIG.TWITTER_ONSNATCH: logger.info(u"Sending Twitter notification") twitter = notifiers.TwitterNotifier() diff --git a/headphones/softchroot.py b/headphones/softchroot.py index 80878548..3716a035 100644 --- a/headphones/softchroot.py +++ b/headphones/softchroot.py @@ -1,6 +1,7 @@ import os from headphones.exceptions import SoftChrootError + class SoftChroot(object): """ SoftChroot provides SOFT chrooting for UI diff --git a/headphones/softchroot_test.py b/headphones/softchroot_test.py index baa56703..474969d7 100644 --- a/headphones/softchroot_test.py +++ b/headphones/softchroot_test.py @@ -6,6 +6,7 @@ from headphones.unittestcompat import TestCase, TestArgs from headphones.softchroot import SoftChroot from headphones.exceptions import SoftChrootError + class SoftChrootTest(TestCase): def test_create(self): """ create headphones.SoftChroot """ diff --git a/headphones/transmission.py b/headphones/transmission.py index 11da8989..b62e2fac 100644 --- a/headphones/transmission.py +++ b/headphones/transmission.py @@ -29,6 +29,7 @@ import headphones _session_id = None + def addTorrent(link, data=None): method = 'torrent-add' diff --git a/headphones/unittestcompat.py b/headphones/unittestcompat.py index 12a497be..0bf26c3c 100644 --- a/headphones/unittestcompat.py +++ b/headphones/unittestcompat.py @@ -14,6 +14,7 @@ _dummy = False if sys.version_info[0] == 2 and sys.version_info[1] <= 6: _dummy = True + def _d(f): def decorate(self, *args, **kw): if not _dummy: @@ -92,6 +93,7 @@ class TestCase(TC): # True indicates, that exception is handled return True + def TestArgs(*parameters): def tuplify(x): if not isinstance(x, tuple): diff --git a/headphones/webserve.py b/headphones/webserve.py index ee1000de..9253e058 100644 --- a/headphones/webserve.py +++ b/headphones/webserve.py @@ -1156,6 +1156,11 @@ class WebInterface(object): "transmission_host": headphones.CONFIG.TRANSMISSION_HOST, "transmission_username": headphones.CONFIG.TRANSMISSION_USERNAME, "transmission_password": headphones.CONFIG.TRANSMISSION_PASSWORD, + "deluge_host": headphones.CONFIG.DELUGE_HOST, + "deluge_password": headphones.CONFIG.DELUGE_PASSWORD, + "deluge_label": headphones.CONFIG.DELUGE_LABEL, + "deluge_done_directory": headphones.CONFIG.DELUGE_DONE_DIRECTORY, + "deluge_paused": checked(headphones.CONFIG.DELUGE_PAUSED), "utorrent_host": headphones.CONFIG.UTORRENT_HOST, "utorrent_username": headphones.CONFIG.UTORRENT_USERNAME, "utorrent_password": headphones.CONFIG.UTORRENT_PASSWORD, @@ -1166,6 +1171,7 @@ class WebInterface(object): "torrent_downloader_blackhole": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 0), "torrent_downloader_transmission": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 1), "torrent_downloader_utorrent": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 2), + "torrent_downloader_deluge": radio(headphones.CONFIG.TORRENT_DOWNLOADER, 3), "download_dir": headphones.CONFIG.DOWNLOAD_DIR, "use_blackhole": checked(headphones.CONFIG.BLACKHOLE), "blackhole_dir": headphones.CONFIG.BLACKHOLE_DIR, @@ -1324,6 +1330,10 @@ class WebInterface(object): "pushbullet_onsnatch": checked(headphones.CONFIG.PUSHBULLET_ONSNATCH), "pushbullet_apikey": headphones.CONFIG.PUSHBULLET_APIKEY, "pushbullet_deviceid": headphones.CONFIG.PUSHBULLET_DEVICEID, + "telegram_enabled": checked(headphones.CONFIG.TELEGRAM_ENABLED), + "telegram_onsnatch": checked(headphones.CONFIG.TELEGRAM_ONSNATCH), + "telegram_token": headphones.CONFIG.TELEGRAM_TOKEN, + "telegram_userid": headphones.CONFIG.TELEGRAM_USERID, "subsonic_enabled": checked(headphones.CONFIG.SUBSONIC_ENABLED), "subsonic_host": headphones.CONFIG.SUBSONIC_HOST, "subsonic_username": headphones.CONFIG.SUBSONIC_USERNAME, @@ -1428,10 +1438,11 @@ class WebInterface(object): "synoindex_enabled", "pushover_enabled", "pushover_onsnatch", "pushbullet_enabled", "pushbullet_onsnatch", "subsonic_enabled", "twitter_enabled", "twitter_onsnatch", + "telegram_enabled", "telegram_onsnatch", "osx_notify_enabled", "osx_notify_onsnatch", "boxcar_enabled", "boxcar_onsnatch", "songkick_enabled", "songkick_filter_enabled", "mpc_enabled", "email_enabled", "email_ssl", "email_tls", "email_onsnatch", - "customauth", "idtag" + "customauth", "idtag", "deluge_paused" ] for checked_config in checked_configs: if checked_config not in kwargs: @@ -1692,6 +1703,12 @@ class WebInterface(object): pushbullet = notifiers.PUSHBULLET() pushbullet.notify("it works!", "Test message") + @cherrypy.expose + def testTelegram(self): + logger.info("Testing Telegram notifications") + telegram = notifiers.TELEGRAM() + telegram.notify("it works!", "lazers pew pew") + class Artwork(object): @cherrypy.expose