Welcome to Media Center Master!
A powerful solution for mastering your digital media library.
Supporting Emby, Kodi/XBMC/OSMC, Plex, Windows Media Center, and more!

Home Download Gallery Wiki Issue Tracker Licensing Forums

   FAQ  •  Search •  Login •  Register     
It is currently April 23rd, 2018, 11:09 pm

All times are UTC - 7 hours [ DST ]



Post new topic Reply to topic  [ 1 post ] 
Author Message
 Post subject: How to get MCM to work with qbittorrent instead of uTorrent  [SOLVED]
PostPosted: January 7th, 2018, 3:35 am 
Offline
Downloaded Clip

Joined: February 7th, 2013, 12:33 am
Posts: 6
I can't post to the Guides board, so posting here.

TL;DR
I made an MCM Feature Request for web api integration with qbittorrent as an alternative to uTorrent, but I'm impatient and used python to write a facade for the qbittorrent web api to enable MCM integration. Basically MCM thinks it's talking to uTorrent, and everybody's happy. It's relatively easy to implement - basically a script that you should be able to run on any platform alongside qbittorrent (i.e. Windows, but I haven't tested this).

Supported Functionality
Python is not my "native" language, so I'll tell you up front that this is a functional script, but probably not the cleanest.
I used Nibbler to watch the requests coming out of MCM and the responses from uTorrent with an eye towards implementing the bare minimum of methods needed to make MCM happy. At this point, MCM's utorrent manager (Tools --> uTorrent download manager) works in that you can see a list of torrents and their status, and manually pause, unpause, and remove torrents from the manager. uTorrent automated tasks can successfully add magnet links to the queue and force start torrents and clear finished torrents according to how you've configured MCM's settings. While the functionality to add torrent files via the API is there and tested with Postman, I haven't done any integration testing for this method with MCM, because it's just damn hard to find torrent files for media these days. So for all intents and purposes, this facade enables MCM to do everything you'd have it do with uTorrent.

How I Use the API Facade
I use Hyper-V to host a headless debian server as a dedicated torrent box - I find many security and configuration advantages to this setup and would be happy to do a guide on how you can do this too if there's interest. This guide won't cover the box configuration, just what you need to get the script working.

What You Need
To implement the API facade, you need to install python. I'm using 3.5.3. You'll need to install pip if it's not included with your python install and use it to pull down support libraries/modules. This is using Flask and a QBT api wrapper library.

Here is the output of my pip freeze. Run 'pip freeze' at the command line to see what's missing on your machine and use 'pip install <module name>' to pull them down.
aniso8601==1.3.0
certifi==2017.11.5
chardet==3.0.4
click==6.7
Flask==0.12.2
Flask-Jsonpify==1.5.0
Flask-RESTful==0.3.6
idna==2.6
itsdangerous==0.24
Jinja2==2.10
MarkupSafe==1.0
python-dateutil==2.6.1
python-qbittorrent==0.3.1
pytz==2017.3
requests==2.18.4
six==1.11.0
urllib3==1.22
Werkzeug==0.14.1

If you're having trouble running pip (e.g. command not found) it's probably something to do with your path statement.
I'm also using virtualenv to keep the execution environment isolated, but that's probably overkill for a purpose-built box. Just know that it's an option if you're already running python scripts for other purposes and want to avoid version conflicts.

The Script
Here's the script:
Code:
"""Present a facade of the uTorrent API and call equivalent QBT methods."""
import json
import time
from flask import Flask, request, Response
from flask_jsonpify import jsonify
from flask_restful import Resource, Api
from qbittorrent import Client

APP_QUT = Flask(__name__)
API_QUT = Api(APP_QUT)
APP_QUT.config['JSONIFY_PRETTYPRINT_REGULAR'] = False

# QBITTORRENT Constants
# This is the address of QBT's API
# It's the "Backend"
# Include port number and suffix with '/'
# as in 'http://x.x.x.x:yyyy/'
QB_URI = 'http://192.168.1.120:8080/'
# UID and PWD or QBT's WebUI/API
# TODO: switch this over to use the UT creds passed from MCM
QB_UID = 'admin'
QB_PWD = 'adminadmin'
# UTORRENT Constants
# These are the settings you'll be using
# within MCM. It's the "Frontend"
# UT_URI is the address for the API facade
# to listen on. By default Flask only listens
# on localhosts, and specifying '0.0.0.0'
# means all interfaces but you can specify
# a specific IP if it matters to you.
UT_URI = '0.0.0.0'
# Note that port is an integer, unlike URI
UT_PORT = 8081
# FLASK Debug Mode
FLASK_DEBUG = False

class GUI(Resource):
    """Define API for uTorrent"""
    def get(self):
        """Handle all GET requests and return response"""
        param_action = request.args.get('action')
        if param_action == 'add-url':
            # Add a magnet link to the queue
            uri = request.args.get('s')
            result = add_url(uri)
            result = Response('{"build":44332}', status=200, mimetype='text/plain')
            # Why am I manually building a response?
            # For whatever reason, uTorrent sends back
            # JSON data with a plaintext mime type, and
            # for whatever reason, MCM wants to see this
            # to confirm the add was successful. The add
            # fails if the mime-type is changed. The MCM
            # error handling doesn't give much detail as
            # I think it's dropping into a generic msg
            # about the target directory not being cfg'd
            # correctly.
        elif param_action == 'start':
            # Force start a torrent
            torrent = request.args.get('hash')
            torrent = torrent.strip()
            result = force_start(torrent)
        elif param_action == 'stop':
            # Stop a torrent
            # I don't think QBT really knows how to 'stop' a torrent
            # so my theory is the only time you'd call this from MCM
            # is to cancel a torrent, so I'm treating this as a call
            # to QBT to delete the torrent and any downloaded data
            # or deletePerm
            torrent = request.args.get('hash')
            torrent = torrent.strip()
            result = stop(torrent)
        elif param_action == 'remove':
            torrent = request.args.get('hash')
            # TODO: MCM is passing in the hash with leading NL and spaces
            # I think this is because we're returning formatted json
            # which has a NL and same number of leading spaces in the list
            # I think workaround is to disable prettyprint in flask
            torrent = torrent.strip()
            result = delete(torrent)
        elif param_action == 'pause':
            torrent = request.args.get('hash')
            torrent = torrent.strip()
            result = pause(torrent)
        elif param_action == 'unpause':
            torrent = request.args.get('hash')
            torrent = torrent.strip()
            result = unpause(torrent)
        else:
            # Check for a list request
            param_list = request.args.get('list')
            if param_list == '1':
                # Get list of active torrents
                result = json.loads(get_list())
            else:
                # If request doesn't conform to supported
                # methods, just let the caller know we're
                # listening
                result = "Yes, I'm up. Try asking for something meaningful."
        # Send a response back
        return result
    def post(self):
        """Handle all POST requests and return response"""
        # I think the only POST request that uTorrent's API
        # supports is for putting up a torrent file
        # so no need for if/elif/else stuff here
        # TODO: Needs integration testing
        # It's getting tough to find .torrent files for
        # anything MCM cares about, so this hasn't been
        # through integration testing. It works from
        # Postman, but if it doesn't work from MCM
        # it's probably something like the mime-type
        # header as seen with the add_url method
        dummy = request.form
        # Have to read the form data from a POST request
        # to succeed
        file = request.files['torrent_file']
        result = add_file(file)
        return jsonify(result)
# The following methods make the calls to the QBT API
# TODO: consider logging off the session or doing static connection mgr
def add_url(uri):
    """Call QB method to add magnet link"""
    qb_client = initiate_qb()
    return qb_client.download_from_link(uri)

def add_file(file):
    """"Call QB method to add file"""
    qb_client = initiate_qb()
    return qb_client.download_from_file(file)

def force_start(torrent):
    """Call QB method to force start the torrent"""
    qb_client = initiate_qb()
    return qb_client.force_start(torrent)

def stop(torrent):
    """Call QB method to stop the torrent"""
    qb_client = initiate_qb()
    return qb_client.delete_permanently(torrent)

def delete(torrent):
    """Call QB method to delete a torrent with downloaded data"""
    qb_client = initiate_qb()
    return qb_client.delete(torrent)

def pause(torrent):
    """Call QB method to pause a torrent"""
    qb_client = initiate_qb()
    return qb_client.pause(torrent)

def unpause(torrent):
    """Call QB method to unpause a torrent"""
    qb_client = initiate_qb()
    return qb_client.resume(torrent)

def get_list():
    """Call QB method to get list of torrents"""
    qb_client = initiate_qb()
    torrent_list = qb_client.torrents()
    utlist = build_utlist(torrent_list, qb_client)
    return utlist

def initiate_qb():
    """Instantiate and return an authenticated QBT client"""
    qb_client = Client(QB_URI)
    qb_client.login(QB_UID, QB_PWD)
    return qb_client

def build_utlist(qbtlist, client):
    """take a qbt list of torrent details, build and return a string in UT format"""
    utlist = ''
    # Initiate UT list with header
    utlist = '{"build":443322, "label":[["notsupported",' + str(len(qbtlist)) + ']],"torrents":['
    # Initiate UT torrent list with header
    # Loop through torrent details and build a UT-formatted list
    torrents_list = []
    torrent_list = []
    start = time.clock()
    for torrent in qbtlist:
        torrent_detail = client.get_torrent(torrent['hash'])
        utstatuscode = convert_torrent_status(torrent['state'], torrent['force_start'])
        # Add the hash, status
        # Experiment with list join rather than complete concatenation
        torrent_list.append('["' + torrent['hash'] + '"')
        torrent_list.append(utstatuscode)
        torrent_list.append('"' + torrent['name'] + '"')
        torrent_list.append(str(torrent['size']))
        torrent_list.append(str((float(torrent['progress'] * 1000))))
        torrent_list.append(str(torrent_detail['total_downloaded']))
        torrent_list.append(str(torrent_detail['total_uploaded']))
        torrent_list.append(str(torrent['ratio'] / 10))
        torrent_list.append(str(torrent['upspeed']))
        torrent_list.append(str(torrent['dlspeed']))
        torrent_list.append(str(torrent['eta']))
        torrent_list.append('"' + torrent['category'] + '"')
        torrent_list.append(str(torrent_detail['peers']))
        torrent_list.append(str(torrent_detail['peers_total']))
        torrent_list.append(str(torrent_detail['seeds']))
        torrent_list.append(str(torrent_detail['seeds_total']))
        torrent_list.append('0')
        torrent_list.append(str(torrent['priority']))
        torrent_list.append(str(torrent['size'] - torrent['progress'] * torrent['size']) + ']')
        # Join all the elements of torrent_list into master list
        torrents_list.append(','.join(torrent_list))
        torrent_list.clear()

    # add the torrent list to the UT list
    utlist += ','.join(torrents_list)
    end = time.clock()
    # add a footer to the UT list
    # MCM doesn't care about the UT list cache ID
    # so am using this to clock the performance of list grabs
    utlist += '],"time":' + str(end - start) + '}'
    #utlist += '],"torrentc":"9999"}'
    return utlist

def convert_torrent_status(qbtstatus, qbtforce=False):
    """Take in qbt state and convert to utorrent status"""
    utstatus = ''
    # DL in progress (percent progress < 1000)
    if qbtstatus == 'error':
        utstatus = '152'
    elif qbtstatus == 'pausedUP':
        # I think this is the closest thing QBT has to
        # the UT status of 'finished'. If you set your
        # config to pause completed torrents after hitting a share
        # ratio, this is the status, which UT would call finished.
        # MCM reads this as 'stopped'
        utstatus = '136'
    elif qbtstatus == 'pausedDL' and qbtforce is True:
        utstatus = '169'
    elif qbtstatus == 'pausedDL' and qbtforce is False:
        utstatus = '233'
    elif qbtstatus == 'queuedUP':
        utstatus = '200'
    elif qbtstatus == 'queuedDL':
        utstatus = '200'
    elif qbtstatus == 'uploading':
        utstatus = '201'
    elif qbtstatus == 'stalledUP':
        utstatus = '201'
    elif qbtstatus == 'checkingUP':
        utstatus = '130'
    elif qbtstatus == 'checkingDL':
        utstatus = '130'
    elif qbtstatus == 'downloading' and qbtforce is True:
        utstatus = '137'
    elif qbtstatus == 'downloading' and qbtforce is False:
        utstatus = '201'
    elif qbtstatus == 'stalledDL':
        utstatus = '201'
    elif qbtstatus == 'metaDL':
        utstatus = '201'
    else:
        # Just set the default to 201
        utstatus = '201'
    return utstatus

# NOTE: flask is case-sensitive, and mcm
# is using lower case uri.
API_QUT.add_resource(GUI, '/gui/')

if __name__ == '__main__':
    # Runs flask to listen on specified port
    # and interface/IP addy
    APP_QUT.run(host=UT_URI, port=UT_PORT, debug=FLASK_DEBUG)

mmkay. So I probably went overboard with the status code conversions, as MCM probably doesn't care about most of it. Again, this was literally my first python program so please forgive any abuses.

Modifying the Script Configuration
There are really just two things you need to tweak in the script, and if you have experience running utorrent and integrating it with MCM, this will all be very basic stuff conceptually.

Give the script info about your qbt installation. This is under the section labeled QBITTORRENT Constants. You need to provide three things here - the address to the service including the port number followed by a forward slash, and the username and password you configured in qbittorrent. This info should correspond to your config settings in QBT.

Give the script info about your ut configuration. This is under the section labeled UTORRENT Constants. You're just telling the script what IP to listen on (0.0.0.0 for any address) and the port number. For example, if you enter 0.0.0.0 here, the script will listen on any IP address your torrenting box is using.

Tell MCM how to get to your fake 'utorrent' service. In settings and preferences in MCM, under the uTorrent tab, the only server specific settings you need to worry about are the ip address for the webui and the port number. These correspond to what you configured in the script under UTORRENT Constants, except the IP address here won't be 0.0.0.0, it will be the address of the box running the script. The port number will be identical. The username and password in this tab won't be used for anything, so you could literally enter 'beavis' and 'butthead' here and you'll be fine.

Running the Script
How do you run it? From the command line run 'python <script name>'. I save this file as quti.py, so you'd just run 'python quti.py' from the command line to bring it up (The name is Qbt/UTorrentIntegration, if you're wondering). You can get this going at startup in various ways depending on which platform you're using. Supervisor is one method on the linux side, as well as using nginx and gunicorn. You could just make a batch file to kick it off on startup on Windows if that's your bag. Basically you can google how to start a python/flask app on startup for your respective platform and you'll get plenty of guides and options. On debian, if you want to get this script up and running quickly, mark the script as executable with 'chmod 755 ./quti.py', then create a .service file in /etc/systemd/system, such as quti.service. This file should contain the following:
[Unit]
Description=qbittorrent API Facade Service
After=syslog.target network.target

