From 4e25843a2252a33c0fb286b3b8bd4f2969a64757 Mon Sep 17 00:00:00 2001 From: Quinten Stokkink Date: Mon, 27 Nov 2023 14:10:03 +0100 Subject: [PATCH] Added torrent file trees to the download endpoint (#7705) --- .../libtorrent/download_manager/download.py | 44 +- .../libtorrent/restapi/downloads_endpoint.py | 205 +++++- .../restapi/tests/test_downloads_endpoint.py | 149 ++++ .../libtorrent/tests/test_download.py | 108 ++- .../tests/test_torrent_file_tree.py | 13 + .../libtorrent/torrent_file_tree.py | 15 +- src/tribler/gui/qt_resources/mainwindow.ui | 64 +- src/tribler/gui/tests/test_gui.py | 40 +- .../gui/widgets/downloadsdetailstabwidget.py | 25 +- src/tribler/gui/widgets/downloadspage.py | 24 +- .../gui/widgets/torrentfiletreewidget.py | 640 +++++++++++++++++- 11 files changed, 1191 insertions(+), 136 deletions(-) diff --git a/src/tribler/core/components/libtorrent/download_manager/download.py b/src/tribler/core/components/libtorrent/download_manager/download.py index 2ef28a4c74..40b50975a0 100644 --- a/src/tribler/core/components/libtorrent/download_manager/download.py +++ b/src/tribler/core/components/libtorrent/download_manager/download.py @@ -479,16 +479,21 @@ def set_selected_files(self, selected_files=None, prio: int = 4, force: bool = F else: self.config.set_selected_files(selected_files) - torrent_info = get_info_from_handle(self.handle) - if not torrent_info or not hasattr(torrent_info, 'files'): - self._logger.error("File info not available for torrent %s", hexlify(self.tdef.get_infohash())) - return + tree = self.tdef.torrent_file_tree + total_files = self.tdef.torrent_info.num_files() - filepriorities = [] - torrent_storage = torrent_info.files() - for index, file_entry in enumerate(torrent_storage): - filepriorities.append(prio if index in selected_files or not selected_files else 0) - self.set_file_priorities(filepriorities) + if not selected_files: + selected_files = range(total_files) + + def map_selected(index: int) -> int: + file_instance = tree.find(Path(tree.file_storage.file_path(index))) + if index in selected_files: + file_instance.selected = True + return prio + file_instance.selected = False + return 0 + + self.set_file_priorities(list(map(map_selected, range(total_files)))) @check_handle(False) def move_storage(self, new_dir: Path): @@ -799,6 +804,9 @@ def get_piece_priorities(self): def set_file_priorities(self, file_priorities): self.handle.prioritize_files(file_priorities) + def set_file_priority(self, file_index: int, prio: int = 4) -> None: + self.handle.file_priority(file_index, prio) + @check_handle(None) def reset_piece_deadline(self, piece): self.handle.reset_piece_deadline(piece) @@ -829,6 +837,9 @@ def file_piece_range(self, file_path: Path) -> list[int]: # There is no next file so the nex piece is the last piece index + 1 (num_pieces()). next_piece = self.tdef.torrent_info.num_pieces() + if start_piece == next_piece: + # A single piece with multiple files. + return [start_piece] return list(range(start_piece, next_piece)) @check_handle(0.0) @@ -872,6 +883,21 @@ def get_file_index(self, path: Path) -> int: else IllegalFileIndex.expanded_dir.value) return IllegalFileIndex.unloaded.value + @check_handle(None) + def set_selected_file_or_dir(self, path: Path, selected: bool) -> None: + """ + Set a single file or directory to be selected or not. + """ + tree = self.tdef.torrent_file_tree + prio = 4 if selected else 0 + for index in tree.set_selected(Path(path), selected): + self.set_file_priority(index, prio) + if not selected: + with suppress(ValueError): + self.config.get_selected_files().remove(index) + else: + self.config.get_selected_files().append(index) + def is_file_selected(self, file_path: Path) -> bool: """ Check if the given file path is selected. diff --git a/src/tribler/core/components/libtorrent/restapi/downloads_endpoint.py b/src/tribler/core/components/libtorrent/restapi/downloads_endpoint.py index b539e737f2..b43ccb0607 100644 --- a/src/tribler/core/components/libtorrent/restapi/downloads_endpoint.py +++ b/src/tribler/core/components/libtorrent/restapi/downloads_endpoint.py @@ -9,6 +9,7 @@ from ipv8.messaging.anonymization.tunnel import CIRCUIT_ID_PORT, PEER_FLAG_EXIT_BT from marshmallow.fields import Boolean, Float, Integer, List, String +from tribler.core.components.libtorrent.download_manager.download import Download, IllegalFileIndex from tribler.core.components.libtorrent.download_manager.download_config import DownloadConfig from tribler.core.components.libtorrent.download_manager.download_manager import DownloadManager from tribler.core.components.libtorrent.download_manager.stream import STREAM_PAUSE_TIME, StreamChunk @@ -95,6 +96,10 @@ def setup_routes(self): web.patch('/{infohash}', self.update_download), web.get('/{infohash}/torrent', self.get_torrent), 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), + web.get('/{infohash}/files/select', self.select_tree_path), + web.get('/{infohash}/files/deselect', self.deselect_tree_path), web.get('/{infohash}/stream/{fileindex}', self.stream, allow_head=False)]) @staticmethod @@ -155,6 +160,37 @@ def get_files_info_json(download): file_index += 1 return files_json + @staticmethod + def get_files_info_json_paged(download: Download, view_start: Path, view_size: int): + """ + Return file info, similar to get_files_info_json() but paged (based on view_start and view_size). + + Note that the view_start path is not included in the return value. + + :param view_start: The last-known path from which to fetch new paths. + :param view_size: The requested number of elements (though only less may be available). + """ + if not download.tdef.torrent_info_loaded(): + download.tdef.load_torrent_info() + return [{ + "index": IllegalFileIndex.unloaded.value, + "name": "loading...", + "size": 0, + "included": 0, + "progress": 0.0 + }] + return [ + { + "index": download.get_file_index(path), + "name": str(PurePosixPath(path_str)), + "size": download.get_file_length(path), + "included": download.is_file_selected(path), + "progress": download.get_file_completion(path) + } + for path_str in download.tdef.torrent_file_tree.view(view_start, view_size) + if (path := Path(path_str)) + ] + @docs( tags=["Libtorrent"], summary="Return all downloads, both active and inactive", @@ -582,7 +618,21 @@ async def get_torrent(self, request): 'description': 'Infohash of the download to from which to get file information', 'type': 'string', 'required': True - }], + }, + { + 'in': 'query', + 'name': 'view_start_path', + 'description': 'Path of the file or directory to form a view for', + 'type': 'string', + 'required': False + }, + { + 'in': 'query', + 'name': 'view_size', + 'description': 'Number of files to include in the view', + 'type': 'number', + 'required': False + }], responses={ 200: { "schema": schema(GetFilesResponse={"files": [schema(File={'index': Integer, @@ -598,7 +648,158 @@ async def get_files(self, request): download = self.download_manager.get_download(infohash) if not download: return DownloadsEndpoint.return_404(request) - return RESTResponse({"files": self.get_files_info_json(download)}) + + params = request.query + view_start_path = params.get('view_start_path') + if view_start_path is None: + return RESTResponse({ + "infohash": request.match_info['infohash'], + "files": self.get_files_info_json(download) + }) + + view_size = int(params.get('view_size', '100')) + return RESTResponse({ + "infohash": request.match_info['infohash'], + "query": view_start_path, + "files": self.get_files_info_json_paged(download, Path(view_start_path), view_size) + }) + + @docs( + tags=["Libtorrent"], + summary="Collapse a tree directory.", + parameters=[{ + 'in': 'path', + 'name': 'infohash', + 'description': 'Infohash of the download', + 'type': 'string', + 'required': True + }, + { + 'in': 'query', + 'name': 'path', + 'description': 'Path of the directory to collapse', + 'type': 'string', + 'required': True + }], + responses={ + 200: { + "schema": schema(File={'path': path}) + } + } + ) + async def collapse_tree_directory(self, request): + infohash = unhexlify(request.match_info['infohash']) + download = self.download_manager.get_download(infohash) + if not download: + return DownloadsEndpoint.return_404(request) + + params = request.query + path = params.get('path') + download.tdef.torrent_file_tree.collapse(Path(path)) + + return RESTResponse({'path': path}) + + + @docs( + tags=["Libtorrent"], + summary="Expand a tree directory.", + parameters=[{ + 'in': 'path', + 'name': 'infohash', + 'description': 'Infohash of the download', + 'type': 'string', + 'required': True + }, + { + 'in': 'query', + 'name': 'path', + 'description': 'Path of the directory to expand', + 'type': 'string', + 'required': True + }], + responses={ + 200: { + "schema": schema(File={'path': String}) + } + } + ) + async def expand_tree_directory(self, request): + infohash = unhexlify(request.match_info['infohash']) + download = self.download_manager.get_download(infohash) + if not download: + return DownloadsEndpoint.return_404(request) + + params = request.query + path = params.get('path') + download.tdef.torrent_file_tree.expand(Path(path)) + + return RESTResponse({'path': path}) + + @docs( + tags=["Libtorrent"], + summary="Select a tree path.", + parameters=[{ + 'in': 'path', + 'name': 'infohash', + 'description': 'Infohash of the download', + 'type': 'string', + 'required': True + }, + { + 'in': 'query', + 'name': 'path', + 'description': 'Path of the directory to select', + 'type': 'string', + 'required': True + }], + responses={ + 200: {} + } + ) + async def select_tree_path(self, request): + infohash = unhexlify(request.match_info['infohash']) + download = self.download_manager.get_download(infohash) + if not download: + return DownloadsEndpoint.return_404(request) + + params = request.query + path = params.get('path') + download.set_selected_file_or_dir(Path(path), True) + + return RESTResponse({}) + + @docs( + tags=["Libtorrent"], + summary="Deselect a tree path.", + parameters=[{ + 'in': 'path', + 'name': 'infohash', + 'description': 'Infohash of the download', + 'type': 'string', + 'required': True + }, + { + 'in': 'query', + 'name': 'path', + 'description': 'Path of the directory to deselect', + 'type': 'string', + 'required': True + }], + responses={ + 200: {} + } + ) + async def deselect_tree_path(self, request): + infohash = unhexlify(request.match_info['infohash']) + download = self.download_manager.get_download(infohash) + if not download: + return DownloadsEndpoint.return_404(request) + + params = request.query + path = params.get('path') + download.set_selected_file_or_dir(Path(path), False) + + return RESTResponse({}) @docs( tags=["Libtorrent"], diff --git a/src/tribler/core/components/libtorrent/restapi/tests/test_downloads_endpoint.py b/src/tribler/core/components/libtorrent/restapi/tests/test_downloads_endpoint.py index d006267d62..3d1cafc64b 100644 --- a/src/tribler/core/components/libtorrent/restapi/tests/test_downloads_endpoint.py +++ b/src/tribler/core/components/libtorrent/restapi/tests/test_downloads_endpoint.py @@ -1,6 +1,7 @@ import collections import os import unittest.mock +from pathlib import Path from unittest.mock import Mock import pytest @@ -8,8 +9,10 @@ from ipv8.util import fail, succeed import tribler.core.components.libtorrent.restapi.downloads_endpoint as download_endpoint +from tribler.core.components.libtorrent.download_manager.download import IllegalFileIndex from tribler.core.components.libtorrent.download_manager.download_state import DownloadState from tribler.core.components.libtorrent.restapi.downloads_endpoint import DownloadsEndpoint, get_extended_status +from tribler.core.components.libtorrent.torrent_file_tree import TorrentFileTree from tribler.core.components.restapi.rest.base_api_test import do_request from tribler.core.tests.tools.common import TESTS_DATA_DIR from tribler.core.utilities.rest_utils import HTTP_SCHEME, path_to_url @@ -32,6 +35,18 @@ def get_hex_infohash(tdef): defaults=[0, 0, True]) +@pytest.fixture(name="_patch_handle") +def fixture_patch_handle(mock_handle): + """ + The mock_handle fixture has side effects. Tests that use it only for its side effects will trigger W0613 (``Unused + argument 'mock_handle'``). By providing a fixture name starting with an underscore, we tell Pylint to ignore + the fact that this fixture goes unused in the unit test. + + :param mock_handle: The download handle mock. + """ + return mock_handle + + @pytest.fixture(name="mock_extended_status", scope="function") def fixture_extended_status(request, mock_lt_status) -> int: """ @@ -538,6 +553,42 @@ async def test_get_files_unknown_download(mock_dlmgr, rest_api): await do_request(rest_api, 'downloads/abcd/files', expected_code=404, request_type='GET') +async def test_get_files_from_view_start_loading(mock_dlmgr, test_download, rest_api): + """ + Testing whether the API returns the special loading state from a given start path. + """ + mock_dlmgr.get_download = lambda _: test_download + expected_file = {'index': IllegalFileIndex.unloaded.value, 'name': 'loading...', 'size': 0, 'included': False, + 'progress': 0.0} + + result = await do_request(rest_api, f'downloads/{test_download.infohash}/files', + params={"view_start_path": "."}) + + assert 'infohash' in result + assert result['infohash'] == test_download.infohash + assert 'files' in result + assert len(result['files']) == 1 + assert expected_file == result['files'][0] + + +async def test_get_files_from_view_start(mock_dlmgr, test_download, rest_api): + """ + Testing whether the API returns files from a given start path. + """ + mock_dlmgr.get_download = lambda _: test_download + test_download.tdef.load_torrent_info() + expected_file = {'index': 0, 'name': 'video.avi', 'size': 1942100, 'included': True, 'progress': 0.0} + + result = await do_request(rest_api, f'downloads/{test_download.infohash}/files', + params={"view_start_path": "."}) + + assert 'infohash' in result + assert result['infohash'] == test_download.infohash + assert 'files' in result + assert len(result['files']) == 1 + assert expected_file == result['files'][0] + + async def test_get_download_files(mock_dlmgr, test_download, rest_api): """ Testing whether the API returns file information of a specific download when requested @@ -623,3 +674,101 @@ async def test_change_hops_fail(mock_dlmgr, test_download, rest_api): await do_request(rest_api, f'downloads/{test_download.infohash}', post_data={'anon_hops': 1}, expected_code=500, request_type='PATCH', expected_json={'error': {'message': '', 'code': 'RuntimeError', 'handled': True}}) + + +async def test_expand(mock_dlmgr, _patch_handle, test_download, rest_api): + """ + Testing if a call to expand is correctly propagated to the underlying torrent file tree. + """ + tree = TorrentFileTree(None) + tree.root.directories = {"testdir": TorrentFileTree.Directory(collapsed=True)} + + test_download.tdef.torrent_file_tree = tree + mock_dlmgr.get_download = lambda _: test_download + + await do_request(rest_api, f'downloads/{test_download.infohash}/files/expand', params={"path": "testdir"}, + expected_code=200) + assert not tree.find(Path("testdir")).collapsed + + +async def test_expand_unknown_download(mock_dlmgr, rest_api): + """ + Testing for 404 when expanding a valid path for a non-existent download. + """ + mock_dlmgr.get_download = lambda _: None + + await do_request(rest_api, f'downloads/{"00" * 20}/files/expand', params={"path": "."}, expected_code=404) + + +async def test_collapse(mock_dlmgr, _patch_handle, test_download, rest_api): + """ + Testing if a call to collapse is correctly propagated to the underlying torrent file tree. + """ + tree = TorrentFileTree(None) + tree.root.directories = {"testdir": TorrentFileTree.Directory(collapsed=False)} + + test_download.tdef.torrent_file_tree = tree + mock_dlmgr.get_download = lambda _: test_download + + await do_request(rest_api, f'downloads/{test_download.infohash}/files/collapse', params={"path": "testdir"}, + expected_code=200) + assert tree.find(Path("testdir")).collapsed + + +async def test_collapse_unknown_download(mock_dlmgr, rest_api): + """ + Testing for 404 when collapsing a valid path for a non-existent download. + """ + mock_dlmgr.get_download = lambda _: None + + await do_request(rest_api, f'downloads/{"00" * 20}/files/collapse', params={"path": "."}, expected_code=404) + + +async def test_select(mock_dlmgr, _patch_handle, test_download, rest_api): + """ + Testing if a call to select is correctly propagated to the underlying torrent file tree. + """ + test_file = TorrentFileTree.File("somefile.trib", 0, 1, selected=False) + tree = TorrentFileTree(None) + tree.root.directories = {"testdir": TorrentFileTree.Directory(files=[test_file], collapsed=False)} + + test_download.tdef.torrent_file_tree = tree + mock_dlmgr.get_download = lambda _: test_download + + await do_request(rest_api, f'downloads/{test_download.infohash}/files/select', + params={"path": "testdir/somefile.trib"}, expected_code=200) + assert test_file.selected + + +async def test_select_unknown_download(mock_dlmgr, rest_api): + """ + Testing for 404 when selecting a valid path for a non-existent download. + """ + mock_dlmgr.get_download = lambda _: None + + await do_request(rest_api, f'downloads/{"00" * 20}/files/select', params={"path": "."}, expected_code=404) + + +async def test_deselect(mock_dlmgr, _patch_handle, test_download, rest_api): + """ + Testing if a call to deselect is correctly propagated to the underlying torrent file tree. + """ + test_file = TorrentFileTree.File("somefile.trib", 0, 1, selected=True) + tree = TorrentFileTree(None) + tree.root.directories = {"testdir": TorrentFileTree.Directory(files=[test_file], collapsed=False)} + + test_download.tdef.torrent_file_tree = tree + mock_dlmgr.get_download = lambda _: test_download + + await do_request(rest_api, f'downloads/{test_download.infohash}/files/deselect', + params={"path": "testdir/somefile.trib"}, expected_code=200) + assert not test_file.selected + + +async def test_deselect_unknown_download(mock_dlmgr, rest_api): + """ + Testing for 404 when deselecting a valid path for a non-existent download. + """ + mock_dlmgr.get_download = lambda _: None + + await do_request(rest_api, f'downloads/{"00" * 20}/files/deselect', params={"path": "."}, expected_code=404) diff --git a/src/tribler/core/components/libtorrent/tests/test_download.py b/src/tribler/core/components/libtorrent/tests/test_download.py index 515203678e..691e5e357a 100644 --- a/src/tribler/core/components/libtorrent/tests/test_download.py +++ b/src/tribler/core/components/libtorrent/tests/test_download.py @@ -1,5 +1,6 @@ from asyncio import Future, sleep from pathlib import Path +from typing import Generator from unittest.mock import MagicMock, Mock, patch import libtorrent as lt @@ -20,6 +21,27 @@ from tribler.core.utilities.utilities import bdecode_compat +@pytest.fixture(name="minifile_download") +async def fixture_minifile_download(mock_dlmgr) -> Generator[Download, None, None]: + """ + A download with multiple files that fit into a single piece. + """ + tdef = TorrentDef({ + b'info': { + b'name': 'data', + b'files': [{b'path': [b'a.txt'], b'length': 1}, {b'path': [b'b.txt'], b'length': 1}], + b'piece length': 128, # Note: both a.txt (length 1) and b.txt (length 1) fit in one piece + b'pieces': b'\x00' * 20 + } + }) + config = DownloadConfig(state_dir=mock_dlmgr.state_dir) + download = Download(tdef, download_manager=mock_dlmgr, config=config) + download.infohash = hexlify(tdef.get_infohash()) + yield download + + await download.shutdown() + + def test_download_properties(test_download, test_tdef): assert not test_download.get_magnet_link() assert test_download.tdef, test_tdef @@ -89,32 +111,68 @@ async def test_save_checkpoint(test_download, test_tdef): assert filename.is_file() -def test_selected_files(mock_handle, test_download): +def test_selected_files_default(minifile_download: Download): """ - Test whether the selected files are set correctly + Test whether the default selected files are no files. """ + minifile_download.handle = Mock(file_priorities=Mock(return_value=[0, 0])) + assert [] == minifile_download.config.get_selected_files() + assert [0, 0] == minifile_download.get_file_priorities() - def mocked_set_file_prios(_): - mocked_set_file_prios.called = True - mocked_set_file_prios.called = False +def test_selected_files_last(minifile_download: Download): + """ + Test whether the last selected file in a list of files gets correctly selected. + """ + minifile_download.handle = Mock(file_priorities=Mock(return_value=[0, 4])) + minifile_download.set_selected_files([1]) + minifile_download.handle.prioritize_files.assert_called_with([0, 4]) + assert [1] == minifile_download.config.get_selected_files() + assert [0, 4] == minifile_download.get_file_priorities() - mocked_file = MockObject() - mocked_file.path = 'my/path' - mock_torrent_info = MockObject() - mock_torrent_info.files = lambda: [mocked_file, mocked_file] - test_download.handle.prioritize_files = mocked_set_file_prios - test_download.handle.get_torrent_info = lambda: mock_torrent_info - test_download.handle.rename_file = lambda *_: None - test_download.get_share_mode = lambda: False - test_download.tdef.get_infohash = lambda: b'a' * 20 - test_download.set_selected_files([0]) - assert mocked_set_file_prios.called +def test_selected_files_first(minifile_download: Download): + """ + Test whether the first selected file in a list of files gets correctly selected. + """ + minifile_download.handle = Mock(file_priorities=Mock(return_value=[4, 0])) + minifile_download.set_selected_files([0]) + minifile_download.handle.prioritize_files.assert_called_with([4, 0]) + assert [0] == minifile_download.config.get_selected_files() + assert [4, 0] == minifile_download.get_file_priorities() - test_download.get_share_mode = lambda: False - mocked_set_file_prios.called = False - assert not mocked_set_file_prios.called + +def test_selected_files_all(minifile_download: Download): + """ + Test whether all files can be selected. + """ + minifile_download.handle = Mock(file_priorities=Mock(return_value=[4, 4])) + minifile_download.set_selected_files([0, 1]) + minifile_download.handle.prioritize_files.assert_called_with([4, 4]) + assert [0, 1] == minifile_download.config.get_selected_files() + assert [4, 4] == minifile_download.get_file_priorities() + + +def test_selected_files_all_through_none(minifile_download: Download): + """ + Test whether all files can be selected by selecting None. + """ + minifile_download.handle = Mock(file_priorities=Mock(return_value=[4, 4])) + minifile_download.set_selected_files() + minifile_download.handle.prioritize_files.assert_called_with([4, 4]) + assert [] == minifile_download.config.get_selected_files() + assert [4, 4] == minifile_download.get_file_priorities() + + +def test_selected_files_all_through_empty_list(minifile_download: Download): + """ + Test whether all files can be selected by selecting an empty list + """ + minifile_download.handle = Mock(file_priorities=Mock(return_value=[4, 4])) + minifile_download.set_selected_files([]) + minifile_download.handle.prioritize_files.assert_called_with([4, 4]) + assert [] == minifile_download.config.get_selected_files() + assert [4, 4] == minifile_download.get_file_priorities() def test_selected_files_no_files(mock_handle, test_download): @@ -132,6 +190,7 @@ def mocked_set_file_prios(_): test_download.handle.prioritize_files = mocked_set_file_prios test_download.handle.torrent_file = lambda: None test_download.handle.rename_file = lambda *_: None + test_download.handle.is_valid = lambda: False test_download.tdef.get_infohash = lambda: b'a' * 20 # If share mode is not enabled and everything else is fine, file priority should be set @@ -519,6 +578,17 @@ def test_file_piece_range_flat(test_download: Download) -> None: assert piece_range == list(range(total_pieces)) +def test_file_piece_range_minifiles(minifile_download: Download) -> None: + """ + Test if the piece range of a file is correctly determined if multiple files exist in the same piece. + """ + piece_range_a = minifile_download.file_piece_range(Path("data") / "a.txt") + piece_range_b = minifile_download.file_piece_range(Path("data") / "b.txt") + + assert [0] == piece_range_a + assert [0] == piece_range_b + + def test_file_piece_range_wide(dual_movie_tdef: TorrentDef) -> None: """ Test if the piece range of a two-file torrent is correctly determined. diff --git a/src/tribler/core/components/libtorrent/tests/test_torrent_file_tree.py b/src/tribler/core/components/libtorrent/tests/test_torrent_file_tree.py index f7b7a77237..f1ddc0ed8f 100644 --- a/src/tribler/core/components/libtorrent/tests/test_torrent_file_tree.py +++ b/src/tribler/core/components/libtorrent/tests/test_torrent_file_tree.py @@ -467,6 +467,19 @@ def test_view_full_collapsed(file_storage_with_dirs): ] +def test_view_start_at_collapsed(file_storage_with_dirs): + """ + Test if we can form a view starting at a collapsed directory. + """ + tree = TorrentFileTree.from_lt_file_storage(file_storage_with_dirs) + + tree.expand(Path("torrent_create")) + + result = tree.view(Path("torrent_create") / "abc", 2) + + assert [Path(r) for r in result] == [Path("torrent_create") / "def", Path("torrent_create") / "file1.txt"] + + def test_select_start_selected(file_storage_with_dirs): """ Test if all files start selected. diff --git a/src/tribler/core/components/libtorrent/torrent_file_tree.py b/src/tribler/core/components/libtorrent/torrent_file_tree.py index efefa7fdee..de72c60838 100644 --- a/src/tribler/core/components/libtorrent/torrent_file_tree.py +++ b/src/tribler/core/components/libtorrent/torrent_file_tree.py @@ -193,20 +193,25 @@ def collapse(self, path: Path) -> None: if isinstance(element, TorrentFileTree.Directory) and element != self.root: element.collapsed = True - def set_selected(self, path: Path, selected: bool) -> None: + def set_selected(self, path: Path, selected: bool) -> list[int]: """ Set the selected status for a File or entire Directory. + + :returns: the list of modified file indices. """ item = self.find(path) if item is None: - return + return [] if isinstance(item, TorrentFileTree.File): item.selected = selected # pylint: disable=W0201 - return + return [item.index] + out = [] for key in item.directories: - self.set_selected(path / key, selected) + out += self.set_selected(path / key, selected) for file in item.files: file.selected = selected + out.append(file.index) + return out def find(self, path: Path) -> Directory | File | None: """ @@ -341,7 +346,7 @@ def view(self, start_path: tuple[Directory, Path] | Path, number: int) -> list[s # This is a collapsed directory, it has no elements. if fetch_directory.collapsed: - return [] + return self._view_up_after_files(number, fetch_path) view = [] if self.path_is_dir(element_path): diff --git a/src/tribler/gui/qt_resources/mainwindow.ui b/src/tribler/gui/qt_resources/mainwindow.ui index d6e00208b1..32028b7e0b 100644 --- a/src/tribler/gui/qt_resources/mainwindow.ui +++ b/src/tribler/gui/qt_resources/mainwindow.ui @@ -3719,65 +3719,6 @@ margin-right: 10px; 0 - - - - Qt::CustomContextMenu - - - QHeaderView { -background-color: transparent; -} -QHeaderView::section { -background-color: transparent; -border: none; -color: #B5B5B5; -padding: 10px; -font-size: 14px; -border-bottom: 1px solid #303030; -} -QHeaderView::drop-down { -color: red; -} -QHeaderView::section:hover { -color: white; -} - - - - - - QAbstractItemView::NoSelection - - - true - - - true - - - false - - - false - - - - PATH - - - - - SIZE - - - - - % DONE - - - - @@ -5555,6 +5496,11 @@ background: none; QTreeWidget
tribler.gui.widgets.torrentfiletreewidget.h
+ + PreformattedTorrentFileTreeWidget + QTableWidget +
tribler.gui.widgets.torrentfiletreewidget.h
+
EllipseButton QToolButton diff --git a/src/tribler/gui/tests/test_gui.py b/src/tribler/gui/tests/test_gui.py index 8543cb58f0..683e2acf26 100644 --- a/src/tribler/gui/tests/test_gui.py +++ b/src/tribler/gui/tests/test_gui.py @@ -28,7 +28,7 @@ from tribler.gui.widgets.loading_list_item import LoadingListItem from tribler.gui.widgets.tablecontentmodel import Column from tribler.gui.widgets.tagbutton import TagButton -from tribler.gui.widgets.torrentfiletreewidget import CHECKBOX_COL +from tribler.gui.widgets.torrentfiletreewidget import CHECKBOX_COL, PreformattedTorrentFileTreeWidget DEFAULT_TIMEOUT_SEC = 20 WAIT_INTERVAL_MSEC = 100 # 0.1 sec @@ -208,6 +208,22 @@ def wait_for_list_populated(llist, num_items=1, timeout=DEFAULT_TIMEOUT_SEC): raise TimeoutException(f"The list was not populated within {timeout} seconds") +def wait_for_filedetails_populated(details_list: PreformattedTorrentFileTreeWidget, timeout=DEFAULT_TIMEOUT_SEC): + """ Wait for a list to be populated. + + :param details_list: The file tree widget + :param timeout: The timeout in seconds + """ + # Wait for the list to be populated in intervals of `DEFAULT_WAIT_INTERVAL_MSEC` + for _ in range(0, timeout * 1000, WAIT_INTERVAL_MSEC): + QTest.qWait(WAIT_INTERVAL_MSEC) + if details_list.pages[0].loaded: + return + + # List was not populated in time, fail the test + raise TimeoutException(f"The list was not populated within {timeout} seconds") + + def wait_for_settings(window, timeout=DEFAULT_TIMEOUT_SEC): """ Wait for the settings to be populated. @@ -407,22 +423,16 @@ def test_download_details(window): screenshot(window, name="download_detail") window.download_details_widget.setCurrentIndex(1) - dfl = window.download_files_list - wait_for_list_populated(dfl) - item = dfl.topLevelItem(0) - dfl.expand(dfl.indexFromItem(item)) - QTest.qWait(WAIT_INTERVAL_MSEC) + dfl: PreformattedTorrentFileTreeWidget = window.download_files_list + wait_for_list_populated(dfl) # Loading spinner + wait_for_filedetails_populated(dfl) # First torrent content + dfl.selectRow(0) + QTest.qWait(500) + dfl.item_clicked(dfl.item(0, 1)) + while dfl.pages[0].num_files() == 1: + QTest.qWait(WAIT_INTERVAL_MSEC) screenshot(window, name="download_files") - dfl.header().setSortIndicator(0, Qt.AscendingOrder) - QTest.qWait(WAIT_INTERVAL_MSEC) - dfl.header().setSortIndicator(1, Qt.AscendingOrder) - QTest.qWait(WAIT_INTERVAL_MSEC) - dfl.header().setSortIndicator(2, Qt.AscendingOrder) - QTest.qWait(WAIT_INTERVAL_MSEC) - dfl.header().setSortIndicator(3, Qt.AscendingOrder) - QTest.qWait(WAIT_INTERVAL_MSEC) - window.download_details_widget.setCurrentIndex(2) screenshot(window, name="download_trackers") diff --git a/src/tribler/gui/widgets/downloadsdetailstabwidget.py b/src/tribler/gui/widgets/downloadsdetailstabwidget.py index 41f60003dc..a2c92fbafc 100644 --- a/src/tribler/gui/widgets/downloadsdetailstabwidget.py +++ b/src/tribler/gui/widgets/downloadsdetailstabwidget.py @@ -9,6 +9,7 @@ from tribler.gui.defs import STATUS_STRING from tribler.gui.network.request_manager import request_manager from tribler.gui.utilities import compose_magnetlink, connect, copy_to_clipboard, format_size, format_speed, tr +from tribler.gui.widgets.torrentfiletreewidget import PreformattedTorrentFileTreeWidget INCLUDED_FILES_CHANGE_DELAY = 1000 # milliseconds @@ -70,15 +71,15 @@ def _restart_changes_timer(self): self._batch_changes_timer.start(INCLUDED_FILES_CHANGE_DELAY) def initialize_details_widget(self): - self.window().download_files_list.header().resizeSection(0, 220) + dl_files_list = PreformattedTorrentFileTreeWidget(self.window().download_files_tab) + self.window().download_files_tab.layout().addWidget(dl_files_list) + setattr(self.window(), "download_files_list", dl_files_list) self.setCurrentIndex(0) # make name, infohash and download destination selectable to copy self.window().download_detail_infohash_label.setTextInteractionFlags(Qt.TextSelectableByMouse) self.window().download_detail_name_label.setTextInteractionFlags(Qt.TextSelectableByMouse) self.window().download_detail_destination_label.setTextInteractionFlags(Qt.TextSelectableByMouse) connect(self.window().download_detail_copy_magnet_button.clicked, self.on_copy_magnet_clicked) - connect(self._batch_changes_timer.timeout, self.set_included_files) - connect(self.window().download_files_list.selected_files_changed, self._restart_changes_timer) def update_with_download(self, download): # If the same infohash gets re-added with different parameters (e.g. different selected files), @@ -93,7 +94,6 @@ def update_with_download(self, download): # Also, we have to stop the change batching time to prevent carrying the event to the new download if did_change and self._batch_changes_timer.isActive(): self._batch_changes_timer.stop() - self.set_included_files() self.current_download = download self.update_pages(new_download=did_change) @@ -178,16 +178,9 @@ def update_pages(self, new_download=False): ) self.window().download_detail_availability_label.setText(f"{self.current_download['availability']:.2f}") - if force_update := (new_download or self.window().download_files_list.is_empty): - # (re)populate the files list + if new_download: self.window().download_files_list.clear() - files = convert_to_files_tree_format(self.current_download) - self.window().download_files_list.fill_entries(files) - self.window().download_files_list.update_progress( - self.current_download['files'], - force_update=force_update, - draw_progress_bars=len(self.current_download['files']) <= PROGRESS_BAR_DRAW_LIMIT, - ) + self.window().download_files_list.initialize(self.current_download['infohash']) # Populate the trackers list self.window().download_trackers_list.clear() @@ -202,12 +195,6 @@ def update_pages(self, new_download=False): item = QTreeWidgetItem(self.window().download_peers_list) DownloadsDetailsTabWidget.update_peer_row(item, peer) - def set_included_files(self): - if not self.current_download: - return - included_list = self.window().download_files_list.get_selected_files_indexes() - request_manager.patch(f"downloads/{self.current_download['infohash']}", data={"selected_files": included_list}) - def on_copy_magnet_clicked(self, checked): trackers = [ tk['url'] for tk in self.current_download['trackers'] if 'url' in tk and tk['url'] not in ['[DHT]', '[PeX]'] diff --git a/src/tribler/gui/widgets/downloadspage.py b/src/tribler/gui/widgets/downloadspage.py index 83d5ee3a81..9706a36f2d 100644 --- a/src/tribler/gui/widgets/downloadspage.py +++ b/src/tribler/gui/widgets/downloadspage.py @@ -1,5 +1,6 @@ import logging import os +from pathlib import Path from typing import List, Optional, Tuple from PyQt5.QtCore import QTimer, QUrl, Qt, pyqtSignal @@ -132,18 +133,31 @@ def stop_refreshing_downloads(self): self.background_refresh_downloads_timer.stop() def refresh_downloads(self): - index = self.window().download_details_widget.currentIndex() + details_widget = self.window().download_details_widget + index = details_widget.currentIndex() - details_shown = not self.window().download_details_widget.isHidden() - selected_download = self.window().download_details_widget.current_download + details_shown = not details_widget.isHidden() + selected_download = details_widget.current_download if details_shown and selected_download is not None: + infohash = selected_download.get('infohash', "") url_params = { 'get_pieces': 1, 'get_peers': int(index == DownloadDetailsTabs.PEERS), - 'get_files': int(index == DownloadDetailsTabs.FILES), - 'infohash': selected_download.get('infohash', "") + 'get_files': 0, + 'infohash': infohash } + # We only need to hard refresh the download_files_list once. + dl_files_list = details_widget.window().download_files_list + if index == DownloadDetailsTabs.FILES and not dl_files_list.pages[0].loaded: + request_manager.get( + endpoint=f"downloads/{infohash}/files", + url_params={ + 'view_start_path': Path("."), + 'view_size': dl_files_list.page_size + }, + on_success=dl_files_list.fill_entries, + ) else: url_params = { 'get_pieces': 0, diff --git a/src/tribler/gui/widgets/torrentfiletreewidget.py b/src/tribler/gui/widgets/torrentfiletreewidget.py index a43866d98f..3418806174 100644 --- a/src/tribler/gui/widgets/torrentfiletreewidget.py +++ b/src/tribler/gui/widgets/torrentfiletreewidget.py @@ -1,8 +1,19 @@ +from __future__ import annotations + +import re import sys +from bisect import bisect +from contextlib import suppress +from dataclasses import dataclass, field +from pathlib import Path +import PyQt5 from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtWidgets import QHeaderView, QTreeWidget, QTreeWidgetItem +from PyQt5.QtGui import QIcon, QMovie +from PyQt5.QtWidgets import QHeaderView, QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem, QWidget +from tribler.gui.defs import KB, MB, GB, TB, PB +from tribler.gui.network.request_manager import request_manager from tribler.gui.utilities import connect, format_size, get_image_path from tribler.gui.widgets.downloadwidgetitem import create_progress_bar_widget @@ -13,6 +24,8 @@ SIZE_COL = 1 PROGRESS_COL = 2 +NAT_SORT_PATTERN = re.compile('([0-9]+)') + """ !!! ACHTUNG !!!! The following series of QT and PyQT bugs forces us to put checkboxes styling here: @@ -233,7 +246,6 @@ def fill_entries(self, files): self.selected_files_size = sum( item.file_size for item in self.get_selected_items() if item.file_index is not None ) - self.selected_files_changed.emit() def update_progress(self, updates, force_update=False, draw_progress_bars=False): self.blockSignals(True) @@ -277,4 +289,626 @@ def update_selected_files_size(self, item, _): self.selected_files_size += item.file_size else: self.selected_files_size -= item.file_size - self.selected_files_changed.emit() + + +@dataclass +class FilesPage: + """ + A page of file/directory names (and their selected status) in the PreformattedTorrentFileTreeWidget. + """ + + query: Path + """ + The query that was used (loaded=True) OR can be used (loaded=False) to fetch this page. + """ + + index: int + """ + The index of this page in the "PreformattedTorrentFileTreeWidget.pages" list. + """ + + states: dict[Path, int] = field(default_factory=dict) + """ + All Paths belonging to this page and their Qt.CheckState (unselected, partially selected, or selected). + """ + + loaded: bool = False + """ + Whether this is a placeholder (when still unloaded or after memory has been freed) or fully loaded. + """ + + next_query: Path | None = None + """ + The Path to use for the next page, or None if there is no next page to be fetched. + """ + + def load(self, states: dict[Path, int]) -> None: + """ + Load this page from the given states. + """ + self.states = states + self.loaded = True + + # This is black magic: we want to peek the last added entry (the next query) but there is no method for this. + # Instead, popitem() removes the last entry, which we then add again (note: this does not violate the order!). + with suppress(KeyError): + k, v = states.popitem() + self.states[k] = v + self.next_query = k + + def unload(self) -> None: + """ + Unload the states to free up some memory and lessen the front-end load of shifting rows and selecting files. + + The "query" can be used to fetch the states again. + """ + self.states = {} + self.loaded = False + self.next_query = None + + def num_files(self) -> int: + """ + Return the number of files in this page. + """ + return len(self.states) + + def is_last_page(self): + """ + Whether there are more pages to be fetched after this page. + """ + return self.loaded and len(self.states) == 0 + + @staticmethod + def path_to_sort_key(path: Path): + """ + We mimic the sorting of the underlying TorrentFileTree to avoid Qt messing up our pages. + """ + return tuple(int(part) if part.isdigit() else part for part in NAT_SORT_PATTERN.split(str(path))) + + def __lt__(self, other: FilesPage | Path) -> bool: + """ + Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). + """ + query = self.path_to_sort_key(self.query) + other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) + return query < other_query + + def __le__(self, other: FilesPage | Path) -> bool: + """ + Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). + """ + query = self.path_to_sort_key(self.query) + other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) + return query <= other_query + + def __gt__(self, other: FilesPage | Path) -> bool: + """ + Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). + """ + query = self.path_to_sort_key(self.query) + other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) + return query > other_query + + def __ge__(self, other: FilesPage | Path) -> bool: + """ + Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). + """ + query = self.path_to_sort_key(self.query) + other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) + return query >= other_query + + def __eq__(self, other: FilesPage | Path) -> bool: + """ + Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). + """ + query = self.path_to_sort_key(self.query) + other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) + return query == other_query + + def __ne__(self, other: FilesPage | Path) -> bool: + """ + Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). + """ + query = self.path_to_sort_key(self.query) + other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) + return query != other_query + + +class PreformattedTorrentFileTreeWidget(QTableWidget): + """ + A widget for paged file views that use an underlying (core process) TorrentFileTree. + """ + + def __init__(self, parent: QWidget | None, page_size: int = 20, view_size_pre: int = 1, view_size_post: int = 2): + """ + + :param page_size: The number of items (directory/file Paths) per page. + :param view_size_pre: The number of pages to keep preloaded "above" the visible items. + :param view_size_post: The number of pages to keep preloaded "below" the visible items. + """ + super().__init__(1, 4, parent) + + # Parameters + self.page_size = page_size + self.view_size_pre = view_size_pre + self.view_size_post = view_size_post + + # Torrent information variables + self.infohash = None + self.pages: list[FilesPage] = [FilesPage(Path('.'), 0)] + + # View related variables + self.view_start_index: int = 0 + self.previous_view_start_index: int | None = None + + # GUI state variables + self.exp_contr_requests: dict[Path, int] = {} + + # Setup vertical scrollbar + self.verticalScrollBar().setPageStep(self.page_size) + self.verticalScrollBar().setSingleStep(1) + self.reset_scroll_bar() + self.setAutoScroll(False) + + # Setup (hide) table columns + self.horizontalHeader().hide() + self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) + self.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + self.horizontalHeader().setContentsMargins(0, 0, 0, 0) + self.setShowGrid(False) + + self.verticalHeader().hide() + + # Setup selection and focus modes + self.setEditTriggers(PyQt5.QtWidgets.QAbstractItemView.NoEditTriggers) + self.setSelectionBehavior(PyQt5.QtWidgets.QAbstractItemView.SelectRows) + self.setSelectionMode(PyQt5.QtWidgets.QAbstractItemView.NoSelection) + self.setFocusPolicy(Qt.NoFocus) + + # Set style + self.setStyleSheet(""" + QTableView::item::hover { background-color: rgba(255,255,255, 0); } + QTableView::item:selected{ background-color: #444; } + """) + + # Reset the underlying data + self.clear() + + # Initialize signals and moving graphics + connect(self.itemClicked, self.item_clicked) + + self.loading_movie = QMovie() + self.loading_movie.setFileName(get_image_path("spinner.gif")) + connect(self.loading_movie.frameChanged, self.spin_spinner) + + def clear(self) -> None: + """ + Clear the table data and then add a spinner to signify the loading state. + """ + super().clear() + + self.pages: list[FilesPage] = [FilesPage(Path('.'), 0)] + self.infohash = None + + loading_icon = QIcon(get_image_path("spinner.gif")) + self.loading_widget = QTableWidgetItem(loading_icon, "", QTableWidgetItem.UserType) + + self.setSelectionMode(PyQt5.QtWidgets.QAbstractItemView.NoSelection) + + self.setRowCount(1) + self.setItem(0, 1, self.loading_widget) + + def reset_scroll_bar(self) -> None: + """ + Make sure we never reach the end of the scrollbar, as long as we are not on the first or last page. + + This allows "scroll down" and "scroll up" even though we reached the end of the data loaded in the GUI. + Otherwise, we could get "stuck" on a page that is not the last page. + """ + first_visible_row = self.rowAt(0) + last_visible_row = self.rowAt(self.height() - 1) + if self.verticalScrollBar().sliderPosition() == self.verticalScrollBar().minimum(): + if first_visible_row != -1 and self.row_to_page_index(first_visible_row) != 0: + self.verticalScrollBar().blockSignals(True) + self.verticalScrollBar().setSliderPosition(1) + self.verticalScrollBar().blockSignals(False) + elif self.verticalScrollBar().sliderPosition() == self.verticalScrollBar().maximum(): + if last_visible_row != -1 and self.row_to_page_index(last_visible_row) != len(self.pages) - 1: + self.verticalScrollBar().blockSignals(True) + self.verticalScrollBar().setSliderPosition(self.verticalScrollBar().maximum() - 1) + self.verticalScrollBar().blockSignals(False) + + def row_to_page_index(self, row: int) -> int: + """ + Convert a row index to a page index. + + Because of the underlying view construction all pages are equal to the page_size, except for the last page. + """ + return self.view_start_index + row // self.page_size + + def initialize(self, infohash: str): + """ + Set the current infohash and fetch its first page after unloading or when first initializing. + + NOTE: This widget is reused between infohashes. + """ + self.infohash = infohash + self.fetch_page(0) + + def item_clicked(self, clicked: QTableWidgetItem): + """ + The user clicked on a cell. + + Figure out if we should update the selection or expanded/collapsed state. If we do, let the core know. + + Case 1: When the user clicks the checkbox we want to update the selection state and exit. + Case 2: When the user clicks next to the checkbox (or there is no checkbox) we expand/collapse a directory. + """ + file_desc = clicked.data(Qt.UserRole) + # Determine if we are in case 1: if the clicked cell doesn't even have a checkbox, we don't investigate further. + if isinstance(file_desc, dict): + is_checked = clicked.checkState() + was_checked = Qt.Checked + for page in reversed(self.pages): + file_path = Path(file_desc["name"]) + if file_path in page.states: + was_checked, page.states[file_path] = page.states[file_path], is_checked + break + # The checkbox state changed, meaning the user actually clicked the checkbox. + if is_checked != was_checked: + modifier = "select" if is_checked == Qt.Checked else "deselect" + request_manager.get(f"downloads/{self.infohash}/files/{modifier}", + url_params={"path": file_desc["name"]}) + # Don't wait for a core refresh but immediately update all loaded rows with the expected check status. + for row in range(self.rowCount()): + item = self.item(row, 0) + user_data = item.data(Qt.UserRole) + if user_data["name"].startswith(file_desc["name"]): + item.setCheckState(is_checked) + self.pages[user_data["page"]].states[Path(user_data["name"])] = is_checked + return # End of case 1, exit out! + # Case 2: We would've returned if a checkbox got toggled, this is a collapse/expand event! + if clicked in self.selectedItems(): + # Only the first widget stores the data but the entire row can be clicked. + expand_select_widget, _, _, _ = self.selectedItems() + file_desc = expand_select_widget.data(Qt.UserRole) + if file_desc["index"] in [-1, -2]: + self.exp_contr_requests[file_desc["name"]] = self.row_to_page_index(self.row(clicked)) + mode = "expand" if file_desc["index"] == -1 else "collapse" + request_manager.get( + endpoint=f"downloads/{self.infohash}/files/{mode}", + url_params={ + 'path': file_desc["name"] + }, + on_success=self.on_expanded_contracted, + ) + + def fetch_page(self, page_index: int) -> None: + """ + Query the core for a given page index. + """ + search_path = None + if page_index < len(self.pages): + # We have fetched the query for this page before. + search_path = self.pages[page_index].query + elif page_index == len(self.pages): + # We need to check the previous page for the next query. + if self.pages[page_index - 1].next_query is not None: + search_path = self.pages[page_index - 1].next_query + + if search_path is None: + # This can happen if we request a page more than 1 page past our currently loaded pages or if there are no + # more pages to load. + # Whatever the case, we can't fetch pages without a query and we simply exit out. + return + + request_manager.get( + endpoint=f"downloads/{self.infohash}/files", + url_params={ + 'view_start_path': search_path, + 'view_size': self.page_size + }, + on_success=self.fill_entries, + ) + + def free_page(self, page_index: int) -> None: + """ + Free memory for a single page. + """ + if page_index != 0 and page_index == len(self.pages) - 1: + self.pages.pop() + else: + self.pages[page_index].unload() + + def truncate_pages(self, page_index: int) -> None: + """ + Truncate the list of pages to only CONTAIN UP TO a given page index. + + For example, truncating for page index 3 of the page list [0, 1, 2, 3, 4] will remove the page at index 4. + """ + self.pages = self.pages[:(page_index + 1)] + + def scrollContentsBy(self, dx: int, dy: int) -> None: + """ + The user scrolled. Do the infinite scroll thing. + """ + super().scrollContentsBy(dx, dy) + self.reset_scroll_bar() # Do not allow the user to get into an unrecoverable state, even if we don't update! + + if dy == 0: + # No vertical scroll, no change in content + return + + first_visible_row = self.rowAt(0) + if first_visible_row == -1: + # Scrolling without content + self.fetch_page(self.view_start_index) + return + + last_visible_row = self.rowAt(self.height() - 1) + if last_visible_row == -1: + # Scrolling when already at the end + self.fetch_page(len(self.pages)) + return + + first_visible_page = self.row_to_page_index(first_visible_row) + last_visible_page = self.row_to_page_index(last_visible_row) + self.previous_view_start_index = self.view_start_index + self.view_start_index = max(0, first_visible_page - self.view_size_pre) + + if dy < 0: + # Scrolling down + last_loaded_page = len(self.pages) - 1 + if last_visible_page + self.view_size_post >= last_loaded_page: + # Not enough pages! Load more! + self.fetch_page(len(self.pages)) + else: + # Scrolling up + self.truncate_pages(last_visible_page + self.view_size_post) + for page_index in range(self.previous_view_start_index, + max(0, self.view_start_index - self.view_size_pre), + -1): + # Reload any unloaded pages! + if not self.pages[page_index].loaded: + self.fetch_page(page_index) + self.reset_scroll_bar() + + # Hard refresh all visible rows, in case of really fast scrolling and tearing. This is important on slower + # machines, which can "outscroll" the Qt updates. + self.refresh_visible() + + def on_expanded_contracted(self, response) -> None: + """ + The core finished expanding or collapsing a directory. + + ALL pages after and including the page that the expansion/collapse happened need to be refreshed. + """ + if response is None: + return + + page_index = self.exp_contr_requests.pop(response["path"]) + if page_index is None: + return + page_index = max(0, page_index - 1) + + self.truncate_pages(page_index) + + request_manager.get( + endpoint=f"downloads/{self.infohash}/files", + url_params={ + 'view_start_path': self.pages[page_index].query, + 'view_size': self.page_size + }, + on_success=self.fill_entries, + ) + + def hideEvent(self, a0) -> None: + """ + We are not shown, no need to do the loading animation. + """ + super().hideEvent(a0) + self.loading_movie.stop() + + def showEvent(self, a0) -> None: + """ + We are shown, continue the loading animation. + """ + super().showEvent(a0) + self.loading_movie.start() + + def resizeEvent(self, e) -> None: + """ + We got resized, causing the previous first and last visible row to be invalidated: perform a hard refresh. + """ + super().resizeEvent(e) + if self.isVisible(): + self.refresh_visible() + + def refresh_visible(self) -> None: + """ + Determine the visible rows and refresh what we are missing. + + Note that fetch_page will drop unattainable pages beyond our current knowledge and fill_entries will recursively + pull those pages in afterward. + """ + first_visible_row = self.rowAt(0) + if first_visible_row == -1: + first_visible_row = 0 + last_visible_row = self.rowAt(self.height() - 1) + if last_visible_row == -1: + last_visible_row = first_visible_row + for page_index in (range(self.view_start_index, self.view_start_index + last_visible_row - first_visible_row) + or [self.view_start_index]): + self.fetch_page(page_index) + + def spin_spinner(self, _) -> None: + """ + Perform the loading square spinning animation. + + Note that the spinner object may be suddenly removed when Qt fills in the table with our data. + """ + if self.isVisible(): + with suppress(RuntimeError): + self.loading_widget.setIcon(QIcon(self.loading_movie.currentPixmap())) + + def format_size(self, size_in_bytes: int) -> str: + """ + Stringify the given number of bytes to more human-readable units. + """ + if size_in_bytes < KB: + return f"{size_in_bytes} bytes" + if size_in_bytes < MB: + return f"{round(size_in_bytes / KB, 2)} KB" + if size_in_bytes < GB: + return f"{round(size_in_bytes / MB, 2)} MB" + if size_in_bytes < TB: + return f"{round(size_in_bytes / GB, 2)} GB" + if size_in_bytes < PB: + return f"{round(size_in_bytes / TB, 2)} TB" + return f"{round(size_in_bytes / PB, 2)} PB" + + def render_to_table(self, row: int, page_index: int, states: dict[Path, int], file_desc) -> None: + """ + Render the core's download endpoint response data for a single file (file_desc) to the given row in our table + and store the state in the given states dir for the given page index. + + Note that - at this point - the given states dir is not complete yet and not loaded in the page index yet. We + use this to our advantage when remembering directory states in between updates. + """ + description = file_desc["name"] + file_desc["page"] = page_index + collapse_icon = " " + if file_desc["index"] >= 0: + # Indent with file depth and only show name + *folders, name = Path(description).parts + description = len(folders) * " " + name + else: + collapse_icon = ("\u1405 " if file_desc["index"] == -1 else "\u1401 ") + + # File name + file_name_widget = QTableWidgetItem(description) + file_name_widget.setTextAlignment(Qt.AlignVCenter) + + # Checkbox and expansion arrow + expand_select_widget = QTableWidgetItem(collapse_icon) + expand_select_widget.setTextAlignment(Qt.AlignCenter) + expand_select_widget.setData(Qt.UserRole, file_desc) + expand_select_widget.setFlags(expand_select_widget.flags() | Qt.ItemIsTristate | Qt.ItemIsUserCheckable) + + if file_desc["included"]: + expand_select_widget.setCheckState(Qt.Checked) + states[Path(file_desc["name"])] = Qt.Checked + elif file_desc["index"] >= 0: + expand_select_widget.setCheckState(Qt.Unchecked) + states[Path(file_desc["name"])] = Qt.Unchecked + else: + # Directory, determine from previous state + checked_state = self.pages[page_index].states.get(file_desc["name"], Qt.Checked) + states[Path(file_desc["name"])] = checked_state + expand_select_widget.setCheckState(checked_state) + + # File size + file_size_widget = QTableWidgetItem(self.format_size(file_desc['size'])) + + # Progress + # Note: directories are derived: they are not a real entry in the torrent file list and they have no completion. + file_progress_widget = QTableWidgetItem(f"{round(file_desc['progress'] * 100.0, 2)} %" + if file_desc["index"] >= 0 else "") + + self.setItem(row, 0, expand_select_widget) + self.setItem(row, 1, file_name_widget) + self.setItem(row, 2, file_size_widget) + self.setItem(row, 3, file_progress_widget) + + def render_page_to_table(self, page_index: int, files_dict) -> None: + """ + Given the core's response to the view that we requested at a given page index, fill our table. + """ + base_row = (page_index - self.view_start_index) * self.page_size + states: dict[Path, int] = {} + for row, file_desc in enumerate(files_dict): + target_row = base_row + row + if 0 <= target_row < self.rowCount(): + self.render_to_table(target_row, page_index, states, file_desc) + self.pages[page_index].load(states) + + def shift_pages(self, previous_row_count: int) -> None: + """ + Shift the old data in our table after a scroll. This avoids waiting for hard refreshes (slow). However, + sometimes the user "outscrolls" Qt and hard refreshes have to be used to fill in the gaps. Therefore, this + method should be used to complement hard refreshes, not replace them. + """ + if self.view_start_index < self.previous_view_start_index: + # Move existing down, start from last existing item. + shift = (self.previous_view_start_index - self.view_start_index) * self.page_size + for row in range(self.rowCount() - 1, self.rowCount() - previous_row_count - shift + 1, -1): + if row < 0 or row - shift < 0: + return + self.setItem(row, 0, self.takeItem(row - shift, 0)) + self.setItem(row, 1, self.takeItem(row - shift, 1)) + self.setItem(row, 2, self.takeItem(row - shift, 2)) + self.setItem(row, 3, self.takeItem(row - shift, 3)) + elif self.view_start_index > self.previous_view_start_index: + # Move existing up, start from the first existing item. + shift = (self.view_start_index - self.previous_view_start_index) * self.page_size + for row in range(0, previous_row_count - shift): + if row > self.rowCount() or row + shift > self.rowCount(): + return + self.setItem(row, 0, self.takeItem(row + shift, 0)) + self.setItem(row, 1, self.takeItem(row + shift, 1)) + self.setItem(row, 2, self.takeItem(row + shift, 2)) + self.setItem(row, 3, self.takeItem(row + shift, 3)) + + def fill_entries(self, entry_dict) -> None: + """ + Handle a raw core response. + """ + self.infohash = entry_dict['infohash'] + files_dict = entry_dict['files'] + num_files = len(files_dict) + + # Special loading response + if num_files == 1 and files_dict[0]["index"] == -3: + return + + self.blockSignals(True) + self.loading_movie.stop() # Stop loading and make interactive + + # Determine the page index of the given data and prepare the data structures for it. + query = Path(entry_dict["query"]) + current_page = bisect(self.pages, query) + total_files = sum(page.num_files() for page in self.pages[self.view_start_index:]) + if current_page - 1 >= 0 and self.pages[current_page - 1].query == query: + current_page -= 1 + if current_page == len(self.pages): + total_files += len(files_dict) + if self.pages[-1].next_query == query: + self.pages.append(FilesPage(query, current_page)) + else: + return + if len(self.pages) == 1 and query == Path("."): + total_files = len(files_dict) + + # Make space for all visible pages + previous_row_count = self.rowCount() + self.setRowCount(total_files) + + # First shift previous entries out of the way + if self.previous_view_start_index is not None: + self.shift_pages(previous_row_count) + self.previous_view_start_index = None + + # Inject the individual files + self.render_page_to_table(current_page, files_dict) + + self.setSelectionMode(PyQt5.QtWidgets.QAbstractItemView.SingleSelection) + + self.blockSignals(False) + + # Fill out the remaining area, if possible. + if self.rowAt(self.height() - 1) == -1 and not self.pages[-1].is_last_page(): + self.fetch_page(current_page + 1) + + self.reset_scroll_bar()