Skip to content

Commit

Permalink
Added watch folder again (#8306)
Browse files Browse the repository at this point in the history
  • Loading branch information
qstokkink authored Dec 5, 2024
2 parents d66c81c + 5087eba commit 1b48255
Show file tree
Hide file tree
Showing 18 changed files with 303 additions and 46 deletions.
16 changes: 16 additions & 0 deletions src/tribler/core/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,3 +383,19 @@ def get_endpoints(self) -> list[RESTEndpoint]:
from tribler.core.versioning.restapi.versioning_endpoint import VersioningEndpoint

return [*super().get_endpoints(), VersioningEndpoint()]


@precondition('session.config.get("watch_folder/enabled")')
class WatchFolderComponent(ComponentLauncher):
"""
Launch instructions for the watch folder.
"""

def finalize(self, ipv8: IPv8, session: Session, community: Community) -> None:
"""
When we are done launching, register our REST API.
"""
from tribler.core.watch_folder.manager import WatchFolderManager

manager = WatchFolderManager(session, community)
manager.start()
1 change: 0 additions & 1 deletion src/tribler/core/notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ class Notification(Enum):
circuit_removed = Desc("circuit_removed", ["circuit", "additional_info"], [str, Circuit])
tunnel_removed = Desc("tunnel_removed", ["circuit_id", "bytes_up", "bytes_down", "uptime", "additional_info"],
[int, int, int, float, str])
watch_folder_corrupt_file = Desc("watch_folder_corrupt_file", ["file_name"], [str])
torrent_health_updated = Desc("torrent_health_updated",
["infohash", "num_seeders", "num_leechers", "last_tracker_check", "health"],
[str, int, int, int, str])
Expand Down
1 change: 0 additions & 1 deletion src/tribler/core/restapi/events_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
topics_to_send_to_gui = [
Notification.torrent_status_changed,
Notification.tunnel_removed,
Notification.watch_folder_corrupt_file,
Notification.tribler_new_version,
Notification.tribler_exception,
Notification.torrent_finished,
Expand Down
7 changes: 6 additions & 1 deletion src/tribler/core/restapi/settings_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,19 @@ async def update_settings(self, request: web.Request) -> RESTResponse:
return RESTResponse({"modified": True})

def _recursive_merge_settings(self, existing: dict, updates: dict, top: bool = True) -> None:
for key in existing:
for key in existing: # noqa: PLC0206
# Ignore top-level ui entry
if top and key == "ui":
continue
value = updates.get(key, existing[key])
if isinstance(value, dict):
self._recursive_merge_settings(existing[key], value, False)
existing[key] = value
# It can also be that the updated entry does not exist (yet) in an old config.
for key in updates: # noqa: PLC0206
if key in existing:
continue
existing[key] = updates[key]

# Since the core doesn't need to be aware of the GUI settings, we just copy them.
if top and "ui" in updates:
Expand Down
3 changes: 2 additions & 1 deletion src/tribler/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
TorrentCheckerComponent,
TunnelComponent,
VersioningComponent,
WatchFolderComponent,
)
from tribler.core.libtorrent.download_manager.download_manager import DownloadManager
from tribler.core.libtorrent.restapi.create_torrent_endpoint import CreateTorrentEndpoint
Expand Down Expand Up @@ -154,7 +155,7 @@ def register_launchers(self) -> None:
"""
for launcher_class in [ContentDiscoveryComponent, DatabaseComponent, DHTDiscoveryComponent, KnowledgeComponent,
RecommenderComponent, RendezvousComponent, TorrentCheckerComponent, TunnelComponent,
VersioningComponent]:
VersioningComponent, WatchFolderComponent]:
instance = launcher_class()
for rest_ep in instance.get_endpoints():
self.rest_manager.add_endpoint(rest_ep)
Expand Down
Empty file.
78 changes: 78 additions & 0 deletions src/tribler/core/watch_folder/manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import logging
import os
from pathlib import Path

from ipv8.taskmanager import TaskManager

from tribler.core.libtorrent.torrentdef import TorrentDef
from tribler.core.session import Session

logger = logging.getLogger(__name__)


class WatchFolderManager:
"""
Watch the torrent files in a folder.
Add torrents that are in this folder and remove torrents that are removed while we are watching.
"""

def __init__(self, session: Session, task_manager: TaskManager) -> None:
"""
Attach to the given task manager.
"""
super().__init__()
self.session = session
self.task_manager = task_manager

def start(self) -> None:
"""
Start the periodic processing of the watch folder.
"""
update_interval = self.session.config.get("watch_folder/check_interval")
self.task_manager.register_task("Watch Folder", self.check, interval=update_interval, delay=update_interval)

def check(self) -> bool:
"""
Check the watch folder for new torrents and start downloading them.
"""
logger.debug("Checking watch folder...")
str_directory = self.session.config.get("watch_folder/directory")
if not str_directory:
logger.debug("Cancelled. Directory: %s.", str_directory)
return False

path_directory = Path(str_directory).absolute()
logger.info("Checking watch folder: %s", str(path_directory))
if not path_directory.exists():
logger.warning("Cancelled. Directory does not exist: %s.", str(path_directory))
return False

if not path_directory.is_dir():
logger.warning("Cancelled. Is not directory: %s.", str(path_directory))
return False

processed: set[Path] = set()
for root, _, files in os.walk(str(path_directory)):
for name in files:
path = Path(root) / name
processed.add(path)
if not name.endswith(".torrent"):
continue
self.task_manager.replace_task(f"Process file {path!s}", self.process_torrent_file, path)

logger.debug("Checking watch folder completed.")
return True

async def process_torrent_file(self, path: Path) -> None:
"""
Process an individual torrent file.
"""
logger.debug("Add watched torrent file: %s", str(path))
try:
tdef = await TorrentDef.load(path)
if not self.session.download_manager.download_exists(tdef.infohash):
logger.info("Starting download from torrent file %s", path.name)
await self.session.download_manager.start_download(torrent_file=path, tdef=tdef)
except Exception as e: # pylint: disable=broad-except
logger.exception("Exception while adding watched torrent! %s: %s", e.__class__.__name__, str(e))
Empty file.
160 changes: 160 additions & 0 deletions src/tribler/test_unit/core/watch_folder/test_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import os.path
from asyncio import sleep
from pathlib import Path
from unittest.mock import AsyncMock, Mock, call, patch

from ipv8.taskmanager import TaskManager
from ipv8.test.base import TestBase

from tribler.core.libtorrent.torrentdef import TorrentDef
from tribler.core.watch_folder.manager import WatchFolderManager
from tribler.test_unit.core.libtorrent.mocks import TORRENT_WITH_DIRS_CONTENT
from tribler.tribler_config import TriblerConfigManager


class MockTriblerConfigManager(TriblerConfigManager):
"""
A memory-based TriblerConfigManager.
"""

def write(self) -> None:
"""
Don't actually write to any file.
"""


class TestWatchFolderManager(TestBase):
"""
Tests for the Notifier class.
"""

def setUp(self) -> None:
"""
Create a new versioning manager.
"""
super().setUp()
self.config = MockTriblerConfigManager()
self.task_manager = TaskManager()
self.manager = WatchFolderManager(Mock(config=self.config, download_manager=Mock(
start_download=AsyncMock(), remove_download=AsyncMock())), self.task_manager)

async def tearDown(self) -> None:
"""
Shut down our task manager.
"""
await self.task_manager.shutdown_task_manager()
await super().tearDown()

def test_watch_folder_not_dir(self) -> None:
"""
Test that the watch folder is disabled when the "directory" setting is not a directory.
"""
self.config.set("watch_folder/enabled", True)
self.config.set("watch_folder/directory", "")

result = self.manager.check()

self.assertFalse(result)

def test_watch_folder_invalid_dir(self) -> None:
"""
Test that the watch folder is disabled when the directory is invalid.
"""
self.config.set("watch_folder/enabled", True)
self.config.set("watch_folder/directory", "BFLKJAELKJRLAKJDLAGKjLjgaEPGJAPEJGPAIJEPGIAPDJG")

result = self.manager.check()

self.assertFalse(result)

async def test_watch_folder_no_files(self) -> None:
"""
Test that in the case of an empty folder, downloads are not started.
"""
self.config.set("watch_folder/enabled", True)
self.config.set("watch_folder/directory", os.path.dirname(__file__))

with patch("os.walk", lambda _: []):
result = self.manager.check()
await sleep(0)
scheduled_tasks = self.task_manager.get_tasks()

self.assertTrue(result)
self.assertEqual(0, len(scheduled_tasks))

async def test_watch_folder_no_torrent_file(self) -> None:
"""
Test that in the case of a folder without torrents, downloads are not started.
"""
self.config.set("watch_folder/enabled", True)
self.config.set("watch_folder/directory", os.path.dirname(__file__))

result = self.manager.check()
await sleep(0)
scheduled_tasks = self.task_manager.get_tasks()

self.assertTrue(result)
self.assertEqual(0, len(scheduled_tasks))

async def test_watch_folder_torrent_file_start_download(self) -> None:
"""
Test that in the case of presence of a torrent file, a download is started.
"""
self.config.set("watch_folder/enabled", True)
self.config.set("watch_folder/directory", os.path.dirname(__file__))
self.manager.session.download_manager.download_exists = lambda _: False
tdef = TorrentDef.load_from_memory(TORRENT_WITH_DIRS_CONTENT)

with patch("os.walk", lambda _: [(".", [], ["fake.torrent"])]), \
patch.object(TorrentDef, "load", AsyncMock(return_value=tdef)):
result = self.manager.check()
await sleep(0) # Schedule processing
scheduled_tasks = self.task_manager.get_tasks()
await sleep(0) # Process (i.e., start the download)

self.assertTrue(result)
self.assertEqual(1, len(scheduled_tasks))
self.assertEqual(call(torrent_file=Path("fake.torrent"), tdef=tdef),
self.manager.session.download_manager.start_download.call_args)

async def test_watch_folder_torrent_file_start_download_existing(self) -> None:
"""
Test that in the case of presence of a torrent file, a download is started twice.
"""
self.config.set("watch_folder/enabled", True)
self.config.set("watch_folder/directory", os.path.dirname(__file__))
self.manager.session.download_manager.download_exists = lambda _: True
tdef = TorrentDef.load_from_memory(TORRENT_WITH_DIRS_CONTENT)

with patch("os.walk", lambda _: [(".", [], ["fake.torrent"])]), \
patch.object(TorrentDef, "load", AsyncMock(return_value=tdef)):
result = self.manager.check()
await sleep(0) # Schedule processing
scheduled_tasks = self.task_manager.get_tasks()
await sleep(0) # Process (i.e., start the download)

self.assertTrue(result)
self.assertEqual(1, len(scheduled_tasks))
self.assertIsNone(self.manager.session.download_manager.start_download.call_args)

async def test_watch_folder_no_crash_exception(self) -> None:
"""
Test that errors raised during processing do not crash us.
"""
self.config.set("watch_folder/enabled", True)
self.config.set("watch_folder/directory", os.path.dirname(__file__))
self.manager.session.download_manager.start_download = AsyncMock(side_effect=RuntimeError)
self.manager.session.download_manager.download_exists = lambda _: False
tdef = TorrentDef.load_from_memory(TORRENT_WITH_DIRS_CONTENT)

with patch("os.walk", lambda _: [(".", [], ["fake.torrent"])]), \
patch.object(TorrentDef, "load", AsyncMock(return_value=tdef)):
result = self.manager.check()
await sleep(0) # Schedule processing
scheduled_tasks = self.task_manager.get_tasks()
await sleep(0) # Process (i.e., start the download)

self.assertTrue(result)
self.assertEqual(1, len(scheduled_tasks))
self.assertEqual(call(torrent_file=Path("fake.torrent"), tdef=tdef),
self.manager.session.download_manager.start_download.call_args)
11 changes: 11 additions & 0 deletions src/tribler/tribler_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ class TunnelCommunityConfig(TypedDict):
max_circuits: int


class WatchFolderConfig(TypedDict):
"""
Settings for the watch folder component.
"""

enabled: bool
directory: str
check_interval: float


class TriblerConfig(TypedDict):
"""
The main Tribler settings and all of its components' sub-settings.
Expand Down Expand Up @@ -220,6 +230,7 @@ class TriblerConfig(TypedDict):
"torrent_checker": TorrentCheckerConfig(enabled=True),
"tunnel_community": TunnelCommunityConfig(enabled=True, min_circuits=3, max_circuits=8),
"versioning": VersioningConfig(enabled=True),
"watch_folder": WatchFolderConfig(enabled=False, directory="", check_interval=10.0),

"state_dir": str((Path(os.environ.get("APPDATA", "~")) / ".Tribler").expanduser().absolute()),
"memory_db": False
Expand Down
4 changes: 1 addition & 3 deletions src/tribler/ui/public/locales/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@
"SeedAnon": "Encrypted anonymous seeding using proxies",
"TorrentWatchFolder": "Torrent watch folder",
"WatchFolder": "Watch Folder",
"FamilyFilter": "Family filter",
"EnableFamilyFilter": "Family filter enabled",
"HideTags": "Hide tags from content items",
"EnableWatchFolder": "Enable watch folder (requires restart)",
"Save": "Save",
"Tags": "Tags",
"ProxySettings": "Torrent proxy settings",
Expand Down
4 changes: 1 addition & 3 deletions src/tribler/ui/public/locales/es_ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@
"SeedAnon": "Siembra anónima cifrada mediante proxies",
"TorrentWatchFolder": "Carpeta de seguimiento de torrents",
"WatchFolder": "Carpeta de seguimiento",
"FamilyFilter": "Filtro parental",
"EnableFamilyFilter": "¿Quiere activar el flitro parental?",
"HideTags": "Ocultar etiquetas de los contenidos",
"EnableWatchFolder": "Habilitar carpeta de vigilancia (es necesario reiniciar)",
"Save": "Guardar",
"Tags": "Etiquetas",
"ProxySettings": "Configuración del proxy torrent",
Expand Down
4 changes: 1 addition & 3 deletions src/tribler/ui/public/locales/ko_KR.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@
"SeedAnon": "프록시를 사용하여 암호화된 익명 시딩",
"TorrentWatchFolder": "토렌트 폴더 보기",
"WatchFolder": "폴더 보기",
"FamilyFilter": "가족 필터",
"EnableFamilyFilter": "가족 필터 사용",
"HideTags": "콘텐츠 항목의 태그 숨기기",
"EnableWatchFolder": "감시 폴더 활성화(다시 시작해야 함)",
"Save": "저장",
"Tags": "태그",
"ProxySettings": "토렌트 프록시 설정",
Expand Down
3 changes: 1 addition & 2 deletions src/tribler/ui/public/locales/pt_BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@
"SeedAnon": "Semear anonimamente usando proxies",
"TorrentWatchFolder": "Observar pasta torrent",
"WatchFolder": "Monitoramento de pasta",
"FamilyFilter": "Filtro Familiar",
"EnableFamilyFilter": "Filtro de família habilitado",
"EnableWatchFolder": "Ativar pasta monitorada (requer reinicialização)",
"Save": "Salvar",
"ProxySettings": "Configurações de proxy torrent",
"Type": "Tipo",
Expand Down
3 changes: 1 addition & 2 deletions src/tribler/ui/public/locales/ru_RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@
"SeedAnon": "Раздавать торренты анонимно (через других участников)",
"TorrentWatchFolder": "Папка автоскачивания:",
"WatchFolder": "Автоскачивание торрентов из папки",
"FamilyFilter": "Семейный фильтр",
"EnableFamilyFilter": "Включить семейный фильтр",
"EnableWatchFolder": "Включить папку просмотра (требуется перезагрузка)",
"HideTags": "Скрыть тэги контента",
"Save": "Сохранить",
"Tags": "Тэги",
Expand Down
Loading

0 comments on commit 1b48255

Please sign in to comment.