[UI][Common] Add support for BitTorrent V2 file tree

In BEP52, a new tiles structure was introduce, a `file tree`.
This change added support for this structure in the `TorrentInfo` class.

Ref: https://www.bittorrent.org/beps/bep_0052.html
Closes: https://github.com/deluge-torrent/deluge/pull/404
This commit is contained in:
DjLegolas 2022-12-03 16:39:53 +02:00 committed by Calum Lind
parent 8001110625
commit a459e78268
No known key found for this signature in database
GPG Key ID: 90597A687B836BA3
4 changed files with 316 additions and 23 deletions

Binary file not shown.

Binary file not shown.

View File

@ -44,7 +44,7 @@ class TestUICommon:
ti = TorrentInfo(filename, filetree=1)
assert ti.files_tree == files_tree
filestree2 = {
files_tree2 = {
'contents': {
'torrent_filehash': {
'type': 'dir',
@ -71,7 +71,7 @@ class TestUICommon:
'type': 'dir',
}
ti = TorrentInfo(filename, filetree=2)
assert ti.files_tree == filestree2
assert ti.files_tree == files_tree2
def test_hash_optional_md5sum(self):
# Ensure `md5sum` key is not included in filetree output
@ -173,3 +173,118 @@ class TestUICommon:
}
]
assert ti.files == result_files
def test_bittorrent_v2_path(self):
filename = common.get_test_data_file('v2_test.torrent')
files_tree = {
'torrent_test': {
'small.txt': (0, 22, True),
'還在一個人無聊嗎~還不趕緊上來聊天美.txt': (1, 32, True),
}
}
ti = TorrentInfo(filename, filetree=1)
assert ti.files_tree == files_tree
files_tree2 = {
'contents': {
'torrent_test': {
'type': 'dir',
'contents': {
'small.txt': {
'type': 'file',
'path': 'torrent_test/small.txt',
'length': 22,
'index': 0,
'download': True,
},
'還在一個人無聊嗎~還不趕緊上來聊天美.txt': {
'type': 'file',
'path': 'torrent_test/還在一個人無聊嗎~還不趕緊上來聊天美.txt',
'length': 32,
'index': 1,
'download': True,
},
},
'length': 54,
'download': True,
}
},
'type': 'dir',
}
ti = TorrentInfo(filename, filetree=2)
assert ti.files_tree == files_tree2
def test_bittorrent_v2_hybrid_path(self):
filename = common.get_test_data_file('v2_hybrid.torrent')
files_tree = {
'torrent_test': {
'small.txt': (0, 22, True),
'還在一個人無聊嗎~還不趕緊上來聊天美.txt': (2, 32, True),
'.pad': {
'16362': (1, 16362, True),
'16352': (3, 16352, True),
},
}
}
ti = TorrentInfo(filename, filetree=1, force_bt_version=1)
assert ti.files_tree == files_tree
del files_tree['torrent_test']['.pad']
files_tree['torrent_test']['還在一個人無聊嗎~還不趕緊上來聊天美.txt'] = (1, 32, True)
ti = TorrentInfo(filename, filetree=1, force_bt_version=2)
assert ti.files_tree == files_tree
files_tree2 = {
'contents': {
'torrent_test': {
'type': 'dir',
'contents': {
'small.txt': {
'type': 'file',
'path': 'torrent_test/small.txt',
'length': 22,
'index': 0,
'download': True,
},
'還在一個人無聊嗎~還不趕緊上來聊天美.txt': {
'type': 'file',
'path': 'torrent_test/還在一個人無聊嗎~還不趕緊上來聊天美.txt',
'length': 32,
'index': 2,
'download': True,
},
'.pad': {
'type': 'dir',
'contents': {
'16362': {
'type': 'file',
'path': 'torrent_test/.pad/16362',
'length': 16362,
'index': 1,
'download': True,
},
'16352': {
'type': 'file',
'path': 'torrent_test/.pad/16352',
'length': 16352,
'index': 3,
'download': True,
},
},
'length': 32714,
'download': True,
},
},
'length': 32768,
'download': True,
}
},
'type': 'dir',
}
ti = TorrentInfo(filename, filetree=2, force_bt_version=1)
assert ti.files_tree == files_tree2
torrent_test = files_tree2['contents']['torrent_test']
torrent_test['length'] -= torrent_test['contents']['.pad']['length']
del torrent_test['contents']['.pad']
torrent_test['contents']['還在一個人無聊嗎~還不趕緊上來聊天美.txt']['index'] = 1
ti = TorrentInfo(filename, filetree=2, force_bt_version=2)
assert ti.files_tree == files_tree2

View File

@ -13,6 +13,7 @@ The ui common module contains methods and classes that are deemed useful for all
import logging
import os
from hashlib import sha1 as sha
from typing import Tuple
from deluge import bencode
from deluge.common import decode_bytes
@ -171,10 +172,11 @@ class TorrentInfo:
filename (str, optional): The path to the .torrent file.
filetree (int, optional): The version of filetree to create (defaults to 1).
torrent_file (dict, optional): A bdecoded .torrent file contents.
force_bt_version (int, optional): The BitTorrent spec to use for parsing (defaults to 1).
"""
def __init__(self, filename='', filetree=1, torrent_file=None):
def __init__(self, filename='', filetree=1, torrent_file=None, force_bt_version=1):
self._filedata = None
if torrent_file:
self._metainfo = torrent_file
@ -211,9 +213,24 @@ class TorrentInfo:
else:
self._name = decode_bytes(info_dict['name'], encoding)
meta_version = info_dict['meta version'] if 'meta version' in info_dict else -1
is_hybrid = 'files' in info_dict and meta_version == 2
parse_v1 = False
parse_v2 = False
if is_hybrid:
if force_bt_version == 1:
parse_v1 = True
elif force_bt_version == 2:
parse_v2 = True
elif 'files' in info_dict:
parse_v1 = True
elif meta_version == 2 and 'file tree' in info_dict:
parse_v2 = True
# Get list of files from torrent info
self._files = []
if 'files' in info_dict:
if parse_v1:
paths = {}
dirs = {}
prefix = self._name
@ -245,25 +262,67 @@ class TorrentInfo:
if filetree == 2:
def walk(path, item):
def walk(full_path, item):
if item['type'] == 'dir':
item.update(dirs[path])
item.update(dirs[full_path])
else:
item.update(paths[path])
item.update(paths[full_path])
item['download'] = True
file_tree = FileTree2(list(paths))
file_tree.walk(walk)
else:
def walk(path, item):
def walk(full_path, item):
if isinstance(item, dict):
return item
return [paths[path]['index'], paths[path]['length'], True]
return [paths[full_path]['index'], paths[full_path]['length'], True]
file_tree = FileTree(paths)
file_tree.walk(walk)
self._files_tree = file_tree.get_tree()
elif parse_v2:
def single_file_torrent(inner_info_dict):
if len(inner_info_dict['file tree']) > 1:
return False
file_name = [key for key in inner_info_dict['file tree']][0]
return inner_info_dict['name'] == file_name
if not single_file_torrent(info_dict):
info_dict['file tree'] = {info_dict['name']: info_dict['file tree']}
if filetree == 2:
def walk(full_path, item):
if item['type'] == 'file':
item['path'] = full_path
self._files.append(
{
'path': full_path,
'size': item['length'],
'download': True,
}
)
item['download'] = True
file_tree = FileTree2BTv2(info_dict['file tree'])
file_tree.walk(walk)
else:
def walk(full_path, item):
if isinstance(item, dict):
return item
self._files.append(
{'path': full_path, 'size': item[1], 'download': True}
)
return [item[0], item[1], True]
file_tree = FiletreeBTv2(info_dict['file tree'])
file_tree.walk(walk)
self._files_tree = file_tree.get_tree()
else:
self._files.append(
{'path': self._name, 'size': info_dict['length'], 'download': True}
@ -386,13 +445,31 @@ class TorrentInfo:
class FileTree2:
"""
Converts a list of paths in to a file tree.
Converts a list of paths, from a V1 torrent, into a file tree.
:param paths: The paths to be converted
:type paths: list
Each file will have the dictionary structure of:
{ file_name: {type, path, index, length, download} }
where:
type (str): will always be "file"
path (str): the absolute file path from the root the torrent
index (int): the index of file in the torrent
length (int): the size of the file, in bytes
download (bool): marks the file to download
Folder will be dictionaries of files:
{ dir1: type, contents: {file_name1: {...}, file_name2: {...}}, dir2: ... }
where:
type (str): will always be "dir"
contents (dict): a dictionary of inner files and folders
The entire tree will start with a root dictionary:
{ contents: {dirs...}, type: "dir" }
Args:
paths (list): The paths to be converted.
"""
def __init__(self, paths):
def __init__(self, paths: list):
self.tree = {'contents': {}, 'type': 'dir'}
def get_parent(path):
@ -466,13 +543,23 @@ class FileTree2:
class FileTree:
"""
Convert a list of paths in a file tree.
Converts a dict of paths, from a V1 torrent, into a file tree.
:param paths: The paths to be converted.
:type paths: list
Each file will have the dictionary structure of:
{ file_name: [index, length, download] }
Where:
index (int): the index of file in the torrent
length (int): the size of the file, in bytes
download (bool): marks the file to download
Folder will be dictionaries of files:
{ dir1: {file_name1: [...], file_name2: [...]}, dir2: ... }
Args:
paths (dict): The paths to be converted.
"""
def __init__(self, paths):
def __init__(self, paths: dict):
self.tree = {}
def get_parent(path):
@ -498,8 +585,8 @@ class FileTree:
"""
Return the tree, after first converting all file lists to a tuple.
:returns: the file tree.
:rtype: dictionary
Returns:
dict: the file tree.
"""
def to_tuple(path, item):
@ -515,10 +602,10 @@ class FileTree:
Walk through the file tree calling the callback function on each item
contained.
:param callback: The function to be used as a callback, it should have
Args:
callback (function): The function to be used as a callback, it should have
the signature func(item, path) where item is a `tuple` for a file
and `dict` for a directory.
:type callback: function
"""
def walk(directory, parent_path):
@ -547,3 +634,94 @@ class FileTree:
self.walk(write)
return '\n'.join(lines)
class FiletreeBTv2(FileTree):
"""
Converts a dict of paths, from a V2 torrent, into a file tree.
Each file will have the dictionary structure of:
{ file_name: [index, length, download] }
Where:
index (int): the index of file in the torrent
length (int): the size of the file, in bytes
download (bool): marks the file to download
Folder will be dictionaries of files:
{ dir1: {file_name1: [...], file_name2: [...]}, dir2: ... }
Args:
file_tree (dict): The paths to be converted.
"""
def __init__(self, file_tree):
self.tree = {}
def get_parent(curr_tree_dict, index, parent) -> int:
for key, item in curr_tree_dict.items():
key = decode_bytes(key)
if b'' in item:
parent[key] = [index, item[b''][b'length']]
index += 1
else:
parent[key] = {}
index = get_parent(item, index, parent[key])
return index
get_parent(file_tree, 0, self.tree)
class FileTree2BTv2(FileTree2):
"""
Converts a dict of paths, from a V2 torrent, into a file tree.
Each file will have the dictionary structure of:
{ file_name: {type, path, index, length, download} }
where:
type (str): will always be "file"
path (str): the absolute file path from the root the torrent
index (int): the index of file in the torrent
length (int): the size of the file, in bytes
download (bool): marks the file to download
Folder will be dictionaries of files:
{ dir1: type, contents: {file_name1: {...}, file_name2: {...}}, dir2: ... }
where:
type (str): will always be "dir"
contents (dict): a dictionary of inner files and folders
The entire tree will start with a root dictionary:
{ contents: {dirs...}, type: "dir" }
Args:
file_tree (dict): The paths to be converted.
"""
def __init__(self, file_tree):
self.tree = {'contents': {}, 'type': 'dir'}
def get_parent(curr_tree_dict, index, parent) -> Tuple[int, int]:
total_length = 0
for key, item in curr_tree_dict.items():
key = decode_bytes(key)
if b'' in item:
length = item[b''][b'length']
total_length += length
parent['contents'][key] = {
'index': index,
'length': length,
'type': 'file',
}
index += 1
else:
parent['contents'][key] = {
'contents': {},
'type': 'dir',
'length': 0,
}
index, length = get_parent(item, index, parent['contents'][key])
parent['contents'][key]['length'] = length
total_length += length
return index, total_length
get_parent(file_tree, 0, self.tree)