[Service]
User=root
ExecStart=/bin/bash -c "source /opt/qbscripts/qbapifacade/bin/activate; python /opt/qbscripts/qbapifacade/quti.py"
WorkingDirectory=/opt/qbscripts/qbapifacade/
Restart=on-failure
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

You can modify it to use the user account of your choice, just make sure that user has ownership/execute perms on the working directory. Also, this is activating my virtualenv environment, so on that execstart param, you can eliminate the source command if you're not using virtualenv. Just run the 'python quti.py' part. Keep your full path in the execstart and working directory params.

Room for Improvement
  • There's zero error handling in this script, but it's pretty reliable and MCM is pretty forgiving as well.
  • as said, it's probably best to use nginx to run the flask app rather than flask itself, if only for the simplicity of configuring it to run as a service on boot up. Considering the low request volume for personal use, I don't think there's any motivation to make this switch from a performance perspective. My debian box uses 2% of the host cpu and about 850MB of mem, just for the record.
  • if I had more information about how MCM is working with the UT data I'd probably make some changes to better integrate. For instance, I don't know if MCM tries to label the torrents anymore, but I don't see it trying via Nibbler, so I've not implemented any support for labels/categories. As a user I've never used labels, so yeah. Also, it seems like there's something funky in how the JSON data is being handled in MCM or being fed to it by UT - like reading in new lines and spaces as part of the hash from a JSON object in the list response make me think MCM may be manually parsing a string rather than using a deserializing/decoding library. This is accommodated in the script by stripping whitespace from the hash when MCM sends the hash back for another action, but I and maybe MCM are doing something ungraceful here that I would prefer to clean up.
Is this Useful? Got any Questions?
Curious if anyone else finds this useful. Let me know if you need assistance with setting this up or have any questions about this. I'm happy to help.


 Profile  
Display posts from previous:  Sort by  
Post new topic Reply to topic  [ 1 post ] 

All times are UTC - 7 hours [ DST ]


Who is online

Users browsing this forum: No registered users and 10 guests


You cannot post new topics in this forum
You cannot reply to topics in this forum
You cannot edit your posts in this forum
You cannot delete your posts in this forum
You cannot post attachments in this forum

Search for:
Jump to:  
cron


Powered by phpBB © 2000, 2002, 2005, 2007 phpBB Group



Copyright © 2009-2018, Media Center Master, Inc. All rights reserved.