Added code comments, better support of unicode path names

sandbox1
Anton Argirov 2015-01-29 16:34:13 +06:00
parent 10d91a1d2d
commit 7fcf3e20ab
6 changed files with 255 additions and 34 deletions

60
README.md 100644
View File

@ -0,0 +1,60 @@
script.module.torrent2http
==========================
This add-on is binding to [torrent2http](https://github.com/anteo/torrent2http) client.
It is bundled with torrent2http binaries for Android ARM, Linux x86/x64/ARM, Windows x86/x64 and Darwin/OSX x64 platforms.
You can download it from my [repository](http://bit.ly/184XKjm)
This add-on can be used to stream media files from torrents without need to download entire files.
Internally, it runs pre-compiled torrent2http client binary, which starts local HTTP server, presenting contents of torrent.
Next, request to HTTP server can be sent to receive list of files or to start streaming of needed file inside of torrent.
Usage
-----
### Get list of files inside torrent ###
Getting list of files inside torrent is straightforward:
import xbmc
from torrent2http import State, Engine, MediaType
from contextlib import closing
# Create instance of Engine
engine = Engine(uri="...")
files = []
# Ensure we'll close engine on exception
with closing(engine):
# Start engine
engine.start()
# Wait until files received
while not files and not xbmc.abortRequested:
# Will list only video files in torrent
files = engine.list(media_types=[MediaType.VIDEO])
# Check if there is loading torrent error and raise exception
engine.check_torrent_error()
xbmc.sleep(200)
### Start streaming ###
import xbmc
from torrent2http import State, Engine, MediaType
from contextlib import closing
# We can know file_id of needed video file on this step, if no, we'll try to detect one.
file_id = None
engine = Engine(uri="...")
with closing(self.engine):
# Start engine and instruct torrent2http to begin download first file,
# so it can start searching and connecting to peers
self.engine.start(file_id or 0)
while not xbmc.abortRequested:
sleep(self.SLEEP_DELAY)
status = self.engine.status()
self.engine.check_torrent_error(status)
if status.state in [State.DOWNLOADING, State.FINISHED, State.SEEDING]:
ready = True
break
... todo ...

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="script.module.torrent2http" name="torrent2http" version="0.0.5" provider-name="anteo">
<addon id="script.module.torrent2http" name="torrent2http" version="0.0.6" provider-name="anteo">
<requires>
<import addon="xbmc.python" version="2.14.0"/>
</requires>

View File

@ -1,3 +1,7 @@
[B]Version 0.0.6[/B]
+ Added code comments
* Better support of unicode path names
[B]Version 0.0.5[/B]
+ Torrent2http binaries updated to 1.0.1

View File

@ -7,17 +7,22 @@ import subprocess
import sys
import time
import urllib2
import httplib
from os.path import dirname
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
from util import can_bind, find_free_port, ensure_fs_encoding
class Engine:
"""
This is python binding class to torrent2http client.
"""
SUBTITLES_FORMATS = ['.aqt', '.gsub', '.jss', '.sub', '.ttxt', '.pjs', '.psb', '.rt', '.smi', '.stl',
'.ssf', '.srt', '.ssa', '.ass', '.usf', '.idx']
@ -42,6 +47,12 @@ class Engine:
xbmc.log("[torrent2http] %s" % message)
def _get_binary_path(self, binaries_path):
"""
Detects platform and returns corresponding torrent2http binary path
:param binaries_path:
:return: torrent2http binary 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)
@ -74,27 +85,6 @@ class Engine:
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,
@ -105,6 +95,55 @@ class Engine:
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):
"""
Creates engine instance. It doesn't do anything except initializing object members. For starting engine use
start() method.
:param uri: Torrent URI (magnet://, file:// or http://)
:param binaries_path: Path to torrent2http binaries
:param platform: Object with two methods implemented: arch() and system()
:param download_path: Torrent download path
:param bind_host: Bind host of torrent2http
:param bind_port: Bind port of torrent2http
:param connections_limit: Set a global limit on the number of connections opened
:param download_kbps: Max download rate (kB/s)
:param upload_kbps: Max upload rate (kB/s)
:param enable_dht: Enable DHT (Distributed Hash Table)
:param enable_lsd: Enable LSD (Local Service Discovery)
:param enable_natpmp: Enable NATPMP (NAT port-mapping)
:param enable_upnp: Enable UPnP (UPnP port-mapping)
:param enable_scrape: Enable sending scrape request to tracker (updates total peers/seeds count)
:param log_stats: Log all stats (incl. log_overall_progress, log_files_progress, log_pieces_progress)
:param encryption: Encryption: 0=forced 1=enabled (default) 2=disabled
:param keep_complete: Keep complete files after exiting
:param keep_incomplete: Keep incomplete files after exiting
:param keep_files: Keep all files after exiting (incl. keep_complete and keep_incomplete)
:param log_files_progress: Log files progress
:param log_overall_progress: Log overall progress
:param log_pieces_progress: Log pieces progress
:param listen_port: Use specified port for incoming connections
:param use_random_port: Use random listen port (49152-65535)
:param max_idle_timeout: Automatically shutdown torrent2http if no connection are active after a timeout
:param no_sparse: Do not use sparse file allocation
:param resume_file: Use fast resume file
:param user_agent: Set an user agent
:param startup_timeout: torrent2http startup timeout
:param state_file: Use file for saving/restoring session state
:param enable_utp: Enable uTP protocol
:param enable_tcp: Enable TCP protocol
:param debug_alerts: Show debug alert notifications
:param logger: Instance of logging.Logger
:param torrent_connect_boost: The number of peers to try to connect to immediately when the first tracker
response is received for a torrent
:param connection_speed: The number of peer connection attempts that are made per second
:param peer_connect_timeout: The number of seconds to wait after a connection attempt is initiated to a peer
:param request_timeout: The number of seconds until the current front piece request will time out
:param min_reconnect_time: The time to wait between peer connection attempts. If the peer fails, the time is
multiplied by fail counter
:param max_failcount: The maximum times we try to connect to a peer before stop connecting again
:param dht_routers: List of additional DHT routers (host:port pairs)
:param trackers: List of additional tracker URLs
"""
self.dht_routers = dht_routers or []
self.trackers = trackers or []
self.max_failcount = max_failcount
@ -154,21 +193,36 @@ class Engine:
@staticmethod
def _validate_save_path(path):
"""
Ensures download path can be accessed locally.
:param path: Download path
:return: Translated path
"""
import xbmc
path = xbmc.translatePath(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):
if not os.path.isdir(ensure_fs_encoding(path)):
raise Error("Download path doesn't exist (%s)" % path, Error.INVALID_DOWNLOAD_PATH)
return path
def start(self, start_index=None):
"""
Starts torrent2http client with specified settings. If it can be started in startup_timeout seconds, exception
will be raised.
:param start_index: File index to start download instantly, if not specified, downloading will be paused, until
any file requested
"""
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 not can_bind(self.bind_host, self.bind_port):
port = 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))
@ -225,10 +279,8 @@ class Engine:
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')
if isinstance(v, str) or isinstance(v, unicode):
v = ensure_fs_encoding(v)
else:
v = str(v)
args.append(v)
@ -266,12 +318,25 @@ class Engine:
self._log("torrent2http successfully started.")
def check_torrent_error(self, status=None):
"""
It is recommended to call this method periodically to check if any libtorrent errors occurred.
Usually libtorrent sets error if it can't download or parse torrent file by specified URI.
Note that torrent2http remains started after such error, so you need to shutdown it manually.
:param status: Pass return of status() method if you don't want status() called twice
"""
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):
"""
Returns libtorrent session status. See SessionStatus named tuple.
:rtype : SessionStatus
:param timeout: torrent2http client request timeout
"""
status = self._decode(self._request('status', timeout))
status = SessionStatus(**status)
return status
@ -293,6 +358,16 @@ class Engine:
return MediaType.UNKNOWN
def list(self, media_types=None, timeout=10):
"""
Returns list of files in the torrent (see FileStatus named tuple).
Note that it will return None if torrent file is not loaded yet by torrent2http client, so you may need to call
this method periodically until results are returned.
:param media_types: List of media types (see MediaType constants)
:param timeout: torrent2http client request timeout
:rtype : list of FileStatus
:return: List of files of specified media types or None if torrent is not loaded yet
"""
files = self._decode(self._request('ls', timeout))['files']
if files:
res = [FileStatus(index=index, media_type=self._detect_media_type(f['name']), **f)
@ -302,6 +377,16 @@ class Engine:
return res
def file_status(self, file_index, timeout=10):
"""
Returns file in the torrent with specified index (see FileStatus named tuple)
Note that it will return None if torrent file is not loaded yet by torrent2http client, so you may need to call
this method periodically until results are returned.
:param file_index: Requested file's index
:param timeout: torrent2http client request timeout
:return: File with specified index
:rtype: FileStatus
"""
res = self.list(timeout=timeout)
if res:
try:
@ -311,6 +396,13 @@ class Engine:
file_index=file_index)
def peers(self, timeout=10):
"""
Returns list of peers connected (see PeerInfo named tuple).
:param timeout: torrent2http client request timeout
:return: List of peers
:rtype: list of PeerInfo
"""
peers = self._decode(self._request('peers', timeout))['peers']
if peers:
return [PeerInfo(**p) for p in peers]
@ -334,8 +426,8 @@ class Engine:
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):
except (urllib2.URLError, httplib.HTTPException) as e:
if isinstance(e, urllib2.URLError) and 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)
@ -346,9 +438,20 @@ class Engine:
raise Error("Can't read from torrent2http: %s" % reason, Error.REQUEST_ERROR)
def wait_on_close(self, wait_timeout=10):
"""
By default, close() method sends shutdown command to torrent2http, stops logging and returns immediately, not
waiting while torrent2http exits. It can be handy to wait torrent2http to view log messages during shutdown.
So call this method with reasonable timeout before calling close().
:param wait_timeout: Time in seconds to wait until torrent2http client shut down
"""
self.wait_on_close_timeout = wait_timeout
def close(self):
"""
Shuts down torrent2http and stops logging. If wait_on_close() was called earlier, it will wait until
torrent2http successfully exits.
"""
if self.logpipe and self.wait_on_close_timeout is None:
self.logpipe.close()
if self.is_alive():
@ -371,4 +474,4 @@ class Engine:
self.wait_on_close_timeout = None
self.started = False
self.logpipe = None
self.process = None
self.process = None

View File

@ -1,17 +1,29 @@
class Error(Exception):
TORRENT_ERROR = 12
""" Error returned by libtorrent """
CRASHED = 13
""" torrent2http client has crashed abnormally """
UNKNOWN_PLATFORM = 1
""" Unknown/unsupported platform """
XBMC_HOME_NOT_DEFINED = 2
""" XBMC_HOME or KODI_HOME is not set """
NOEXEC_FILESYSTEM = 3
""" torrent2http binary is placed on noexec filesystem, so it can't be started """
REQUEST_ERROR = 5
""" Error occurred while sending request to torrent2http """
INVALID_DOWNLOAD_PATH = 6
""" Dowload path is invalid """
BIND_ERROR = 7
""" Bind error can occur on start, if it's impossible to find a port to bind torrent2http to """
POPEN_ERROR = 8
""" Can't start torrent2http client, path to binary doesn't exist or can't be executed """
PROCESS_ERROR = 9
""" torrent2http client started but exited abnormally, may be conflict in startup options """
TIMEOUT = 10
""" torrent2http not answered during specified timeout """
INVALID_FILE_INDEX = 11
""" Specified file index is invalid, no file with specified index found in torrent """
def __init__(self, message, code=0, **kwargs):
self.message = message

View File

@ -0,0 +1,42 @@
import sys
import socket
def can_bind(host, port):
"""
Checks we can bind to specified host and port
:param host: Host
:param port: Port
:return: True if bind succeed
"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((host, port))
s.close()
except socket.error:
return False
return True
def find_free_port(host):
"""
Finds free TCP port that can be used for binding
:param host: Host
:return: Free port
"""
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 ensure_fs_encoding(string):
if isinstance(string, str):
string = string.decode('utf-8')
return string.encode(sys.getfilesystemencoding() or 'utf-8')