Skip to content

Commit

Permalink
Added option to add trackers to a download (#8167)
Browse files Browse the repository at this point in the history
  • Loading branch information
qstokkink authored Sep 24, 2024
2 parents a81b03d + f7eee64 commit 15985e1
Show file tree
Hide file tree
Showing 10 changed files with 533 additions and 24 deletions.
10 changes: 6 additions & 4 deletions src/tribler/core/libtorrent/download_manager/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,10 +749,12 @@ def get_tracker_status(self) -> dict[str, tuple[int, str]]:
self.handle = cast(lt.torrent_handle, self.handle)
# Make sure all trackers are in the tracker_status dict
try:
for announce_entry in self.handle.trackers():
url = announce_entry["url"]
if url not in self.tracker_status:
self.tracker_status[url] = (0, "Not contacted yet")
tracker_urls = {tracker["url"] for tracker in self.handle.trackers()}
for removed in (set(self.tracker_status.keys()) - tracker_urls):
self.tracker_status.pop(removed)
for tracker_url in tracker_urls:
if tracker_url not in self.tracker_status:
self.tracker_status[tracker_url] = (0, "Not contacted yet")
except UnicodeDecodeError:
self._logger.warning("UnicodeDecodeError in get_tracker_status")

Expand Down
131 changes: 131 additions & 0 deletions src/tribler/core/libtorrent/restapi/downloads_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ def __init__(self, download_manager: DownloadManager, metadata_store: MetadataSt
web.delete("/{infohash}", self.delete_download),
web.patch("/{infohash}", self.update_download),
web.get("/{infohash}/torrent", self.get_torrent),
web.put("/{infohash}/trackers", self.add_tracker),
web.delete("/{infohash}/trackers", self.remove_tracker),
web.put("/{infohash}/tracker_force_announce", self.tracker_force_announce),
web.get("/{infohash}/files", self.get_files),
web.get("/{infohash}/files/expand", self.expand_tree_directory),
web.get("/{infohash}/files/collapse", self.collapse_tree_directory),
Expand Down Expand Up @@ -612,6 +615,134 @@ async def get_torrent(self, request: Request) -> RESTResponse:
"Content-Disposition": f"attachment; filename={hexlify(infohash).decode()}.torrent"
})

@docs(
tags=["Libtorrent"],
summary="Add a tracker to the specified torrent.",
parameters=[{
"in": "path",
"name": "infohash",
"description": "Infohash of the download to add the given tracker to",
"type": "string",
"required": True
}],
responses={
200: {
"schema": schema(AddTrackerResponse={"added": Boolean}),
"examples": {"added": True}
}
}
)
@json_schema(schema(AddTrackerRequest={
"url": (String, "The tracker URL to insert"),
}))
async def add_tracker(self, request: Request) -> RESTResponse:
"""
Return the .torrent file associated with the specified download.
"""
infohash = unhexlify(request.match_info["infohash"])
download = self.download_manager.get_download(infohash)
if not download:
return DownloadsEndpoint.return_404()

parameters = await request.json()
url = parameters.get("url")
if not url:
return RESTResponse({"error": "url parameter missing"}, status=HTTP_BAD_REQUEST)

try:
download.add_trackers([url])
download.handle.force_reannounce(0, len(download.handle.trackers()) - 1)
except RuntimeError as e:
return RESTResponse({"error": str(e)}, status=HTTP_INTERNAL_SERVER_ERROR)

return RESTResponse({"added": True})

@docs(
tags=["Libtorrent"],
summary="Remove a tracker from the specified torrent.",
parameters=[{
"in": "path",
"name": "infohash",
"description": "Infohash of the download to remove the given tracker from",
"type": "string",
"required": True
}],
responses={
200: {
"schema": schema(AddTrackerResponse={"removed": Boolean}),
"examples": {"removed": True}
}
}
)
@json_schema(schema(AddTrackerRequest={
"url": (String, "The tracker URL to remove"),
}))
async def remove_tracker(self, request: Request) -> RESTResponse:
"""
Return the .torrent file associated with the specified download.
"""
infohash = unhexlify(request.match_info["infohash"])
download = self.download_manager.get_download(infohash)
if not download:
return DownloadsEndpoint.return_404()

parameters = await request.json()
url = parameters.get("url")
if not url:
return RESTResponse({"error": "url parameter missing"}, status=HTTP_BAD_REQUEST)

try:
download.handle.replace_trackers([tracker for tracker in download.handle.trackers()
if tracker["url"] != url])
except RuntimeError as e:
return RESTResponse({"error": str(e)}, status=HTTP_INTERNAL_SERVER_ERROR)

return RESTResponse({"removed": True})

@docs(
tags=["Libtorrent"],
summary="Forcefully announce to the given tracker.",
parameters=[{
"in": "path",
"name": "infohash",
"description": "Infohash of the download to force the tracker announce for",
"type": "string",
"required": True
}],
responses={
200: {
"schema": schema(AddTrackerResponse={"forced": Boolean}),
"examples": {"forced": True}
}
}
)
@json_schema(schema(AddTrackerRequest={
"url": (String, "The tracker URL to query"),
}))
async def tracker_force_announce(self, request: Request) -> RESTResponse:
"""
Forcefully announce to the given tracker.
"""
infohash = unhexlify(request.match_info["infohash"])
download = self.download_manager.get_download(infohash)
if not download:
return DownloadsEndpoint.return_404()

