/* * This file is part of Transdroid * * Transdroid 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. * * Transdroid 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 Transdroid. If not, see . * */ package org.transdroid.daemon.adapters.qBittorrent; import com.android.internal.http.multipart.FilePart; import com.android.internal.http.multipart.MultipartEntity; import com.android.internal.http.multipart.Part; import org.apache.http.HttpEntity; import org.apache.http.HttpResponse; import org.apache.http.NameValuePair; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpPost; import org.apache.http.cookie.Cookie; import org.apache.http.impl.client.DefaultHttpClient; import org.apache.http.message.BasicNameValuePair; import org.apache.http.protocol.HTTP; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.transdroid.core.gui.log.Log; import org.transdroid.daemon.Daemon; import org.transdroid.daemon.DaemonException; import org.transdroid.daemon.DaemonException.ExceptionType; import org.transdroid.daemon.DaemonSettings; import org.transdroid.daemon.IDaemonAdapter; import org.transdroid.daemon.Label; import org.transdroid.daemon.Priority; import org.transdroid.daemon.Torrent; import org.transdroid.daemon.TorrentDetails; import org.transdroid.daemon.TorrentFile; import org.transdroid.daemon.TorrentStatus; import org.transdroid.daemon.task.AddByFileTask; import org.transdroid.daemon.task.AddByMagnetUrlTask; import org.transdroid.daemon.task.AddByUrlTask; import org.transdroid.daemon.task.DaemonTask; import org.transdroid.daemon.task.DaemonTaskFailureResult; import org.transdroid.daemon.task.DaemonTaskResult; import org.transdroid.daemon.task.DaemonTaskSuccessResult; import org.transdroid.daemon.task.GetFileListTask; import org.transdroid.daemon.task.GetFileListTaskSuccessResult; import org.transdroid.daemon.task.GetStatsTask; import org.transdroid.daemon.task.GetStatsTaskSuccessResult; import org.transdroid.daemon.task.GetTorrentDetailsTask; import org.transdroid.daemon.task.GetTorrentDetailsTaskSuccessResult; import org.transdroid.daemon.task.RemoveTask; import org.transdroid.daemon.task.RetrieveTask; import org.transdroid.daemon.task.RetrieveTaskSuccessResult; import org.transdroid.daemon.task.SetDownloadLocationTask; import org.transdroid.daemon.task.SetFilePriorityTask; import org.transdroid.daemon.task.SetLabelTask; import org.transdroid.daemon.task.SetTransferRatesTask; import org.transdroid.daemon.util.HttpHelper; import java.io.File; import java.io.FileNotFoundException; import java.io.UnsupportedEncodingException; import java.net.URI; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; /** * The daemon adapter for the qBittorrent torrent client. * * @author erickok */ public class QBittorrentAdapter implements IDaemonAdapter { private static final String LOG_NAME = "qBittorrent daemon"; private DaemonSettings settings; private DefaultHttpClient httpclient; private int version = -1; private long lastAuthTime = -1; private int qbNoPriority = 0; private int qbLowPriority = 1; private int qbNormalPriority = 2; private int qbHighPriority = 7; public QBittorrentAdapter(DaemonSettings settings) { this.settings = settings; } private synchronized void ensureVersion(Log log) { // Still need to retrieve the API and qBittorrent version numbers from the server? if (version > 0) return; // Since 4.1, API v2 is used. Since qBittorrent 3.2, API v1 is used. Otherwise we use unofficial legacy json endpoints. try { String versionText = ""; try { // Try v2 API first, which returns version number in 'v4.1.9' format versionText = makeRequest(log, "/api/v2/app/version").substring(1); } catch (Exception e1) { // Try v1 API, which returns version number in 'v3.2.0' format try { versionText = makeRequest(log, "/version/qbittorrent").substring(1); } catch (Exception e2) { // Legacy mode; format is something like 'qBittorrent v2.9.7 (Web UI)' or 'qBittorrent v3.0.0-alpha5 (Web UI)' String about = makeRequest(log, "/about.html"); String aboutStartText = "qBittorrent v"; String aboutEndText = " (Web UI)"; int aboutStart = about.indexOf(aboutStartText); int aboutEnd = about.indexOf(aboutEndText); if (aboutStart >= 0 && aboutEnd > aboutStart) { versionText = about.substring(aboutStart + aboutStartText.length(), aboutEnd); } } } version = parseVersionNumber(versionText); if (version >= 30200) { qbNormalPriority = 6; } } catch (Exception e) { // Unable to establish version number; assume an old version by setting it to version 1 version = 10000; qbNormalPriority = 2; } } private int parseVersionNumber(String versionText) { // String found: now parse a version like 2.9.7 as a number like 20907 (allowing 10 places for each .) int version = -1; String[] parts = versionText.split("\\."); if (parts.length > 0) { version = Integer.parseInt(parts[0]) * 100 * 100; if (parts.length > 1) { version += Integer.parseInt(parts[1]) * 100; if (parts.length > 2) { // For the last part only read until a non-numeric character is read // For example version 3.0.0-alpha5 is read as version code 30000 String numbers = ""; for (char c : parts[2].toCharArray()) { if (Character.isDigit(c)) // Still a number; add it to the numbers string numbers += Character.toString(c); else { // No longer reading numbers; stop reading break; } } version += Integer.parseInt(numbers); } } } return version; } private synchronized void ensureAuthenticated(Log log) throws DaemonException { // Have we already authenticated? Check if we have the cookie that we need if (isAuthenticated()) { return; } final BasicNameValuePair usernameParam = new BasicNameValuePair("username", settings.getUsername()); final BasicNameValuePair passwordParam = new BasicNameValuePair("password", settings.getPassword()); // Try qBittorrent 4.1 API v2 first try { makeRequest(log, "/api/v2/auth/login", usernameParam, passwordParam); lastAuthTime = System.currentTimeMillis(); } catch (DaemonException ignored) { } // If still not authenticated, try the qBittorrent 3.2 API v1 endpoint if (!isAuthenticated()) { try { makeRequest(log, "/login", usernameParam, passwordParam); lastAuthTime = System.currentTimeMillis(); } catch (DaemonException ignored) { } } if (!isAuthenticated()) { throw new DaemonException(ExceptionType.AuthenticationFailure, "Server rejected our login"); } } private boolean isAuthenticated() { if (System.currentTimeMillis() - lastAuthTime > MAX_SESSION_TIME) { return false; } List cookies = httpclient.getCookieStore().getCookies(); for (Cookie c : cookies) { if (c.getName().equals("SID")) { // And here it is! Okay, no need authenticate again. return true; } } return false; } @Override public DaemonTaskResult executeTask(Log log, DaemonTask task) { try { initialise(); ensureAuthenticated(log); ensureVersion(log); switch (task.getMethod()) { case Retrieve: // Request all torrents from server String path; if (version >= 40100) { path = "/api/v2/torrents/info"; } else if (version >= 30200) { path = "/query/torrents"; } else if (version >= 30000) { path = "/json/torrents"; } else { path = "/json/events"; } JSONArray result = new JSONArray(makeRequest(log, path)); return new RetrieveTaskSuccessResult((RetrieveTask) task, parseJsonTorrents(result), parseJsonLabels(result)); case GetTorrentDetails: // Request tracker and error details for a specific teacher String mhash = task.getTargetTorrent().getUniqueID(); JSONArray messages; JSONArray pieces; if (version >= 40100) { messages = new JSONArray(makeRequest(log, "/api/v2/torrents/trackers", new BasicNameValuePair("hash", mhash))); pieces = new JSONArray(makeRequest(log, "/api/v2/torrents/pieceStates", new BasicNameValuePair("hash", mhash))); } else { messages = new JSONArray(makeRequest(log, "/query/propertiesTrackers/" + mhash)); pieces = new JSONArray(makeRequest(log, "/query/getPieceStates/" + mhash)); } return new GetTorrentDetailsTaskSuccessResult((GetTorrentDetailsTask) task, parseJsonTorrentDetails(messages, pieces)); case GetFileList: // Request files listing for a specific torrent String fhash = task.getTargetTorrent().getUniqueID(); JSONArray files; if (version >= 40100) { files = new JSONArray(makeRequest(log, "/api/v2/torrents/files", new BasicNameValuePair("hash", fhash))); } else if (version >= 30200) { files = new JSONArray(makeRequest(log, "/query/propertiesFiles/" + fhash)); } else { files = new JSONArray(makeRequest(log, "/json/propertiesFiles/" + fhash)); } return new GetFileListTaskSuccessResult((GetFileListTask) task, parseJsonFiles(files)); case AddByFile: // Upload a local .torrent file if (version >= 40100) { path = "/api/v2/torrents/add"; } else { path = "/command/upload"; } String ufile = ((AddByFileTask) task).getFile(); makeUploadRequest(path, ufile, log); return new DaemonTaskSuccessResult(task); case AddByUrl: // Request to add a torrent by URL String url = ((AddByUrlTask) task).getUrl(); if (version >= 40100) { path = "/api/v2/torrents/add"; } else { path = "/command/upload"; } makeRequest(log, path, new BasicNameValuePair("urls", url)); return new DaemonTaskSuccessResult(task); case AddByMagnetUrl: // Request to add a magnet link by URL String magnet = ((AddByMagnetUrlTask) task).getUrl(); if (version >= 40100) { path = "/api/v2/torrents/add"; } else { path = "/command/download"; } makeRequest(log, path, new BasicNameValuePair("urls", magnet)); return new DaemonTaskSuccessResult(task); case Remove: // Remove a torrent RemoveTask removeTask = (RemoveTask) task; if (version >= 40100) { if (removeTask.includingData()) { makeRequest(log, "/api/v2/torrents/delete", new BasicNameValuePair("hashes", removeTask.getTargetTorrent().getUniqueID()), new BasicNameValuePair("deleteFiles", "true")); } else { makeRequest(log, "/api/v2/torrents/delete", new BasicNameValuePair("hashes", removeTask.getTargetTorrent().getUniqueID()), new BasicNameValuePair("deleteFiles", "false")); } } else { path = (removeTask.includingData() ? "/command/deletePerm" : "/command/delete"); makeRequest(log, path, new BasicNameValuePair("hashes", removeTask.getTargetTorrent().getUniqueID())); } return new DaemonTaskSuccessResult(task); case Pause: // Pause a torrent if (version >= 40100) { makeRequest(log, "/api/v2/torrents/pause", new BasicNameValuePair("hashes", task.getTargetTorrent().getUniqueID())); } else { makeRequest(log, "/command/pause", new BasicNameValuePair("hash", task.getTargetTorrent().getUniqueID())); } return new DaemonTaskSuccessResult(task); case PauseAll: // Resume all torrents if (version >= 40100) { makeRequest(log, "/api/v2/torrents/pause", new BasicNameValuePair("hashes", "all")); } else { makeRequest(log, "/command/pauseall"); } return new DaemonTaskSuccessResult(task); case Resume: // Resume a torrent if (version >= 40100) { makeRequest(log, "/api/v2/torrents/resume", new BasicNameValuePair("hashes", task.getTargetTorrent().getUniqueID())); } else { makeRequest(log, "/command/resume", new BasicNameValuePair("hash", task.getTargetTorrent().getUniqueID())); } return new DaemonTaskSuccessResult(task); case ResumeAll: // Resume all torrents if (version >= 40100) { path = "/api/v2/torrents/resume"; makeRequest(log, path, new BasicNameValuePair("hashes", "all")); } else { makeRequest(log, "/command/resumeall"); } return new DaemonTaskSuccessResult(task); case SetFilePriorities: // Update the priorities to a set of files SetFilePriorityTask setPrio = (SetFilePriorityTask) task; int newPrio = qbNoPriority; if (setPrio.getNewPriority() == Priority.Low) { newPrio = qbLowPriority; } else if (setPrio.getNewPriority() == Priority.Normal) { newPrio = qbNormalPriority; } else if (setPrio.getNewPriority() == Priority.High) { newPrio = qbHighPriority; } // We have to make a separate request per file, it seems for (TorrentFile file : setPrio.getForFiles()) { if (version >= 40100) { path = "/api/v2/torrents/filePrio"; } else { path = "/command/setFilePrio"; } makeRequest(log, path, new BasicNameValuePair("hash", task.getTargetTorrent().getUniqueID()), new BasicNameValuePair("id", file.getKey()), new BasicNameValuePair("priority", Integer.toString(newPrio))); } return new DaemonTaskSuccessResult(task); case ForceRecheck: // Force recheck a torrent if (version >= 40100) { path = "/api/v2/torrents/recheck"; } else { path = "/command/recheck"; } makeRequest(log, path, new BasicNameValuePair("hashes", task.getTargetTorrent().getUniqueID())); return new DaemonTaskSuccessResult(task); case ToggleSequentialDownload: // Toggle sequential download mode on a torrent if (version >= 40100) { path = "/api/v2/torrents/toggleSequentialDownload"; } else { path = "/command/toggleSequentialDownload"; } makeRequest(log, path, new BasicNameValuePair("hashes", task.getTargetTorrent().getUniqueID())); return new DaemonTaskSuccessResult(task); case ToggleFirstLastPieceDownload: // Set policy for downloading first and last piece first on a torrent if (version >= 40100) { path = "/api/v2/torrents/toggleFirstLastPiecePrio"; } else { path = "/command/toggleFirstLastPiecePrio"; } makeRequest(log, path, new BasicNameValuePair("hashes", task.getTargetTorrent().getUniqueID())); return new DaemonTaskSuccessResult(task); case SetLabel: SetLabelTask labelTask = (SetLabelTask) task; if (version >= 40100) { path = "/api/v2/torrents/setCategory"; } else { path = "/command/setCategory"; } makeRequest(log, path, new BasicNameValuePair("hashes", task.getTargetTorrent().getUniqueID()), new BasicNameValuePair("category", labelTask.getNewLabel())); return new DaemonTaskSuccessResult(task); case SetDownloadLocation: SetDownloadLocationTask setLocationTask = (SetDownloadLocationTask) task; if (version >= 40100) { path = "/api/v2/torrents/setLocation"; } else { path = "/command/setLocation"; } makeRequest(log, path, new BasicNameValuePair("hashes", task.getTargetTorrent().getUniqueID()), new BasicNameValuePair("location", setLocationTask.getNewLocation())); return new DaemonTaskSuccessResult(task); case SetTransferRates: // Request to set the maximum transfer rates String pathDL; String pathUL; SetTransferRatesTask ratesTask = (SetTransferRatesTask) task; String dl = (ratesTask.getDownloadRate() == null ? "NaN" : Long.toString(ratesTask.getDownloadRate() * 1024)); String ul = (ratesTask.getUploadRate() == null ? "NaN" : Long.toString(ratesTask.getUploadRate() * 1024)); if (version >= 40100) { pathDL = "/api/v2/torrents/setDownloadLimit"; pathUL = "/api/v2/torrents/setUploadLimit"; } else { pathDL = "/command/setGlobalDlLimit"; pathUL = "/command/setGlobalUpLimit"; } makeRequest(log, pathDL, new BasicNameValuePair("limit", dl)); makeRequest(log, pathUL, new BasicNameValuePair("limit", ul)); return new DaemonTaskSuccessResult(task); case GetStats: // Refresh alternative download speeds setting if (version >= 40100) { path = "/api/v2/sync/maindata?rid=0"; } else { path = "/sync/maindata?rid=0"; } JSONObject stats = new JSONObject(makeRequest(log, path)); JSONObject serverStats = stats.optJSONObject("server_state"); boolean alternativeSpeeds = false; if (serverStats != null) { alternativeSpeeds = serverStats.optBoolean("use_alt_speed_limits"); } return new GetStatsTaskSuccessResult((GetStatsTask) task, alternativeSpeeds, -1); case SetAlternativeMode: // Flip alternative speed mode if (version >= 40100) { path = "/api/v2/transfer/toggleSpeedLimitsMode"; } else { path = "/command/toggleAlternativeSpeedLimits"; } makeRequest(log, path); return new DaemonTaskSuccessResult(task); default: return new DaemonTaskFailureResult(task, new DaemonException(ExceptionType.MethodUnsupported, task.getMethod() + " is not supported by " + getType())); } } catch (JSONException e) { return new DaemonTaskFailureResult(task, new DaemonException(ExceptionType.ParsingFailed, e.toString())); } catch (DaemonException e) { return new DaemonTaskFailureResult(task, e); } } private String makeRequest(Log log, String path, NameValuePair... params) throws DaemonException { try { // Setup request using POST String url_to_request = buildWebUIUrl(path); HttpPost httppost = new HttpPost(url_to_request); List nvps = new ArrayList<>(); Collections.addAll(nvps, params); httppost.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8)); return makeWebRequest(httppost, log); } catch (UnsupportedEncodingException e) { throw new DaemonException(ExceptionType.ConnectionError, e.toString()); } } private String makeUploadRequest(String path, String file, Log log) throws DaemonException { try { // Setup request using POST HttpPost httppost = new HttpPost(buildWebUIUrl(path)); File upload = new File(URI.create(file)); Part[] parts = {new FilePart("torrentfile", upload)}; httppost.setEntity(new MultipartEntity(parts, httppost.getParams())); return makeWebRequest(httppost, log); } catch (FileNotFoundException e) { throw new DaemonException(ExceptionType.FileAccessError, e.toString()); } } private String makeWebRequest(HttpPost httppost, Log log) throws DaemonException { try { // Execute HttpResponse response = httpclient.execute(httppost); // Throw exception on 403 if (response.getStatusLine().getStatusCode() == 403) { throw new DaemonException(ExceptionType.AuthenticationFailure, "Response code 403"); } HttpEntity entity = response.getEntity(); if (entity != null) { // Read JSON response java.io.InputStream instream = entity.getContent(); String result = HttpHelper.convertStreamToString(instream); instream.close(); // TLog.d(LOG_NAME, "Success: " + (result.length() > 300? result.substring(0, 300) + "... (" + // result.length() + " chars)": result)); // Return raw result return result; } log.d(LOG_NAME, "Error: No entity in HTTP response"); throw new DaemonException(ExceptionType.UnexpectedResponse, "No HTTP entity object in response."); } catch (Exception e) { log.d(LOG_NAME, "Error: " + e.toString()); if (e instanceof DaemonException) { throw (DaemonException) e; } else { throw new DaemonException(ExceptionType.ConnectionError, e.toString()); } } } /** * Instantiates an HTTP client with proper credentials that can be used for all qBittorrent requests. * * @throws DaemonException On conflicting or missing settings */ private synchronized void initialise() throws DaemonException { if (httpclient == null) { httpclient = HttpHelper.createStandardHttpClient(settings, true); } } /** * Build the URL of the web UI request from the user settings * * @return The URL to request */ private String buildWebUIUrl(String path) { String proxyFolder = settings.getFolder(); if (proxyFolder == null) proxyFolder = ""; else if (proxyFolder.endsWith("/")) proxyFolder = proxyFolder.substring(0, proxyFolder.length() - 1); return (settings.getSsl() ? "https://" : "http://") + settings.getAddress() + ":" + settings.getPort() + proxyFolder + path; } private TorrentDetails parseJsonTorrentDetails(JSONArray messages, JSONArray pieceStates) throws JSONException { ArrayList trackers = new ArrayList<>(); ArrayList errors = new ArrayList<>(); // Parse response if (messages.length() > 0) { for (int i = 0; i < messages.length(); i++) { JSONObject tor = messages.getJSONObject(i); trackers.add(tor.getString("url")); String msg = tor.getString("msg"); if (msg != null && !msg.equals("")) errors.add(msg); } } ArrayList pieces = new ArrayList<>(); if (pieceStates.length() > 0) { for (int i = 0; i < pieceStates.length(); i++) { pieces.add(pieceStates.getInt(i)); } } // Return the list return new TorrentDetails(trackers, errors, pieces); } private List