374 lines
16 KiB
Python
374 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
import json
|
|
import os
|
|
import socket
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib2
|
|
import logpipe
|
|
import mimetypes
|
|
import xbmc
|
|
|
|
from error import Error
|
|
from platform import Platform
|
|
from . import SessionStatus, FileStatus, PeerInfo, MediaType, Encryption
|
|
from os.path import dirname
|
|
|
|
|
|
class Engine:
|
|
SUBTITLES_FORMATS = ['.aqt', '.gsub', '.jss', '.sub', '.ttxt', '.pjs', '.psb', '.rt', '.smi', '.stl',
|
|
'.ssf', '.srt', '.ssa', '.ass', '.usf', '.idx']
|
|
|
|
def _ensure_binary_executable(self, path):
|
|
st = os.stat(path)
|
|
if not st.st_mode & stat.S_IEXEC:
|
|
self._log("%s is not executable, trying to change its mode..." % path)
|
|
os.chmod(path, st.st_mode | stat.S_IEXEC)
|
|
st = os.stat(path)
|
|
if st.st_mode & stat.S_IEXEC:
|
|
self._log("Succeeded")
|
|
return True
|
|
else:
|
|
self._log("Failed")
|
|
return False
|
|
return True
|
|
|
|
def _log(self, message):
|
|
if self.logger:
|
|
self.logger(message)
|
|
else:
|
|
xbmc.log("[torrent2http] %s" % message)
|
|
|
|
def _get_binary_path(self, binaries_path):
|
|
binary = "torrent2http" + (".exe" if self.platform.system == 'windows' else "")
|
|
binary_dir = os.path.join(binaries_path, "%s_%s" % (self.platform.system, self.platform.arch))
|
|
binary_path = os.path.join(binary_dir, binary)
|
|
if not os.path.isfile(binary_path):
|
|
raise Error("Can't find torrent2http binary for %s" % self.platform,
|
|
Error.UNKNOWN_PLATFORM, platform=str(self.platform))
|
|
|
|
if not self._ensure_binary_executable(binary_path):
|
|
if self.platform.system == "android":
|
|
self._log("Trying to copy torrent2http to ext4, since the sdcard is noexec...")
|
|
xbmc_home = os.environ.get('XBMC_HOME') or os.environ.get('KODI_HOME')
|
|
if not xbmc_home:
|
|
raise Error("Suppose we are running XBMC, but environment variable "
|
|
"XBMC_HOME or KODI_HOME is not found", Error.XBMC_HOME_NOT_DEFINED)
|
|
base_xbmc_path = dirname(dirname(dirname(xbmc_home)))
|
|
android_binary_dir = os.path.join(base_xbmc_path, "files")
|
|
if not os.path.exists(android_binary_dir):
|
|
os.makedirs(android_binary_dir)
|
|
android_binary_path = os.path.join(android_binary_dir, binary)
|
|
if not os.path.exists(android_binary_path) or \
|
|
int(os.path.getmtime(android_binary_path)) < int(os.path.getmtime(binary_path)):
|
|
import shutil
|
|
shutil.copy2(binary_path, android_binary_path)
|
|
if not self._ensure_binary_executable(android_binary_path):
|
|
raise Error("Can't make %s executable" % android_binary_path, Error.NOEXEC_FILESYSTEM)
|
|
binary_path = android_binary_path
|
|
else:
|
|
raise Error("Can't make %s executable, ensure it's placed on exec partition and "
|
|
"partition is in read/write mode" % binary_path, Error.NOEXEC_FILESYSTEM)
|
|
self._log("Selected %s as torrent2http binary" % binary_path)
|
|
return binary_path
|
|
|
|
@staticmethod
|
|
def _can_bind(host, port):
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.bind((host, port))
|
|
s.close()
|
|
except socket.error:
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def _find_free_port(host):
|
|
try:
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
s.bind((host, 0))
|
|
port = s.getsockname()[1]
|
|
s.close()
|
|
except socket.error:
|
|
return False
|
|
return port
|
|
|
|
def __init__(self, uri=None, binaries_path=None, platform=None, download_path=".",
|
|
bind_host='127.0.0.1', bind_port=5001, connections_limit=None, download_kbps=None, upload_kbps=None,
|
|
enable_dht=True, enable_lsd=True, enable_natpmp=True, enable_upnp=True, enable_scrape=False,
|
|
log_stats=False, encryption=Encryption.ENABLED, keep_complete=False, keep_incomplete=False,
|
|
keep_files=False, log_files_progress=False, log_overall_progress=False, log_pieces_progress=False,
|
|
listen_port=6881, use_random_port=False, max_idle_timeout=None, no_sparse=False, resume_file=None,
|
|
user_agent=None, startup_timeout=5, state_file=None, enable_utp=True, enable_tcp=True,
|
|
debug_alerts=False, logger=None, torrent_connect_boost=50, connection_speed=50,
|
|
peer_connect_timeout=15, request_timeout=20, min_reconnect_time=60, max_failcount=3,
|
|
dht_routers=None, trackers=None):
|
|
self.dht_routers = dht_routers or []
|
|
self.trackers = trackers or []
|
|
self.max_failcount = max_failcount
|
|
self.min_reconnect_time = min_reconnect_time
|
|
self.request_timeout = request_timeout
|
|
self.peer_connect_timeout = peer_connect_timeout
|
|
self.connection_speed = connection_speed
|
|
self.torrent_connect_boost = torrent_connect_boost
|
|
self.platform = platform
|
|
self.bind_host = bind_host
|
|
self.bind_port = bind_port
|
|
self.binaries_path = binaries_path or os.path.join(dirname(dirname(dirname(os.path.abspath(__file__)))), 'bin')
|
|
self.download_path = download_path
|
|
self.connections_limit = connections_limit
|
|
self.download_kbps = download_kbps
|
|
self.upload_kbps = upload_kbps
|
|
self.enable_dht = enable_dht
|
|
self.enable_lsd = enable_lsd
|
|
self.enable_natpmp = enable_natpmp
|
|
self.enable_upnp = enable_upnp
|
|
self.enable_scrape = enable_scrape
|
|
self.log_stats = log_stats
|
|
self.encryption = encryption
|
|
self.keep_complete = keep_complete
|
|
self.keep_incomplete = keep_incomplete
|
|
self.keep_files = keep_files
|
|
self.log_files_progress = log_files_progress
|
|
self.log_overall_progress = log_overall_progress
|
|
self.log_pieces_progress = log_pieces_progress
|
|
self.listen_port = listen_port
|
|
self.use_random_port = use_random_port
|
|
self.max_idle_timeout = max_idle_timeout
|
|
self.no_sparse = no_sparse
|
|
self.resume_file = resume_file
|
|
self.user_agent = user_agent
|
|
self.startup_timeout = startup_timeout
|
|
self.state_file = state_file
|
|
self.wait_on_close_timeout = None
|
|
self.enable_utp = enable_utp
|
|
self.enable_tcp = enable_tcp
|
|
self.debug_alerts = debug_alerts
|
|
self.logger = logger
|
|
self.uri = uri
|
|
self.logpipe = None
|
|
self.process = None
|
|
self.started = False
|
|
|
|
@staticmethod
|
|
def _validate_save_path(path):
|
|
if "://" in path:
|
|
if sys.platform.startswith('win') and path.lower().startswith("smb://"):
|
|
path = path.replace("smb:", "").replace("/", "\\")
|
|
else:
|
|
raise Error("Downloading to an unmounted network share is not supported", Error.INVALID_DOWNLOAD_PATH)
|
|
if not os.path.isdir(path):
|
|
raise Error("Download path doesn't exist (%s)" % path, Error.INVALID_DOWNLOAD_PATH)
|
|
return path
|
|
|
|
def start(self, start_index=None):
|
|
self.platform = self.platform or Platform()
|
|
binary_path = self._get_binary_path(self.binaries_path)
|
|
download_path = self._validate_save_path(self.download_path)
|
|
if not self._can_bind(self.bind_host, self.bind_port):
|
|
port = self._find_free_port(self.bind_host)
|
|
if port is False:
|
|
raise Error("Can't find port to bind torrent2http", Error.BIND_ERROR)
|
|
self._log("Can't bind to %s:%s, so we found another port: %d" % (self.bind_host, self.bind_port, port))
|
|
self.bind_port = port
|
|
|
|
kwargs = {
|
|
'--bind': "%s:%s" % (self.bind_host, self.bind_port),
|
|
'--uri': self.uri,
|
|
'--file-index': start_index,
|
|
'--dl-path': download_path,
|
|
'--connections-limit': self.connections_limit,
|
|
'--dl-rate': self.download_kbps,
|
|
'--ul-rate': self.upload_kbps,
|
|
'--enable-dht': self.enable_dht,
|
|
'--enable-lsd': self.enable_lsd,
|
|
'--enable-natpmp': self.enable_natpmp,
|
|
'--enable-upnp': self.enable_upnp,
|
|
'--enable-scrape': self.enable_scrape,
|
|
'--encryption': self.encryption,
|
|
'--show-stats': self.log_stats,
|
|
'--files-progress': self.log_files_progress,
|
|
'--overall-progress': self.log_overall_progress,
|
|
'--pieces-progress': self.log_pieces_progress,
|
|
'--listen-port': self.listen_port,
|
|
'--random-port': self.use_random_port,
|
|
'--keep-complete': self.keep_complete,
|
|
'--keep-incomplete': self.keep_incomplete,
|
|
'--keep-files': self.keep_files,
|
|
'--max-idle': self.max_idle_timeout,
|
|
'--no-sparse': self.no_sparse,
|
|
'--resume-file': self.resume_file,
|
|
'--user-agent': self.user_agent,
|
|
'--state-file': self.state_file,
|
|
'--enable-utp': self.enable_utp,
|
|
'--enable-tcp': self.enable_tcp,
|
|
'--debug-alerts': self.debug_alerts,
|
|
'--torrent-connect-boost': self.torrent_connect_boost,
|
|
'--connection-speed': self.connection_speed,
|
|
'--peer-connect-timeout': self.peer_connect_timeout,
|
|
'--request-timeout': self.request_timeout,
|
|
'--min-reconnect-time': self.min_reconnect_time,
|
|
'--max-failcount': self.max_failcount,
|
|
'--dht-routers': ",".join(self.dht_routers),
|
|
'--trackers': ",".join(self.trackers),
|
|
}
|
|
|
|
args = [binary_path]
|
|
for k, v in kwargs.iteritems():
|
|
if v is not None:
|
|
if isinstance(v, bool):
|
|
if v:
|
|
args.append(k)
|
|
else:
|
|
args.append("%s=false" % k)
|
|
else:
|
|
args.append(k)
|
|
if isinstance(v, str):
|
|
v = v.decode('utf-8')
|
|
if isinstance(v, unicode):
|
|
v = v.encode(sys.getfilesystemencoding() or 'utf-8')
|
|
else:
|
|
v = str(v)
|
|
args.append(v)
|
|
|
|
self._log("Invoking %s" % " ".join(args))
|
|
startupinfo = None
|
|
if self.platform.system == "windows":
|
|
startupinfo = subprocess.STARTUPINFO()
|
|
startupinfo.dwFlags |= 1
|
|
startupinfo.wShowWindow = 0
|
|
|
|
self.logpipe = logpipe.LogPipe(self._log)
|
|
try:
|
|
self.process = subprocess.Popen(args, stderr=self.logpipe, stdout=self.logpipe, startupinfo=startupinfo)
|
|
except OSError, e:
|
|
raise Error("Can't start torrent2http: %r" % e, Error.POPEN_ERROR)
|
|
|
|
start = time.time()
|
|
self.started = True
|
|
initialized = False
|
|
while (time.time() - start) < self.startup_timeout:
|
|
time.sleep(0.1)
|
|
if not self.is_alive():
|
|
raise Error("Can't start torrent2http, see log for details", Error.PROCESS_ERROR)
|
|
try:
|
|
self.status(1)
|
|
initialized = True
|
|
break
|
|
except Error:
|
|
pass
|
|
|
|
if not initialized:
|
|
self.started = False
|
|
raise Error("Can't start torrent2http, time is out", Error.TIMEOUT)
|
|
self._log("torrent2http successfully started.")
|
|
|
|
def check_torrent_error(self, status=None):
|
|
if not status:
|
|
status = self.status()
|
|
if status.error:
|
|
raise Error("Torrent error: %s" % status.error, Error.TORRENT_ERROR, reason=status.error)
|
|
|
|
def status(self, timeout=10):
|
|
status = self._decode(self._request('status', timeout))
|
|
status = SessionStatus(**status)
|
|
return status
|
|
|
|
def _detect_media_type(self, name):
|
|
ext = os.path.splitext(name)[1]
|
|
if ext in self.SUBTITLES_FORMATS:
|
|
return MediaType.SUBTITLES
|
|
else:
|
|
mime_type = mimetypes.guess_type(name)[0]
|
|
if not mime_type:
|
|
return MediaType.UNKNOWN
|
|
mime_type = mime_type.split("/")[0]
|
|
if mime_type == 'audio':
|
|
return MediaType.AUDIO
|
|
elif mime_type == 'video':
|
|
return MediaType.VIDEO
|
|
else:
|
|
return MediaType.UNKNOWN
|
|
|
|
def list(self, media_types=None, timeout=10):
|
|
files = self._decode(self._request('ls', timeout))['files']
|
|
if files:
|
|
res = [FileStatus(index=index, media_type=self._detect_media_type(f['name']), **f)
|
|
for index, f in enumerate(files)]
|
|
if media_types is not None:
|
|
res = filter(lambda fs: fs.media_type in media_types, res)
|
|
return res
|
|
|
|
def file_status(self, file_index, timeout=10):
|
|
res = self.list(timeout=timeout)
|
|
if res:
|
|
try:
|
|
return next((f for f in res if f.index == file_index))
|
|
except StopIteration:
|
|
raise Error("Requested file index (%d) is invalid" % file_index, Error.INVALID_FILE_INDEX,
|
|
file_index=file_index)
|
|
|
|
def peers(self, timeout=10):
|
|
peers = self._decode(self._request('peers', timeout))['peers']
|
|
if peers:
|
|
return [PeerInfo(**p) for p in peers]
|
|
|
|
def is_alive(self):
|
|
return self.process and self.process.poll() is None
|
|
|
|
@staticmethod
|
|
def _decode(response):
|
|
try:
|
|
return json.loads(response)
|
|
except (KeyError, ValueError), e:
|
|
raise Error("Can't decode response from torrent2http: %r" % e, Error.REQUEST_ERROR)
|
|
|
|
def _request(self, cmd, timeout=None):
|
|
if not self.started:
|
|
raise Error("torrent2http is not started", Error.REQUEST_ERROR)
|
|
try:
|
|
url = "http://%s:%s/%s" % (self.bind_host, self.bind_port, cmd)
|
|
kwargs = {}
|
|
if timeout is not None:
|
|
kwargs['timeout'] = timeout
|
|
return urllib2.urlopen(url, **kwargs).read()
|
|
except urllib2.URLError as e:
|
|
if isinstance(e.reason, socket.timeout):
|
|
raise Error("Timeout occurred while sending command '%s' to torrent2http" % cmd, Error.TIMEOUT)
|
|
elif not self.is_alive() and self.started:
|
|
raise Error("torrent2http has crashed.", Error.CRASHED)
|
|
else:
|
|
raise Error("Can't send command '%s' to torrent2http: %r" % (cmd, e), Error.REQUEST_ERROR)
|
|
except socket.error as e:
|
|
reason = e[1] if isinstance(e, tuple) else e
|
|
raise Error("Can't read from torrent2http: %s" % reason, Error.REQUEST_ERROR)
|
|
|
|
def wait_on_close(self, wait_timeout=10):
|
|
self.wait_on_close_timeout = wait_timeout
|
|
|
|
def close(self):
|
|
if self.logpipe and self.wait_on_close_timeout is None:
|
|
self.logpipe.close()
|
|
if self.is_alive():
|
|
self._log("Shutting down torrent2http...")
|
|
self._request('shutdown')
|
|
finished = False
|
|
if self.wait_on_close_timeout is not None:
|
|
start = time.time()
|
|
os.close(self.logpipe.write_fd)
|
|
while (time.time() - start) < self.wait_on_close_timeout:
|
|
time.sleep(0.5)
|
|
if not self.is_alive():
|
|
finished = True
|
|
break
|
|
if not finished:
|
|
self._log("Timeout occurred while shutting down torrent2http, killing it")
|
|
self.process.kill()
|
|
else:
|
|
self._log("torrent2http successfully shut down.")
|
|
self.wait_on_close_timeout = None
|
|
self.started = False
|
|
self.logpipe = None
|
|
self.process = None |