parameters = await request.json()
url = parameters.get("url")
if not url:
return RESTResponse({"error": "url parameter missing"}, status=HTTP_BAD_REQUEST)

try:
for i, tracker in enumerate(download.handle.trackers()):
if tracker["url"] == url:
download.handle.force_reannounce(0, i)
break
except RuntimeError as e:
return RESTResponse({"error": str(e)}, status=HTTP_INTERNAL_SERVER_ERROR)

return RESTResponse({"forced": True})

@docs(
tags=["Libtorrent"],
summary="Return file information of a specific download.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

from asyncio import ensure_future, sleep
from binascii import hexlify
from io import StringIO
from pathlib import Path
from unittest.mock import AsyncMock, Mock, call, patch
Expand Down Expand Up @@ -237,6 +238,32 @@ def match_info(self) -> UrlMappingMatchInfo:
return UrlMappingMatchInfo({"infohash": self._infohash}, Mock())


class GenericTrackerRequest(MockRequest):
"""
A MockRequest that mimics requests to add, remove or check trackers.
"""

def __init__(self, infohash: str, url: str | None, method: str, sub_endpoint: str) -> None:
"""
Create a new AddDownloadRequest.
"""
super().__init__({"url": url}, method, f"/downloads/{infohash}/{sub_endpoint}")
self._infohash = infohash

async def json(self) -> dict:
"""
Get the json equivalent of the query (i.e., just the query).
"""
return self._query

@property
def match_info(self) -> UrlMappingMatchInfo:
"""
Get the match info (the infohash in the url).
"""
return UrlMappingMatchInfo({"infohash": self._infohash}, Mock())


class StreamRequest(MockRequest):
"""
A MockRequest that mimics StreamRequests.
Expand Down Expand Up @@ -1187,3 +1214,196 @@ async def test_stream(self) -> None:

self.assertEqual(206, response.status)
self.assertEqual(b'"', request.get_transmitted())

async def test_add_tracker(self) -> None:
"""
Test if trackers can be added to a download.
"""
trackers = ["http://127.0.0.1/somethingelse"]
download = self.create_mock_download()
download.handle = Mock(is_valid=Mock(return_value=True), trackers=Mock(return_value=trackers),
add_tracker=lambda tracker_dict: trackers.append(tracker_dict["url"]))
self.download_manager.get_download = Mock(return_value=download)
url = "http://127.0.0.1/announce"

response = await self.endpoint.add_tracker(
GenericTrackerRequest(hexlify(download.tdef.infohash).decode(), url, "PUT", "trackers")
)
response_body_json = await response_to_json(response)

self.assertEqual(200, response.status)
self.assertTrue(response_body_json["added"])
self.assertListEqual(["http://127.0.0.1/somethingelse", url], trackers)
self.assertEqual(call(0, 1), download.handle.force_reannounce.call_args)

async def test_add_tracker_no_download(self) -> None:
"""
Test if adding a tracker fails when no download is found.
"""
self.download_manager.get_download = Mock(return_value=None)

response = await self.endpoint.add_tracker(GenericTrackerRequest("AA" * 20, "http://127.0.0.1/announce",
"PUT", "trackers"))

self.assertEqual(404, response.status)

async def test_add_tracker_no_url(self) -> None:
"""
Test if adding a tracker fails when no tracker url is given.
"""
download = self.create_mock_download()
download.handle = Mock(is_valid=Mock(return_value=True), trackers=Mock(return_value=[]))
self.download_manager.get_download = Mock(return_value=download)

response = await self.endpoint.add_tracker(GenericTrackerRequest("AA" * 20, None, "PUT", "trackers"))

self.assertEqual(400, response.status)

async def test_add_tracker_handle_error(self) -> None:
"""
Test if adding a tracker fails when a libtorrent internal error occurs.
"""
trackers = ["http://127.0.0.1/somethingelse"]
download = self.create_mock_download()
download.handle = Mock(is_valid=Mock(return_value=True), trackers=Mock(return_value=trackers),
add_tracker=Mock(side_effect=RuntimeError("invalid torrent handle used")))
self.download_manager.get_download = Mock(return_value=download)
url = "http://127.0.0.1/announce"

response = await self.endpoint.add_tracker(
GenericTrackerRequest(hexlify(download.tdef.infohash).decode(), url, "PUT", "trackers")
)
response_body_json = await response_to_json(response)

self.assertEqual(500, response.status)
self.assertEqual("invalid torrent handle used", response_body_json["error"])

async def test_remove_tracker(self) -> None:
"""
Test if trackers can be removed from a download.
"""
trackers = [{"url": "http://127.0.0.1/somethingelse", "verified": True},
{"url": "http://127.0.0.1/announce", "verified": True}]
download = self.create_mock_download()
download.handle = Mock(is_valid=Mock(return_value=True), trackers=Mock(return_value=trackers),
replace_trackers=lambda new_trackers: (trackers.clear()
is trackers.extend(new_trackers)))
self.download_manager.get_download = Mock(return_value=download)
url = "http://127.0.0.1/announce"

response = await self.endpoint.remove_tracker(
GenericTrackerRequest(hexlify(download.tdef.infohash).decode(), url, "DELETE", "trackers")
)
response_body_json = await response_to_json(response)

self.assertEqual(200, response.status)
self.assertTrue(response_body_json["removed"])
self.assertListEqual([{"url": "http://127.0.0.1/somethingelse", "verified": True}], trackers)

async def test_remove_tracker_no_download(self) -> None:
"""
Test if removing a tracker fails when no download is found.
"""
self.download_manager.get_download = Mock(return_value=None)

response = await self.endpoint.remove_tracker(GenericTrackerRequest("AA" * 20, "http://127.0.0.1/announce",
"DELETE", "trackers"))

self.assertEqual(404, response.status)

async def test_remove_tracker_no_url(self) -> None:
"""
Test if removing a tracker fails when no tracker url is given.
"""
download = self.create_mock_download()
download.handle = Mock(is_valid=Mock(return_value=True), trackers=Mock(return_value=[]))
self.download_manager.get_download = Mock(return_value=download)

response = await self.endpoint.remove_tracker(GenericTrackerRequest("AA" * 20, None, "DELETE", "trackers"))

self.assertEqual(400, response.status)

async def test_remove_tracker_handle_error(self) -> None:
"""
Test if removing a tracker fails when a libtorrent internal error occurs.
"""
trackers = [{"url": "http://127.0.0.1/somethingelse", "verified": True},
{"url": "http://127.0.0.1/announce", "verified": True}]
download = self.create_mock_download()
download.handle = Mock(is_valid=Mock(return_value=True), trackers=Mock(return_value=trackers),
replace_trackers=Mock(side_effect=RuntimeError("invalid torrent handle used")))
self.download_manager.get_download = Mock(return_value=download)
url = "http://127.0.0.1/announce"

response = await self.endpoint.remove_tracker(
GenericTrackerRequest(hexlify(download.tdef.infohash).decode(), url, "DELETE", "trackers")
)
response_body_json = await response_to_json(response)

self.assertEqual(500, response.status)
self.assertEqual("invalid torrent handle used", response_body_json["error"])

async def test_tracker_force_announce(self) -> None:
"""
Test if trackers can be force announced.
"""
trackers = [{"url": "http://127.0.0.1/somethingelse", "verified": True},
{"url": "http://127.0.0.1/announce", "verified": True}]
download = self.create_mock_download()
download.handle = Mock(is_valid=Mock(return_value=True), trackers=Mock(return_value=trackers))
self.download_manager.get_download = Mock(return_value=download)
url = "http://127.0.0.1/announce"

response = await self.endpoint.tracker_force_announce(
GenericTrackerRequest(hexlify(download.tdef.infohash).decode(), url, "PUT", "tracker_force_announce")
)
response_body_json = await response_to_json(response)

self.assertEqual(200, response.status)
self.assertTrue(response_body_json["forced"])
self.assertEqual(call(0, 1), download.handle.force_reannounce.call_args)

async def test_tracker_force_announce_no_download(self) -> None:
"""
Test if force-announcing a tracker fails when no download is found.
"""
self.download_manager.get_download = Mock(return_value=None)

response = await self.endpoint.tracker_force_announce(
GenericTrackerRequest("AA" * 20, "http://127.0.0.1/announce", "PUT", "tracker_force_announce")
)

self.assertEqual(404, response.status)

async def test_tracker_force_announce_no_url(self) -> None:
"""
Test if force-announcing a tracker fails when no tracker url is given.
"""
download = self.create_mock_download()
download.handle = Mock(is_valid=Mock(return_value=True), trackers=Mock(return_value=[]))
self.download_manager.get_download = Mock(return_value=download)

response = await self.endpoint.tracker_force_announce(GenericTrackerRequest("AA" * 20, None, "PUT",
"tracker_force_announce"))

self.assertEqual(400, response.status)

async def test_tracker_force_announce_handle_error(self) -> None:
"""
Test if force-announcing a tracker fails when a libtorrent internal error occurs.
"""
trackers = [{"url": "http://127.0.0.1/somethingelse", "verified": True},
{"url": "http://127.0.0.1/announce", "verified": True}]
download = self.create_mock_download()
download.handle = Mock(is_valid=Mock(return_value=True), trackers=Mock(return_value=trackers),
force_reannounce=Mock(side_effect=RuntimeError("invalid torrent handle used")))
self.download_manager.get_download = Mock(return_value=download)
url = "http://127.0.0.1/announce"

response = await self.endpoint.tracker_force_announce(
GenericTrackerRequest(hexlify(download.tdef.infohash).decode(), url, "PUT", "tracker_force_announce")
)
response_body_json = await response_to_json(response)

self.assertEqual(500, response.status)
self.assertEqual("invalid torrent handle used", response_body_json["error"])
Loading

0 comments on commit 15985e1

Please sign in to comment.