From 748b14e5f6fb027c3d0b2464e2bc53555779792c Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Sat, 19 Apr 2025 20:55:07 +0300 Subject: [PATCH 01/46] Create raw version of specs --- specs/README.md | 11 +++++++++ specs/peer-announce.json | 6 +++++ specs/peer-message-exchange.md | 44 ++++++++++++++++++++++++++++++++++ specs/torrentinno.json | 22 +++++++++++++++++ specs/tracker-response.json | 14 +++++++++++ 5 files changed, 97 insertions(+) create mode 100644 specs/README.md create mode 100644 specs/peer-announce.json create mode 100644 specs/peer-message-exchange.md create mode 100644 specs/torrentinno.json create mode 100644 specs/tracker-response.json diff --git a/specs/README.md b/specs/README.md new file mode 100644 index 0000000..456aede --- /dev/null +++ b/specs/README.md @@ -0,0 +1,11 @@ +# The overall flow + +1) The peer announces itself to the tracker. In order to do that, the peer +a) computes the *info-hash* of the `torrentinno` file. To do so, it sorts all the keys in alphabetic order, then removes any whitespaces, and then takes the sha512 hash of the resulting string encoded in UTF-8. +b) sends the http query with `peer-announce.json` as request body. + +2) The tracker accepts the peer request and returns the list of all currently online peers that have sent the announce to the tracker with the same *info-hash*. The server response body is formatted according to `tracker-response.json` + +3) After that, the peer maintains the connection with tracker. And periodically (around 30 seconds) repeats the announcement. If the tracker detects that some peer hasn't announced itself with `info-hash` for certain time, then it stops sending that peer in response to other peers' announcements. + +4) Once the peer receives the list of peers, it begins communicating with them. The details on that communication are in `peer-message-exchange.md`. \ No newline at end of file diff --git a/specs/peer-announce.json b/specs/peer-announce.json new file mode 100644 index 0000000..91bc97f --- /dev/null +++ b/specs/peer-announce.json @@ -0,0 +1,6 @@ +{ + "peerId": "uQ5dKnR3FZcAVjY0eBHwGslM9tJpXxUNq7PavCLOgmy6z1nEkWYh8b2rTcfMVuDl", + "resourceSha512": "2c74fd17edafd80e8447b0d46741ee243b7eb74c1a1e41e2c7b7afecb5a4b96f3c6e5d7cb8e5b7e20d2343fdb1f3e22f3efb2c4ff977d7dbd7d0e351b9d4a2c4", + "publicIp": "10.908.23.123", + "publicPort": "90833" +} \ No newline at end of file diff --git a/specs/peer-message-exchange.md b/specs/peer-message-exchange.md new file mode 100644 index 0000000..343517e --- /dev/null +++ b/specs/peer-message-exchange.md @@ -0,0 +1,44 @@ +# Peer message exchange format + +All messages between peers are translated as raw bytes. +Where applicable, the order of bytes is big-endian. +All indexes are zero based, unless stated otherwise. +The peer-id is a string satisfying regex `^[0-9a-zA-Z]{64}$` + +## Handshake +``` +TorrentInno[peer-id (8 bytes)][info-hash (64 bytes)] +``` +*Example*: +``` +TorrentInnouQ5dKnR3FZcAVjY0eBHwGslM9tJpXxUNq7PavCLOgmy6z1nEkWYh8b2rTcfMVuDl2c74fd17edafd80e8447b0d46741ee243b7eb74c1a1e41e2c7b7afecb5a4b96f3c6e5d7cb8e5b7e20d2343fdb1f3e22f3efb2c4ff977d7dbd7d0e351b9d4a2c4 +``` + +**Description**: +The handshake message is sent by one of the peers trying to establish a connection with some other peer. Note that the length of the handshake message is always fixed. + +The `info-hash` is the hash of the `torrentinno` file. + +The peer that receives this message must check if it knows the resource in the `info-hash` field. If it knows this resource, then reply will the same message, substituting `peer-id` with its own id. + +If the handshake is successful, then both peers create and maintain a separate connection tied to that specific resource. The connection is biderectional continuous channel where peers exchange length-prefixed messages. + +## Peer to peer communication + +Each message has the following format: `[body-length (8 bytes)][message-body]`. Where `body-length` is the length of the `[message-body]` (in bytes). Further, only `[message-body]` will be discussed. + +Each `[message-body]` has the following format: `[message-type (1 byte)][message-data]`. `[message-type]` is a number (`0x1`, for example) +Currently, only two types of messages are supported: +1) 'Request': The `[message-data]` has format: `[piece-index (8 bytes)][piece-inner-offset (8 bytes)][block-length (8 bytes)]`. +This message indicates that the peer wants to fetch the `[block-length]` bytes from the piece with index `[piece-index]`, with inner offset within the piece of length `[piece-inner-offset]` bytes. + + +2) 'Piece': The `[message-data]` has format: `[piece-index (8 bytes)][piece-inner-offset (8 bytes)][block-length (8 bytes)]data`. The first three fields has the same meaning as in the 'Request' message. The `data` contains the requested part of the file and it must have the length of `block-length` bytes. + +*Example:* +The full message to request 1024 bytes with offset 384 bytes offset within the piece 19 looks like this: + +`0x00000019` `0x1` `0x00000013` `0x00000180` `0x00000400` + +References: +[https://www.bittorrent.org/beps/bep_0003.html](https://www.bittorrent.org/beps/bep_0003.html) diff --git a/specs/torrentinno.json b/specs/torrentinno.json new file mode 100644 index 0000000..677f2df --- /dev/null +++ b/specs/torrentinno.json @@ -0,0 +1,22 @@ +{ + "trackerIp": "10.907.123.20", + "trackerPort": "3434", + "comment": "Video with Tralelelo Tralala", + "creationDate": "2000-10-31T01:30:00.000-05:00", + "name": "FunnyVideo.mp4", + "pieces": [ + { + "sha512": "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043", + "size": 1048576 + }, + { + "sha512": "1536769a070df5fe9aab5ea106c13f0448eecdbee67753f52ed86faf836098da81313bd3bfdc4d568c27582afa78f9bbedbf19a1714ee32b516c5c2ca468561e", + "size": 1048576 + }, + { + "sha512": "3831a6a6155e509dee59a7f451eb35324d8f8f2df6e3708894740f98fdee23889f4de5adb0c5010dfb555cda77c8ab5dc902094c52de3278f35a75ebc25f093a", + "size": 26071 + } + ], + "fileSha512": "db9b1cd3262dee37756a09b9064973589847caa8e53d31a9d142ea2701b1b28abd97838bb9a27068ba305dc8d04a45a1fcf079de54d607666996b3cc54f6b67c" +} \ No newline at end of file diff --git a/specs/tracker-response.json b/specs/tracker-response.json new file mode 100644 index 0000000..5e60fc0 --- /dev/null +++ b/specs/tracker-response.json @@ -0,0 +1,14 @@ +{ + "peers": [ + { + "peerId": "39d1e4c943bd75305dc9ab5e973284f1a4219a0e5c79f57f2b642c918a0bce59", + "publicIp": "90.565.34.123", + "publicPort": "12312" + }, + { + "peerId": "a71fbd3e12f648c4c9eaf81e0e9b0ec69cf3d9e8120472d0a5c7b1a46f5d3c8e", + "publicIp": "23.32.23.123", + "publicPort": "8086" + } + ] +} \ No newline at end of file From 9a4941319585027f96bd1a3d94ee44f29afb6678 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Mon, 21 Apr 2025 16:50:10 +0300 Subject: [PATCH 02/46] Refine the specification (use sha256 instead of sha512 + clarify the message exchange format). Add .gitignore --- .gitignore | 4 ++++ specs/README.md | 4 ++-- specs/peer-announce.json | 2 +- specs/peer-message-exchange.md | 40 +++++++++++++++++++++------------- specs/torrentinno.json | 8 +++---- specs/tracker-response.json | 4 ++-- 6 files changed, 38 insertions(+), 24 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1254eba --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vscode/ +.idea/ +.venv/ +venv/ diff --git a/specs/README.md b/specs/README.md index 456aede..753e4b2 100644 --- a/specs/README.md +++ b/specs/README.md @@ -1,10 +1,10 @@ # The overall flow 1) The peer announces itself to the tracker. In order to do that, the peer -a) computes the *info-hash* of the `torrentinno` file. To do so, it sorts all the keys in alphabetic order, then removes any whitespaces, and then takes the sha512 hash of the resulting string encoded in UTF-8. +a) computes the *info-hash* of the `torrentinno` file. To do so, it sorts all the keys in alphabetic order, then removes any whitespaces, and then takes the sha256 hash of the resulting string encoded in UTF-8. b) sends the http query with `peer-announce.json` as request body. -2) The tracker accepts the peer request and returns the list of all currently online peers that have sent the announce to the tracker with the same *info-hash*. The server response body is formatted according to `tracker-response.json` +2) The tracker accepts the peer request and returns the list of all currently online peers that have sent the announcement to the tracker with the same *info-hash*. The server response body is formatted according to `tracker-response.json` 3) After that, the peer maintains the connection with tracker. And periodically (around 30 seconds) repeats the announcement. If the tracker detects that some peer hasn't announced itself with `info-hash` for certain time, then it stops sending that peer in response to other peers' announcements. diff --git a/specs/peer-announce.json b/specs/peer-announce.json index 91bc97f..bea4729 100644 --- a/specs/peer-announce.json +++ b/specs/peer-announce.json @@ -1,6 +1,6 @@ { "peerId": "uQ5dKnR3FZcAVjY0eBHwGslM9tJpXxUNq7PavCLOgmy6z1nEkWYh8b2rTcfMVuDl", - "resourceSha512": "2c74fd17edafd80e8447b0d46741ee243b7eb74c1a1e41e2c7b7afecb5a4b96f3c6e5d7cb8e5b7e20d2343fdb1f3e22f3efb2c4ff977d7dbd7d0e351b9d4a2c4", + "infoHash": "9a3f5d4c1e85a216afba9c9aeb62fa7d9a75b57c52cd945b7f58de8a0b9dcb4f", "publicIp": "10.908.23.123", "publicPort": "90833" } \ No newline at end of file diff --git a/specs/peer-message-exchange.md b/specs/peer-message-exchange.md index 343517e..1705d8f 100644 --- a/specs/peer-message-exchange.md +++ b/specs/peer-message-exchange.md @@ -1,23 +1,30 @@ # Peer message exchange format -All messages between peers are translated as raw bytes. -Where applicable, the order of bytes is big-endian. -All indexes are zero based, unless stated otherwise. -The peer-id is a string satisfying regex `^[0-9a-zA-Z]{64}$` +## General notes: + +- All messages between peers are translated as raw bytes. + +- All indexes are zero based, unless stated otherwise. + +- Where applicable, the order of bytes is big-endian. + +- Where applicable, the sequence of bytes is translated using hexadecimal encoding. ## Handshake ``` -TorrentInno[peer-id (8 bytes)][info-hash (64 bytes)] +TorrentInno[peer-id (32 bytes)][info-hash (32 bytes)] ``` *Example*: ``` -TorrentInnouQ5dKnR3FZcAVjY0eBHwGslM9tJpXxUNq7PavCLOgmy6z1nEkWYh8b2rTcfMVuDl2c74fd17edafd80e8447b0d46741ee243b7eb74c1a1e41e2c7b7afecb5a4b96f3c6e5d7cb8e5b7e20d2343fdb1f3e22f3efb2c4ff977d7dbd7d0e351b9d4a2c4 +TorrentInnoba1bd0139c99070e7ed25fd599e603f2b915acf8eb96fca8565a7ed34c4d705441bbcd3f70fbbbac8b5171869c75e274f0b1a6c54f784dc0f763cebb446eee5d ``` **Description**: -The handshake message is sent by one of the peers trying to establish a connection with some other peer. Note that the length of the handshake message is always fixed. +The handshake message is sent by one of the peers trying to establish a connection with some other peer. Note that the length of the handshake message is always 75 bytes. + +The `info-hash` is the hash of the `torrentinno` file. -The `info-hash` is the hash of the `torrentinno` file. +The `peer-id` is a sequence of 32 bytes generated by peer itself. `peer-id` is a unique identifier of a peer and used in all connections. The peer that receives this message must check if it knows the resource in the `info-hash` field. If it knows this resource, then reply will the same message, substituting `peer-id` with its own id. @@ -25,20 +32,23 @@ If the handshake is successful, then both peers create and maintain a separate c ## Peer to peer communication -Each message has the following format: `[body-length (8 bytes)][message-body]`. Where `body-length` is the length of the `[message-body]` (in bytes). Further, only `[message-body]` will be discussed. +Each message has the following format: `[body-length (4 bytes)][message-body]`. Where `body-length` is the length of the `[message-body]` (in bytes). Further, only `[message-body]` will be discussed. -Each `[message-body]` has the following format: `[message-type (1 byte)][message-data]`. `[message-type]` is a number (`0x1`, for example) +Each `[message-body]` has the following format: `[message-type (1 byte)][message-data]`. `[message-type]` is a number (`0x01`, for example) Currently, only two types of messages are supported: -1) 'Request': The `[message-data]` has format: `[piece-index (8 bytes)][piece-inner-offset (8 bytes)][block-length (8 bytes)]`. -This message indicates that the peer wants to fetch the `[block-length]` bytes from the piece with index `[piece-index]`, with inner offset within the piece of length `[piece-inner-offset]` bytes. - +1) 'Request': The `[message-data]` has format: `[piece-index (4 bytes)][piece-inner-offset (4 bytes)][block-length (4 bytes)]`. +This message indicates that the peer wants to fetch the `[block-length]` bytes from the piece with index `[piece-index]`, with inner offset within the piece of length `[piece-inner-offset]` bytes. -2) 'Piece': The `[message-data]` has format: `[piece-index (8 bytes)][piece-inner-offset (8 bytes)][block-length (8 bytes)]data`. The first three fields has the same meaning as in the 'Request' message. The `data` contains the requested part of the file and it must have the length of `block-length` bytes. +2) 'Piece': The `[message-data]` has format: `[piece-index (4 bytes)][piece-inner-offset (4 bytes)][block-length (4 bytes)]data`. The first three fields has the same meaning as in the 'Request' message. The `data` contains the requested part of the file and it must have the length of `block-length` bytes. *Example:* The full message to request 1024 bytes with offset 384 bytes offset within the piece 19 looks like this: -`0x00000019` `0x1` `0x00000013` `0x00000180` `0x00000400` +- `0x0000000d` - body length (13 bytes) +- `0x01` - message type (Request) +- `0x00000013` - piece index (19) +- `0x00000180` - the piece inner offset (384) +- `0x00000400` - the block length (1024) References: [https://www.bittorrent.org/beps/bep_0003.html](https://www.bittorrent.org/beps/bep_0003.html) diff --git a/specs/torrentinno.json b/specs/torrentinno.json index 677f2df..472333a 100644 --- a/specs/torrentinno.json +++ b/specs/torrentinno.json @@ -6,17 +6,17 @@ "name": "FunnyVideo.mp4", "pieces": [ { - "sha512": "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043", + "sha256": "6b8a2d7f30c9e8d9b3d5b7c61e0be67d48280f58ffefae3c8b8423fe7108ecb", "size": 1048576 }, { - "sha512": "1536769a070df5fe9aab5ea106c13f0448eecdbee67753f52ed86faf836098da81313bd3bfdc4d568c27582afa78f9bbedbf19a1714ee32b516c5c2ca468561e", + "sha256": "aabf67d072b5124a8c618a125054ea92c79a0cc489fdbb2d8f9acb68e9d6fd6", "size": 1048576 }, { - "sha512": "3831a6a6155e509dee59a7f451eb35324d8f8f2df6e3708894740f98fdee23889f4de5adb0c5010dfb555cda77c8ab5dc902094c52de3278f35a75ebc25f093a", + "sha256": "d432e67ffb96a5e742ab6d396bf52b41c4b960a5609871d4a477ad879d8a3c0", "size": 26071 } ], - "fileSha512": "db9b1cd3262dee37756a09b9064973589847caa8e53d31a9d142ea2701b1b28abd97838bb9a27068ba305dc8d04a45a1fcf079de54d607666996b3cc54f6b67c" + "fileSha256": "8a517f0f321567c047f1a1e20c35d7f8a2a083f32045a9d98f8ff14f0b5f350" } \ No newline at end of file diff --git a/specs/tracker-response.json b/specs/tracker-response.json index 5e60fc0..2a69ff5 100644 --- a/specs/tracker-response.json +++ b/specs/tracker-response.json @@ -1,12 +1,12 @@ { "peers": [ { - "peerId": "39d1e4c943bd75305dc9ab5e973284f1a4219a0e5c79f57f2b642c918a0bce59", + "peerId": "526b73151eaa0987c084c2fa85a8be0a4b913fbd4b28b165f5b2e62b1b075d3", "publicIp": "90.565.34.123", "publicPort": "12312" }, { - "peerId": "a71fbd3e12f648c4c9eaf81e0e9b0ec69cf3d9e8120472d0a5c7b1a46f5d3c8e", + "peerId": "a2f4c84fdb870c96ff7b93d4dcb8d91575d0598b6b9f8ecf5cc15f857c5a51b", "publicIp": "23.32.23.123", "publicPort": "8086" } From 8dbce6c4f61c78158221dc493afd637c72222917 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Fri, 25 Apr 2025 19:37:11 +0300 Subject: [PATCH 03/46] Implement Connection --- .gitignore | 1 + client/core/__init__.py | 0 client/core/common/__init__.py | 0 client/core/common/resource.py | 24 +++ client/core/p2p/__init__.py | 0 client/core/p2p/connection.py | 127 +++++++++++++++ client/core/p2p/connection_listener.py | 15 ++ client/core/p2p/message.py | 86 +++++++++++ client/core/p2p/resource_file.py | 24 +++ client/core/p2p/resource_manager.py | 204 +++++++++++++++++++++++++ client/core/tests/__init__.py | 0 client/core/tests/mocks.py | 33 ++++ client/core/tests/test_connection.py | 64 ++++++++ client/core/tests/test_message.py | 49 ++++++ client/poetry.lock | 99 ++++++++++++ client/pyproject.toml | 23 +++ specs/peer-announce.json | 2 +- specs/peer-message-exchange.md | 3 +- specs/tracker-response.json | 1 + 19 files changed, 753 insertions(+), 2 deletions(-) create mode 100644 client/core/__init__.py create mode 100644 client/core/common/__init__.py create mode 100644 client/core/common/resource.py create mode 100644 client/core/p2p/__init__.py create mode 100644 client/core/p2p/connection.py create mode 100644 client/core/p2p/connection_listener.py create mode 100644 client/core/p2p/message.py create mode 100644 client/core/p2p/resource_file.py create mode 100644 client/core/p2p/resource_manager.py create mode 100644 client/core/tests/__init__.py create mode 100644 client/core/tests/mocks.py create mode 100644 client/core/tests/test_connection.py create mode 100644 client/core/tests/test_message.py create mode 100644 client/poetry.lock create mode 100644 client/pyproject.toml diff --git a/.gitignore b/.gitignore index 1254eba..d107def 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .idea/ .venv/ venv/ +__pycache__/ \ No newline at end of file diff --git a/client/core/__init__.py b/client/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/core/common/__init__.py b/client/core/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/core/common/resource.py b/client/core/common/resource.py new file mode 100644 index 0000000..18faf7d --- /dev/null +++ b/client/core/common/resource.py @@ -0,0 +1,24 @@ +import datetime +from dataclasses import dataclass +import json +import hashlib + + +@dataclass +class Resource: + @dataclass + class Piece: + sha256: str + size_bytes: int + + tracker_ip: str + tracker_port: int + comment: str + creation_date: datetime.datetime + name: str + pieces: list[Piece] + + def get_info_hash(self) -> str: + resource_repr = f"{self.tracker_ip}{self.tracker_port}{self.comment}{self.creation_date.isoformat()}{self.name}{self.pieces}" + info_hash = hashlib.sha256(resource_repr.encode(encoding='utf-8')).hexdigest() + return info_hash diff --git a/client/core/p2p/__init__.py b/client/core/p2p/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/core/p2p/connection.py b/client/core/p2p/connection.py new file mode 100644 index 0000000..03608a2 --- /dev/null +++ b/client/core/p2p/connection.py @@ -0,0 +1,127 @@ +import asyncio + +from core.p2p.connection_listener import ConnectionListener +from core.p2p.message import Request, Piece, Handshake, Message, Bitfield +from core.common.resource import Resource +from core.tracker.models.peer_info import PeerInfo + + +class Connection: + """ + Represents a resource-related connection between two peers. + The class works with asyncio, therefore its methods must be called on a thread with running event loop + """ + + def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, resource: Resource): + self.reader = reader + self.writer = writer + self.listeners: list[ConnectionListener] = [] + self.resource = resource + + def add_listener(self, listener: ConnectionListener): + self.listeners.append(listener) + + def remove_listener(self, listener: ConnectionListener): + self.listeners.remove(listener) + + # Read big int from the saved reader + async def _read_int_big_endian(self, length: int) -> int: + return int.from_bytes(await self.reader.readexactly(length)) + + # Launch infinite loop to fetch messages from the reader and notify the listeners + async def _listen_on_reader(self): + try: + while True: + message_length = await self._read_int_big_endian(4) + message_type = await self._read_int_big_endian(1) + + if message_type == 1: + # Request message + + # Parse the message + piece_index = await self._read_int_big_endian(4) + piece_inner_offset = await self._read_int_big_endian(4) + block_length = await self._read_int_big_endian(4) + + request = Request(piece_index, piece_inner_offset, block_length) + + # Notify listeners + for listener in self.listeners: + await listener.on_request(request) + elif message_type == 2: + # Piece message + + # Parse the message + piece_index = await self._read_int_big_endian(4) + piece_inner_offset = await self._read_int_big_endian(4) + block_length = await self._read_int_big_endian(4) + + if block_length > 10 ** 6: + raise RuntimeError("The length of data exceeded 1 MB") + + # Retrieve the data block + data = await self.reader.readexactly(block_length) + + piece = Piece(piece_index, piece_inner_offset, block_length, data) + + # Notify listeners + for listener in self.listeners: + await listener.on_piece(piece) + elif message_type == 3: + # Bitfield message + + # Parse the message + bytes_parsed = await self.reader.readexactly( + len(self.resource.pieces) // 8 + bool(len(self.resource.pieces) % 8) + ) + + bitfield = Bitfield(bitfield=[False] * len(self.resource.pieces)) + for i in range(0, len(self.resource.pieces)): + has_piece_i = bool((bytes_parsed[i // 8] >> (7 - i % 8)) & 1) + bitfield.bitfield[i] = has_piece_i + + # Notify listeners + for listener in self.listeners: + await listener.on_bitfield(bitfield) + + except Exception as e: + # For now close the connection in case of any exception + for listener in self.listeners: + await listener.on_close(e) + finally: + self.writer.close() + await self.writer.wait_closed() + + async def send_message(self, message: Message): + self.writer.write(message.to_bytes()) + await self.writer.drain() + + async def listen(self): + loop = asyncio.get_running_loop() + loop.create_task(self._listen_on_reader()) + + async def close(self): + self.writer.write_eof() + self.writer.close() + await self.writer.wait_closed() + + +# Create the connection with some peer +async def establish_connection( + host_peer: PeerInfo, + receiver_peer: PeerInfo, + resource: Resource +) -> (asyncio.StreamReader, asyncio.StreamWriter): + reader, writer = await asyncio.open_connection(host_peer.public_ip, receiver_peer.public_port) + + try: + info_hash = resource.get_info_hash() + handshake = Handshake(host_peer.peer_id, info_hash) + writer.write(handshake.to_bytes()) + response = await reader.read(74) + assert response[0:11].decode() == 'TorrentInno' + assert response[11:43].hex() == info_hash + assert response[43:75].hex() == info_hash + finally: + writer.close() + await writer.wait_closed() diff --git a/client/core/p2p/connection_listener.py b/client/core/p2p/connection_listener.py new file mode 100644 index 0000000..4a2438b --- /dev/null +++ b/client/core/p2p/connection_listener.py @@ -0,0 +1,15 @@ +from core.p2p.message import Message, Request, Piece, Bitfield + + +class ConnectionListener: + async def on_request(self, request: Request) -> bytes: + pass + + async def on_piece(self, piece: Piece): + pass + + async def on_bitfield(self, bitfield: Bitfield): + pass + + def on_close(self, cause): + pass diff --git a/client/core/p2p/message.py b/client/core/p2p/message.py new file mode 100644 index 0000000..53300f2 --- /dev/null +++ b/client/core/p2p/message.py @@ -0,0 +1,86 @@ +import asyncio +from dataclasses import dataclass + + +class Message: + """ + A base class for messages that peers exchanges between each other + """ + + # Convert the message object into bytes according to the specs + def to_bytes(self) -> bytes: + pass + + +@dataclass +class Handshake(Message): + """ + A dataclass for Handshake message + """ + peer_id: str + info_hash: str + + def to_bytes(self) -> bytes: + return b'TorrentInno' + bytes.fromhex(self.peer_id) + bytes.fromhex(self.info_hash) + + +@dataclass +class Request(Message): + """ + A dataclass for Request (type 1) message + """ + piece_index: int + piece_inner_offset: int + block_length: int + + def to_bytes(self) -> bytes: + return ( + (13).to_bytes(length=4, byteorder='big') + + (1).to_bytes(length=1, byteorder='big') + + self.piece_index.to_bytes(4, byteorder='big') + + self.piece_inner_offset.to_bytes(4, byteorder='big') + + self.block_length.to_bytes(4, byteorder='big') + ) + + +@dataclass +class Piece(Message): + """ + A dataclass for Piece (type 2) message + """ + piece_index: int + piece_inner_offset: int + block_length: int + data: bytes + + def __post_init__(self): + assert len(self.data) == self.block_length + + def to_bytes(self) -> bytes: + return ( + (13 + len(self.data)).to_bytes(length=4, byteorder='big') + + (2).to_bytes(length=1, byteorder='big') + + self.piece_index.to_bytes(4, byteorder='big') + + self.piece_inner_offset.to_bytes(4, byteorder='big') + + self.block_length.to_bytes(4, byteorder='big') + + self.data + ) + + +@dataclass +class Bitfield(Message): + """ + A dataclass for Bitfield (type 3) message + """ + bitfield: list[bool] + + def to_bytes(self) -> bytes: + result: list[int] = [] + for i in range(0, len(self.bitfield), 8): + current_byte = 0 + for j in range(i, min(len(self.bitfield), i + 8)): + if self.bitfield[j]: + current_byte += 1 << (i + 7 - j) + result.append(current_byte) + + return (1 + len(result)).to_bytes(length=4) + (3).to_bytes(length=1) + bytes(result) diff --git a/client/core/p2p/resource_file.py b/client/core/p2p/resource_file.py new file mode 100644 index 0000000..1479d74 --- /dev/null +++ b/client/core/p2p/resource_file.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from core.common.resource import Resource + + +class ResourceFile: + def __init__(self, destination: Path, resource: Resource): + self.file = destination + self.resource = resource + + async def get_piece(self, index: int) -> bytes: + return await self.get_block(index, 0, self.resource.pieces[index].size_bytes) + + async def get_block(self, piece_index: int, piece_inner_offset: int, block_length: int) -> bytes: + pass + + async def save_block(self, piece_index: int, piece_inner_offset: int, data: bytes): + pass + + async def save_validated_piece(self, index: int, data: bytes): + pass + + async def prepare(self): + pass \ No newline at end of file diff --git a/client/core/p2p/resource_manager.py b/client/core/p2p/resource_manager.py new file mode 100644 index 0000000..a525d9e --- /dev/null +++ b/client/core/p2p/resource_manager.py @@ -0,0 +1,204 @@ +import asyncio +from pathlib import Path + +import random +from core.p2p.connection import Connection +from core.p2p.message import Handshake, Request, Piece, Bitfield +from core.p2p.resource_file import ResourceFile +from core.common.resource import Resource +from core.p2p.connection_listener import ConnectionListener +from enum import Enum + + +class ResourceManager(ConnectionListener): + class PieceStatus(Enum): + FREE = 1 # The piece is not in work + IN_PROGRESS = 2 # Waiting for reply from some peer + RECEIVED = 3 # The data has been fetched from network and now is saving on disk + SAVED = 4 # Piece is successfully saved on disk + + # Some new peer wants to connect to this peer + async def _handle_incoming_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + try: + response = await reader.readexactly(74) + assert response[0:11].decode() == 'TorrentInno' + assert response[43:75].hex() == self.info_hash + + peer_id: str = response[11:43].hex() + + # If we already have connection with this peer id -> abort the incoming connection + if peer_id in self.connections.keys(): + return + + # If everything is correct, then send the response handshake message + writer.write(Handshake(peer_id=self.host_peer_id, info_hash=self.info_hash).to_bytes()) + await writer.drain() + + # Create the connection object + connection = Connection(reader, writer, self.resource) + connection.add_listener(self._create_connection_listener(peer_id)) + self._add_peer(peer_id, connection) + finally: + writer.close() + await writer.wait_closed() + + def _create_connection_listener(self, peer_id: str) -> ConnectionListener: + class Listener(ConnectionListener): + def __init__(self, resource_manager: 'ResourceManager'): + self.resource_manager = resource_manager + + async def on_request(self, request: Request) -> bytes: + try: + data = await self.resource_manager.resourceFile.get_block( + request.piece_index, + request.piece_inner_offset, + request.block_length + ) + connection = self.resource_manager.connections[peer_id] + await connection.send_message( + Piece( + request.piece_index, + request.piece_inner_offset, + request.block_length, + data + ) + ) + except Exception as e: + # Can't get the requested data. Simply ignore the request + pass + + async def on_piece(self, piece: Piece): + # This peer is not anymore in charge on this piece + if self.resource_manager._peer_in_charge[piece.piece_index] != peer_id: + return + + self.resource_manager._piece_status[piece.piece_index] = ResourceManager.PieceStatus.RECEIVED + try: + await self.resource_manager.resourceFile.save_block( + piece.piece_index, + piece.piece_inner_offset, + piece.data + ) + except Exception as e: + # Can't save the block (too large or other error) + pass + + async def on_bitfield(self, bitfield: Bitfield): + self.resource_manager.bitfields[peer_id] = bitfield.bitfield + + async def on_close(self, cause): + # The connection with peer for some reason is closed + await self.resource_manager._remove_peer(peer_id) + + return Listener(self) + + def _add_peer(self, peer_id: str, connection: Connection): + self.connections[peer_id] = connection + self.bitfields[peer_id] = [False] * len(self.resource.pieces) + self._free_peers.add(peer_id) + + async def _remove_peer(self, peer_id: str): + try: + await self.connections[peer_id].close() + except Exception as e: + # Ignore any exception with closing (probably peer_id either is not in list or connection is already closed + pass + self.connections.pop(peer_id, None) + self.bitfields.pop(peer_id, None) + self._free_peers.remove(peer_id) + + # -----MAIN DOWNLOAD LOGIC BEGINS HERE----- + + async def _download_work(self, peer_id: str, piece_index: int): + connection = self.connections[peer_id] + self._peer_in_charge[piece_index] = peer_id + await connection.send_message( + Request( + piece_index, + 0, + self.resource.pieces[piece_index].size_bytes + ) + ) + await asyncio.sleep(60) # Sleep 1 minute + if self._piece_status[piece_index] == ResourceManager.PieceStatus.IN_PROGRESS: + # If after one minute, the piece is still in progress, + # then something is wrong with peer (slow download speed or smth) + self._peer_in_charge[piece_index] = '' # This peer is no more responsible for this piece + self._piece_status[piece_index] = ResourceManager.PieceStatus.FREE + + + async def _download_loop(self): + works = set() + while True: + # Find free pieces + free_pieces: list[int] = [] + for i, status in enumerate(self._piece_status): + if status == ResourceManager.PieceStatus.FREE: + free_pieces.append(i) + + # Shuffle the pieces + random.shuffle(free_pieces) + + # Try to find piece and peer that has this piece + for piece_index in free_pieces: + for peer_id in self._free_peers: + if self._peer_has_piece(peer_id, piece_index): # Peer has this piece -> run the work + task = asyncio.create_task(self._download_work(peer_id, piece_index)) + task.add_done_callback(works.discard) + works.add(asyncio.create_task(self._download_work)) + + await asyncio.sleep(0.2) + + # -----END OF DOWNLOAD LOGIC----- + + def _peer_has_piece(self, peer_id: str, piece_index: int) -> bool: + return self.bitfields[peer_id][piece_index] == True + + def __init__(self, host_peer_id: str, destination: Path, resource: Resource): + self.host_peer_id = host_peer_id + self.public_server: asyncio.Server | None = None + self.resourceFile = ResourceFile(destination, resource) + self.destination = destination + self.resource = resource + self.info_hash = resource.get_info_hash() + + # Hold the mapping peer_id <-> Connection + self.connections: dict[str, Connection] = dict() + + # peer_id <-> pieces (bitfield) this peer has + self.bitfields: dict[str, list[bool]] = dict() + + # These fields are needed for download + self._free_peers: set[str] = set() + self._piece_status: list[ResourceManager.PieceStatus] =\ + [ResourceManager.PieceStatus.FREE] * len(self.resource.pieces) + # Current peer that handles this piece + self._peer_in_charge: list[str] = [''] * len(self.resource.pieces) + self._download_task: asyncio.Task | None = None + + async def open_public_port(self) -> int: + # Start accepting peer connections on some random port + self.public_server = await asyncio.start_server( + lambda r, w: self._handle_incoming_connection(r, w), + host='0.0.0.0', + port=0 + ) + host, port = self.public_server.sockets[0].getsockname() + + # Return port on which connection has been opened + return port + + async def close_public_port(self): + # Close the public port and stop accepting new connections + self.public_server.close() # Close the socket + await self.public_server.wait_closed() + + async def start_download(self): + # Start downloading the resource + if self._download_task is None: + self._download_task = asyncio.create_task(self._download_loop()) + + async def stop_download(self): + # Stop downloading the resource + self._download_task.cancel() + self._download_task = None diff --git a/client/core/tests/__init__.py b/client/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/core/tests/mocks.py b/client/core/tests/mocks.py new file mode 100644 index 0000000..683f110 --- /dev/null +++ b/client/core/tests/mocks.py @@ -0,0 +1,33 @@ +import datetime + +from core.p2p.message import Request, Piece, Bitfield +from core.common.resource import Resource + +mock_resource = Resource( + tracker_ip="127.0.0.1", + tracker_port=8080, + comment="Test torrent for unit testing", + creation_date=datetime.datetime.now(), + name="sample_file.txt", + pieces=[ + Resource.Piece(sha256="a" * 64, size_bytes=512 * 1024), # 512 KB + Resource.Piece(sha256="b" * 64, size_bytes=256 * 1024), # 256 KB + ] +) + +mock_request = Request( + piece_index=1, + piece_inner_offset=100 * 200, + block_length=1337 +) + +mock_piece = Piece( + piece_index=0, + piece_inner_offset=400, + block_length=64, + data=b'a' * 64 +) + +mock_bitfield = Bitfield( + bitfield=[False, True] +) diff --git a/client/core/tests/test_connection.py b/client/core/tests/test_connection.py new file mode 100644 index 0000000..9f9480b --- /dev/null +++ b/client/core/tests/test_connection.py @@ -0,0 +1,64 @@ +import asyncio + +import pytest + +from core.p2p.connection import Connection +from core.p2p.connection_listener import ConnectionListener +from core.p2p.message import Piece, Request, Bitfield +from core.tests.mocks import mock_resource, mock_request, mock_piece, mock_bitfield +from core.common.resource import Resource + + +async def get_connections(resource: Resource) -> (Connection, Connection): + queue = asyncio.Queue() + + async def handle_client(reader, writer): + await queue.put((reader, writer)) + + server = await asyncio.start_server(handle_client, '127.0.0.1', 0) + host, port = server.sockets[0].getsockname() + client_reader, client_writer = await asyncio.open_connection(host, port) + server_reader, server_writer = await queue.get() + + client_connection = Connection(client_reader, client_writer, resource) + server_connection = Connection(server_reader, server_writer, resource) + return client_connection, server_connection + + +@pytest.mark.asyncio +async def test_connection(): + connections = await get_connections(mock_resource) + sender: Connection = connections[0] + receiver: Connection = connections[1] + + class SenderListener(ConnectionListener): + async def on_piece(self, piece: Piece): + print(piece) + assert piece == mock_piece + + async def on_bitfield(self, bitfield: Bitfield): + print(bitfield) + assert bitfield == mock_bitfield + + async def on_close(self, cause): + print(f"SenderListener; onClose: {cause}") + + class ReceiverListener(ConnectionListener): + async def on_request(self, request: Request) -> bytes: + print(request) + assert request == mock_request + return b'' + + async def on_close(self, cause): + print(f"ReceiverListener; onClose: {cause}") + + sender.add_listener(SenderListener()) + receiver.add_listener(ReceiverListener()) + + await receiver.listen() + await sender.listen() + + await sender.send_message(mock_request) + await receiver.send_message(mock_piece) + await receiver.send_message(mock_bitfield) + diff --git a/client/core/tests/test_message.py b/client/core/tests/test_message.py new file mode 100644 index 0000000..10d1009 --- /dev/null +++ b/client/core/tests/test_message.py @@ -0,0 +1,49 @@ +from core.p2p.message import Handshake, Request, Piece, Bitfield + + +def test_handshake_to_bytes(): + peer_id = 'aabbccddeeff00112233445566778899' + info_hash = '99887766554433221100ffeeddccbbaa' + handshake = Handshake(peer_id, info_hash) + expected = ( + b'TorrentInno' + + bytes.fromhex(peer_id) + + bytes.fromhex(info_hash) + ) + assert handshake.to_bytes() == expected + + +def test_request_to_bytes(): + request = Request(10, 1024, 500) + expected = ( + (13).to_bytes(4) + + (1).to_bytes(1) + + request.piece_index.to_bytes(4) + + request.piece_inner_offset.to_bytes(4) + + request.block_length.to_bytes(4) + ) + assert request.to_bytes() == expected + + +def test_piece_to_bytes(): + piece = Piece(10, 1024, 4, b'102b') + expected = ( + (17).to_bytes(4) + + (2).to_bytes(1) + + piece.piece_index.to_bytes(4, 'big') + + piece.piece_inner_offset.to_bytes(4, 'big') + + piece.block_length.to_bytes(4, 'big') + + piece.data + ) + assert piece.to_bytes() == expected + + +def test_bitfield_to_bytes(): + bitfield = Bitfield([True, True, False, False, True, True, True, False, True, False]) + expected = ( + (3).to_bytes(4) + + (3).to_bytes(1) + + bytes([206, 128]) + ) + + assert bitfield.to_bytes() == expected diff --git a/client/poetry.lock b/client/poetry.lock new file mode 100644 index 0000000..3830c2e --- /dev/null +++ b/client/poetry.lock @@ -0,0 +1,99 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "8.3.5" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820"}, + {file = "pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.26.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pytest_asyncio-0.26.0-py3-none-any.whl", hash = "sha256:7b51ed894f4fbea1340262bdae5135797ebbe21d8638978e35d31c6d19f72fb0"}, + {file = "pytest_asyncio-0.26.0.tar.gz", hash = "sha256:c4df2a697648241ff39e7f0e4a73050b03f123f760673956cf0d72a4990e312f"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.12" +content-hash = "2716dbb98578446506d51d3ab331fa4072d6053633a580763e121814c05658b6" diff --git a/client/pyproject.toml b/client/pyproject.toml new file mode 100644 index 0000000..f3d11a1 --- /dev/null +++ b/client/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "client" +version = "0.0.1" +description = "" +requires-python = ">=3.12" +dependencies = [ + "pytest-asyncio (>=0.26.0,<0.27.0)" +] + + +[tool.poetry] +package-mode = false + + +[tool.poetry.group.dev.dependencies] +pytest = "^8.3.5" + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.pytest.ini_options] +asyncio_default_fixture_loop_scope = "function" diff --git a/specs/peer-announce.json b/specs/peer-announce.json index bea4729..945c425 100644 --- a/specs/peer-announce.json +++ b/specs/peer-announce.json @@ -1,5 +1,5 @@ { - "peerId": "uQ5dKnR3FZcAVjY0eBHwGslM9tJpXxUNq7PavCLOgmy6z1nEkWYh8b2rTcfMVuDl", + "peerId": "6b8a2d7f30c9e8d9b3d5b7c61e0be67d48280f58ffefae3c8b8423fe7108ecb", "infoHash": "9a3f5d4c1e85a216afba9c9aeb62fa7d9a75b57c52cd945b7f58de8a0b9dcb4f", "publicIp": "10.908.23.123", "publicPort": "90833" diff --git a/specs/peer-message-exchange.md b/specs/peer-message-exchange.md index 1705d8f..6d6bf9c 100644 --- a/specs/peer-message-exchange.md +++ b/specs/peer-message-exchange.md @@ -35,12 +35,13 @@ If the handshake is successful, then both peers create and maintain a separate c Each message has the following format: `[body-length (4 bytes)][message-body]`. Where `body-length` is the length of the `[message-body]` (in bytes). Further, only `[message-body]` will be discussed. Each `[message-body]` has the following format: `[message-type (1 byte)][message-data]`. `[message-type]` is a number (`0x01`, for example) -Currently, only two types of messages are supported: +Currently, 4 message types are supported 1) 'Request': The `[message-data]` has format: `[piece-index (4 bytes)][piece-inner-offset (4 bytes)][block-length (4 bytes)]`. This message indicates that the peer wants to fetch the `[block-length]` bytes from the piece with index `[piece-index]`, with inner offset within the piece of length `[piece-inner-offset]` bytes. 2) 'Piece': The `[message-data]` has format: `[piece-index (4 bytes)][piece-inner-offset (4 bytes)][block-length (4 bytes)]data`. The first three fields has the same meaning as in the 'Request' message. The `data` contains the requested part of the file and it must have the length of `block-length` bytes. +3) 'Bitfield': The `[message-data]` has format `[bitfield]`. The first byte corresponds to whether the sender has pieces 0-7 from high bit to low bit. The next byte corresponds to whether the sender has pieces 8-15 etc. Spare bits at the end are set to zero. Peers exchange the `Bitfield` message with each other to indicate the updates in the chunks ownership. *Example:* The full message to request 1024 bytes with offset 384 bytes offset within the piece 19 looks like this: diff --git a/specs/tracker-response.json b/specs/tracker-response.json index 2a69ff5..23ebb15 100644 --- a/specs/tracker-response.json +++ b/specs/tracker-response.json @@ -1,4 +1,5 @@ { + "infoHash": "6b8a2d7f30c9e8d9b3d5b7c61e0be67d48280f58ffefae3c8b8423fe7108ecb", "peers": [ { "peerId": "526b73151eaa0987c084c2fa85a8be0a4b913fbd4b28b165f5b2e62b1b075d3", From 35d3b5a8a719122b6c33cbded1d3ece41fe2c88b Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Sat, 26 Apr 2025 01:23:20 +0300 Subject: [PATCH 04/46] Add ResourceFile tests and fix Connection tests --- client/core/common/peer_info.py | 8 +++ client/core/common/resource.py | 7 ++- client/core/p2p/connection.py | 23 +++---- client/core/p2p/resource_file.py | 82 ++++++++++++++++++++++--- client/core/p2p/resource_manager.py | 9 +-- client/core/tests/mocks.py | 7 ++- client/core/tests/test_connection.py | 11 +++- client/core/tests/test_resource_file.py | 40 ++++++++++++ client/poetry.lock | 14 ++++- client/pyproject.toml | 3 +- 10 files changed, 172 insertions(+), 32 deletions(-) create mode 100644 client/core/common/peer_info.py create mode 100644 client/core/tests/test_resource_file.py diff --git a/client/core/common/peer_info.py b/client/core/common/peer_info.py new file mode 100644 index 0000000..4c0839c --- /dev/null +++ b/client/core/common/peer_info.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass +class PeerInfo: + public_ip: str + public_port: int + peer_id: str \ No newline at end of file diff --git a/client/core/common/resource.py b/client/core/common/resource.py index 18faf7d..15e306a 100644 --- a/client/core/common/resource.py +++ b/client/core/common/resource.py @@ -1,6 +1,5 @@ import datetime from dataclasses import dataclass -import json import hashlib @@ -19,6 +18,10 @@ class Piece: pieces: list[Piece] def get_info_hash(self) -> str: - resource_repr = f"{self.tracker_ip}{self.tracker_port}{self.comment}{self.creation_date.isoformat()}{self.name}{self.pieces}" + resource_repr = f"{self.tracker_ip};{self.tracker_port};{self.comment};{self.creation_date.isoformat()};{self.name};" + path_part = ','.join(f'Path(sha256={piece.sha256},size_bytes={piece.size_bytes})' for piece in self.pieces) + resource_repr += path_part + + print(resource_repr) info_hash = hashlib.sha256(resource_repr.encode(encoding='utf-8')).hexdigest() return info_hash diff --git a/client/core/p2p/connection.py b/client/core/p2p/connection.py index 03608a2..ff522cf 100644 --- a/client/core/p2p/connection.py +++ b/client/core/p2p/connection.py @@ -1,9 +1,9 @@ import asyncio +from core.common.peer_info import PeerInfo from core.p2p.connection_listener import ConnectionListener from core.p2p.message import Request, Piece, Handshake, Message, Bitfield from core.common.resource import Resource -from core.tracker.models.peer_info import PeerInfo class Connection: @@ -18,6 +18,8 @@ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, r self.listeners: list[ConnectionListener] = [] self.resource = resource + self._listen_on_reader_task: asyncio.Task | None = None + def add_listener(self, listener: ConnectionListener): self.listeners.append(listener) @@ -46,8 +48,7 @@ async def _listen_on_reader(self): request = Request(piece_index, piece_inner_offset, block_length) # Notify listeners - for listener in self.listeners: - await listener.on_request(request) + await asyncio.gather(*(listener.on_request(request) for listener in self.listeners)) elif message_type == 2: # Piece message @@ -65,8 +66,7 @@ async def _listen_on_reader(self): piece = Piece(piece_index, piece_inner_offset, block_length, data) # Notify listeners - for listener in self.listeners: - await listener.on_piece(piece) + await asyncio.gather(*(listener.on_piece(piece) for listener in self.listeners)) elif message_type == 3: # Bitfield message @@ -81,8 +81,7 @@ async def _listen_on_reader(self): bitfield.bitfield[i] = has_piece_i # Notify listeners - for listener in self.listeners: - await listener.on_bitfield(bitfield) + await asyncio.gather(*(listener.on_bitfield(bitfield) for listener in self.listeners)) except Exception as e: # For now close the connection in case of any exception @@ -98,12 +97,14 @@ async def send_message(self, message: Message): async def listen(self): loop = asyncio.get_running_loop() - loop.create_task(self._listen_on_reader()) + self._listen_on_reader_task = loop.create_task(self._listen_on_reader()) async def close(self): - self.writer.write_eof() - self.writer.close() - await self.writer.wait_closed() + if self._listen_on_reader_task is not None: + self._listen_on_reader_task.cancel() + self._listen_on_reader_task = None + self.writer.close() + await self.writer.wait_closed() # Create the connection with some peer diff --git a/client/core/p2p/resource_file.py b/client/core/p2p/resource_file.py index 1479d74..1efc566 100644 --- a/client/core/p2p/resource_file.py +++ b/client/core/p2p/resource_file.py @@ -1,24 +1,90 @@ +import asyncio +from itertools import accumulate from pathlib import Path +import aiofiles +import aiofiles.os + from core.common.resource import Resource class ResourceFile: - def __init__(self, destination: Path, resource: Resource): - self.file = destination + """ + Class that represents the destination of resource. The main purpose of this class is to hide + the complexities of managing the "hidden file" and the aiofiles-related operations. Everything else + (checking and validating pieces, for example) is the caller's responsibility. + + There basically two operating modes: + 1) The destination file exists. Then this file will be used in all read operations. The write operations + will raise RuntimeException. + 2) The destination file does not exist. Then the auxiliary downloading_destination file will be used. + This file is created with the size of the initial destination file. When the client decides that all pieces + are written, then it calls the accept_download and the file gets renamed to the original destination name. + """ + + def __init__(self, destination: Path, resource: Resource, fresh_install=True): + self.destination = destination self.resource = resource + self.downloading_destination = self.get_downloading_destination() + self.lock = asyncio.Lock() + + if fresh_install: + destination.unlink(missing_ok=True) + self.downloading_destination.unlink(missing_ok=True) + + self.offsets: list[int] = [0] + list(accumulate(piece.size_bytes for piece in resource.pieces)) + + def get_downloading_destination(self) -> Path: + return self.destination.parent.joinpath(f'.torrentinno-{self.destination.name}') + + def _calculate_offset(self, piece_index: int, piece_inner_offset: int) -> int: + return self.offsets[piece_index] + piece_inner_offset + + async def _create_downloading_destination(self): + async with aiofiles.open(self.downloading_destination, mode='wb') as f: + for piece in self.resource.pieces: + await f.write(bytes([0] * piece.size_bytes)) + + async def _ensure_downloading_destination(self): + async with self.lock: + if not self.downloading_destination.exists(): + await self._create_downloading_destination() async def get_piece(self, index: int) -> bytes: return await self.get_block(index, 0, self.resource.pieces[index].size_bytes) async def get_block(self, piece_index: int, piece_inner_offset: int, block_length: int) -> bytes: - pass + offset = self._calculate_offset(piece_index, piece_inner_offset) + if offset + block_length > self.offsets[-1]: + raise RuntimeError("The requested read portion does not fit the file") + + take_from: Path + if self.destination.exists(): + take_from = self.destination + else: + await self._ensure_downloading_destination() + take_from = self.downloading_destination + + async with aiofiles.open(take_from, mode='rb') as f: + await f.seek(offset) + return await f.read(block_length) async def save_block(self, piece_index: int, piece_inner_offset: int, data: bytes): - pass + offset = self._calculate_offset(piece_index, piece_inner_offset) + if offset + len(data) > self.offsets[-1]: + raise RuntimeError("The write portion overflows the file") + + if self.destination.exists(): + raise RuntimeError("File is already downloaded") + + await self._ensure_downloading_destination() + + async with aiofiles.open(self.downloading_destination, mode='r+b') as f: + await f.seek(offset) + await f.write(data) - async def save_validated_piece(self, index: int, data: bytes): - pass + async def save_validated_piece(self, piece_index: int, data: bytes): + await self.save_block(piece_index, 0, data) - async def prepare(self): - pass \ No newline at end of file + async def accept_download(self): + await aiofiles.os.rename(self.downloading_destination, self.destination) diff --git a/client/core/p2p/resource_manager.py b/client/core/p2p/resource_manager.py index a525d9e..fbd8114 100644 --- a/client/core/p2p/resource_manager.py +++ b/client/core/p2p/resource_manager.py @@ -119,14 +119,13 @@ async def _download_work(self, peer_id: str, piece_index: int): self.resource.pieces[piece_index].size_bytes ) ) - await asyncio.sleep(60) # Sleep 1 minute + await asyncio.sleep(60) # Sleep 1 minute if self._piece_status[piece_index] == ResourceManager.PieceStatus.IN_PROGRESS: # If after one minute, the piece is still in progress, # then something is wrong with peer (slow download speed or smth) - self._peer_in_charge[piece_index] = '' # This peer is no more responsible for this piece + self._peer_in_charge[piece_index] = '' # This peer is no more responsible for this piece self._piece_status[piece_index] = ResourceManager.PieceStatus.FREE - async def _download_loop(self): works = set() while True: @@ -170,12 +169,14 @@ def __init__(self, host_peer_id: str, destination: Path, resource: Resource): # These fields are needed for download self._free_peers: set[str] = set() - self._piece_status: list[ResourceManager.PieceStatus] =\ + self._piece_status: list[ResourceManager.PieceStatus] = \ [ResourceManager.PieceStatus.FREE] * len(self.resource.pieces) # Current peer that handles this piece self._peer_in_charge: list[str] = [''] * len(self.resource.pieces) self._download_task: asyncio.Task | None = None + # PUBLIC METHODS: + async def open_public_port(self) -> int: # Start accepting peer connections on some random port self.public_server = await asyncio.start_server( diff --git a/client/core/tests/mocks.py b/client/core/tests/mocks.py index 683f110..f4eeabf 100644 --- a/client/core/tests/mocks.py +++ b/client/core/tests/mocks.py @@ -7,11 +7,12 @@ tracker_ip="127.0.0.1", tracker_port=8080, comment="Test torrent for unit testing", - creation_date=datetime.datetime.now(), + creation_date=datetime.datetime(2025, 4, 26, 15, 30, 0), name="sample_file.txt", pieces=[ - Resource.Piece(sha256="a" * 64, size_bytes=512 * 1024), # 512 KB - Resource.Piece(sha256="b" * 64, size_bytes=256 * 1024), # 256 KB + Resource.Piece(sha256="a" * 64, size_bytes=512), + Resource.Piece(sha256="b" * 64, size_bytes=1500), + Resource.Piece(sha256="c" * 64, size_bytes=768) ] ) diff --git a/client/core/tests/test_connection.py b/client/core/tests/test_connection.py index 9f9480b..d8635f0 100644 --- a/client/core/tests/test_connection.py +++ b/client/core/tests/test_connection.py @@ -27,6 +27,8 @@ async def handle_client(reader, writer): @pytest.mark.asyncio async def test_connection(): + print() + connections = await get_connections(mock_resource) sender: Connection = connections[0] receiver: Connection = connections[1] @@ -52,8 +54,10 @@ async def on_request(self, request: Request) -> bytes: async def on_close(self, cause): print(f"ReceiverListener; onClose: {cause}") - sender.add_listener(SenderListener()) - receiver.add_listener(ReceiverListener()) + sender_listener = SenderListener() + receiver_listener = ReceiverListener() + sender.add_listener(sender_listener) + receiver.add_listener(receiver_listener) await receiver.listen() await sender.listen() @@ -62,3 +66,6 @@ async def on_close(self, cause): await receiver.send_message(mock_piece) await receiver.send_message(mock_bitfield) + await sender.close() + await receiver.close() + diff --git a/client/core/tests/test_resource_file.py b/client/core/tests/test_resource_file.py new file mode 100644 index 0000000..33116c2 --- /dev/null +++ b/client/core/tests/test_resource_file.py @@ -0,0 +1,40 @@ +import aiofiles +import pytest +import random +import asyncio +from pathlib import Path +from core.p2p.resource_file import ResourceFile +from core.tests.mocks import mock_resource + + +@pytest.mark.asyncio +async def test_resource_file(tmp_path): + data: list[bytes] = [] + for piece in mock_resource.pieces: + data.append(bytes(random.randint(0, 255) for _ in range(piece.size_bytes))) + + destination = tmp_path / 'test_file' + + resource_file = ResourceFile(destination, mock_resource) + + async def write_piece(piece_index: int, piece_data: bytes): + await resource_file.save_validated_piece(piece_index, piece_data) + + # Write all the pieces concurrently + await asyncio.gather( + *(write_piece(i, piece_data) for i, piece_data in enumerate(data)) + ) + + # Accept the download (simulate renaming) + await resource_file.accept_download() + + # Ensure the file content is correctly saved and pieces are correctly fetched by resource_file + async with aiofiles.open(destination, mode='rb') as f: + for piece_index, piece_data in enumerate(data): + on_file = await f.read(len(piece_data)) + from_resource_file = await resource_file.get_piece(piece_index) + assert on_file == piece_data + assert piece_data == from_resource_file + + # Ensure the downloading resource is moved and no longer exists + assert not resource_file.get_downloading_destination().exists() diff --git a/client/poetry.lock b/client/poetry.lock index 3830c2e..d505462 100644 --- a/client/poetry.lock +++ b/client/poetry.lock @@ -1,5 +1,17 @@ # This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +[[package]] +name = "aiofiles" +version = "24.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + [[package]] name = "colorama" version = "0.4.6" @@ -96,4 +108,4 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [metadata] lock-version = "2.1" python-versions = ">=3.12" -content-hash = "2716dbb98578446506d51d3ab331fa4072d6053633a580763e121814c05658b6" +content-hash = "b03d58fff95b6ca39996dfcb5c4ff6025e6995f2eb519d8f9640cbc256b44ab5" diff --git a/client/pyproject.toml b/client/pyproject.toml index f3d11a1..acb88c4 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -4,7 +4,8 @@ version = "0.0.1" description = "" requires-python = ">=3.12" dependencies = [ - "pytest-asyncio (>=0.26.0,<0.27.0)" + "pytest-asyncio (>=0.26.0,<0.27.0)", + "aiofiles (>=24.1.0,<25.0.0)" ] From d9cda5917ef7e3aa85013ec295e32b3677053235 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Sat, 26 Apr 2025 02:20:08 +0300 Subject: [PATCH 05/46] Add logging to resource manager --- client/core/p2p/connection_listener.py | 2 +- client/core/p2p/resource_manager.py | 101 ++++++++++++++++++++----- 2 files changed, 82 insertions(+), 21 deletions(-) diff --git a/client/core/p2p/connection_listener.py b/client/core/p2p/connection_listener.py index 4a2438b..c666ce4 100644 --- a/client/core/p2p/connection_listener.py +++ b/client/core/p2p/connection_listener.py @@ -2,7 +2,7 @@ class ConnectionListener: - async def on_request(self, request: Request) -> bytes: + async def on_request(self, request: Request): pass async def on_piece(self, piece: Piece): diff --git a/client/core/p2p/resource_manager.py b/client/core/p2p/resource_manager.py index fbd8114..09b2525 100644 --- a/client/core/p2p/resource_manager.py +++ b/client/core/p2p/resource_manager.py @@ -1,4 +1,5 @@ import asyncio +import hashlib from pathlib import Path import random @@ -8,6 +9,7 @@ from core.common.resource import Resource from core.p2p.connection_listener import ConnectionListener from enum import Enum +import logging class ResourceManager(ConnectionListener): @@ -17,8 +19,15 @@ class PieceStatus(Enum): RECEIVED = 3 # The data has been fetched from network and now is saving on disk SAVED = 4 # Piece is successfully saved on disk + async def _send_bitfield(self, connection: Connection): + stored_pieces = list( + piece_status == ResourceManager.PieceStatus.SAVED for piece_status in self._piece_status + ) + await connection.send_message(Bitfield(stored_pieces)) + # Some new peer wants to connect to this peer async def _handle_incoming_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + host, port = writer.get_extra_info('peername') try: response = await reader.readexactly(74) assert response[0:11].decode() == 'TorrentInno' @@ -38,6 +47,13 @@ async def _handle_incoming_connection(self, reader: asyncio.StreamReader, writer connection = Connection(reader, writer, self.resource) connection.add_listener(self._create_connection_listener(peer_id)) self._add_peer(peer_id, connection) + + # Send the message about the stored pieces + await self._send_bitfield(connection) + + logging.info(f"Establish connection with {peer_id} on port {port}") + except Exception as e: + logging.warning(f"Failed to handle incoming connection with {host}") finally: writer.close() await writer.wait_closed() @@ -47,9 +63,10 @@ class Listener(ConnectionListener): def __init__(self, resource_manager: 'ResourceManager'): self.resource_manager = resource_manager - async def on_request(self, request: Request) -> bytes: + async def on_request(self, request: Request): + logging.debug(f"Request message from {peer_id}: {request}") try: - data = await self.resource_manager.resourceFile.get_block( + data = await self.resource_manager.resource_file.get_block( request.piece_index, request.piece_inner_offset, request.block_length @@ -63,31 +80,50 @@ async def on_request(self, request: Request) -> bytes: data ) ) - except Exception as e: - # Can't get the requested data. Simply ignore the request + logging.info(f"Send piece {request.piece_index} on Request message for peer {peer_id}") + except Exception: + logging.exception(f"Exception on request message from peer {peer_id}") pass async def on_piece(self, piece: Piece): + logging.debug(f"Piece message from {peer_id}: {piece}") # This peer is not anymore in charge on this piece if self.resource_manager._peer_in_charge[piece.piece_index] != peer_id: + logging.info(f"Discard piece {piece.piece_index} from {peer_id} as not in charge") return self.resource_manager._piece_status[piece.piece_index] = ResourceManager.PieceStatus.RECEIVED try: - await self.resource_manager.resourceFile.save_block( + # Check that the received piece matches the hash + assert hashlib.sha256(piece.data).hexdigest() == self.resource_manager.resource.pieces[ + piece.piece_index].sha256 + + await self.resource_manager.resource_file.save_block( piece.piece_index, piece.piece_inner_offset, piece.data ) - except Exception as e: - # Can't save the block (too large or other error) + + # If the piece is saved, then broadcast the bitfield to all connections and change the status + self.resource_manager._piece_status[piece.piece_index] = ResourceManager.PieceStatus.SAVED + await asyncio.gather( + *(self.resource_manager._send_bitfield(connection) + for connection in self.resource_manager.connections.values()) + ) + logging.info(f"Save piece {piece.piece_index} from {peer_id}") + except Exception: + logging.exception(f"Exception on piece message from peer {peer_id}") + self.resource_manager._piece_status[piece.piece_index] = ResourceManager.PieceStatus.FREE pass async def on_bitfield(self, bitfield: Bitfield): + logging.debug(f"Bitfield message from {peer_id}: {bitfield}") self.resource_manager.bitfields[peer_id] = bitfield.bitfield + logging.info(f"Bitfield from {peer_id}") async def on_close(self, cause): # The connection with peer for some reason is closed + logging.info(f"The connection with {peer_id} is closed") await self.resource_manager._remove_peer(peer_id) return Listener(self) @@ -111,7 +147,6 @@ async def _remove_peer(self, peer_id: str): async def _download_work(self, peer_id: str, piece_index: int): connection = self.connections[peer_id] - self._peer_in_charge[piece_index] = peer_id await connection.send_message( Request( piece_index, @@ -142,6 +177,10 @@ async def _download_loop(self): for piece_index in free_pieces: for peer_id in self._free_peers: if self._peer_has_piece(peer_id, piece_index): # Peer has this piece -> run the work + # Update the status and related peer + self._piece_status[piece_index] = ResourceManager.PieceStatus.IN_PROGRESS + self._peer_in_charge[piece_index] = peer_id + task = asyncio.create_task(self._download_work(peer_id, piece_index)) task.add_done_callback(works.discard) works.add(asyncio.create_task(self._download_work)) @@ -153,46 +192,68 @@ async def _download_loop(self): def _peer_has_piece(self, peer_id: str, piece_index: int) -> bool: return self.bitfields[peer_id][piece_index] == True - def __init__(self, host_peer_id: str, destination: Path, resource: Resource): + async def _run_server_task(self, server: asyncio.Server): + async with server: + await server.serve_forever() + + def __init__( + self, + host_peer_id: str, + destination: Path, + resource: Resource, + has_file + ): self.host_peer_id = host_peer_id - self.public_server: asyncio.Server | None = None - self.resourceFile = ResourceFile(destination, resource) self.destination = destination self.resource = resource + self.has_file = has_file + + self.resource_file = ResourceFile(destination, resource, fresh_install=False) self.info_hash = resource.get_info_hash() - # Hold the mapping peer_id <-> Connection + # dict peer_id <-> Connection self.connections: dict[str, Connection] = dict() - # peer_id <-> pieces (bitfield) this peer has + # dict peer_id <-> pieces (bitfield) this peer has self.bitfields: dict[str, list[bool]] = dict() + if has_file and not destination.exists(): + raise RuntimeError("The caller does not have the file") + # These fields are needed for download self._free_peers: set[str] = set() + + # Fill the initial status of the pieces + # If the caller passes True to the has_file, then we assume that all pieces are downloaded + _default_piece_status = ResourceManager.PieceStatus.SAVED if has_file else ResourceManager.PieceStatus.FREE self._piece_status: list[ResourceManager.PieceStatus] = \ - [ResourceManager.PieceStatus.FREE] * len(self.resource.pieces) + [ResourceManager.PieceStatus.SAVED] * len(self.resource.pieces) + # Current peer that handles this piece self._peer_in_charge: list[str] = [''] * len(self.resource.pieces) + + # Various asyncio background tasks self._download_task: asyncio.Task | None = None + self._server_task: asyncio.Task | None = None # PUBLIC METHODS: async def open_public_port(self) -> int: # Start accepting peer connections on some random port - self.public_server = await asyncio.start_server( + public_server = await asyncio.start_server( lambda r, w: self._handle_incoming_connection(r, w), host='0.0.0.0', port=0 ) - host, port = self.public_server.sockets[0].getsockname() - + host, port = public_server.sockets[0].getsockname() + self._server_task = asyncio.create_task(self._run_server_task(public_server)) # Return port on which connection has been opened return port async def close_public_port(self): - # Close the public port and stop accepting new connections - self.public_server.close() # Close the socket - await self.public_server.wait_closed() + # Close the server_task connection + if self._server_task is not None: + self._server_task.cancel() async def start_download(self): # Start downloading the resource From da8189307c9e611fe82e765a2b94728f2845ac07 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Sat, 26 Apr 2025 16:11:02 +0300 Subject: [PATCH 06/46] Make test for ResourceManager --- client/core/common/resource.py | 1 - client/core/p2p/connection.py | 51 +++-- client/core/p2p/message.py | 2 +- client/core/p2p/resource_manager.py | 243 ++++++++++++++------- client/core/tests/resource_manager_test.py | 110 ++++++++++ client/core/tests/test_message.py | 2 +- 6 files changed, 307 insertions(+), 102 deletions(-) create mode 100644 client/core/tests/resource_manager_test.py diff --git a/client/core/common/resource.py b/client/core/common/resource.py index 15e306a..261746e 100644 --- a/client/core/common/resource.py +++ b/client/core/common/resource.py @@ -22,6 +22,5 @@ def get_info_hash(self) -> str: path_part = ','.join(f'Path(sha256={piece.sha256},size_bytes={piece.size_bytes})' for piece in self.pieces) resource_repr += path_part - print(resource_repr) info_hash = hashlib.sha256(resource_repr.encode(encoding='utf-8')).hexdigest() return info_hash diff --git a/client/core/p2p/connection.py b/client/core/p2p/connection.py index ff522cf..27a79c7 100644 --- a/client/core/p2p/connection.py +++ b/client/core/p2p/connection.py @@ -48,7 +48,10 @@ async def _listen_on_reader(self): request = Request(piece_index, piece_inner_offset, block_length) # Notify listeners - await asyncio.gather(*(listener.on_request(request) for listener in self.listeners)) + await asyncio.gather( + *(listener.on_request(request) for listener in self.listeners), + return_exceptions=True + ) elif message_type == 2: # Piece message @@ -65,8 +68,11 @@ async def _listen_on_reader(self): piece = Piece(piece_index, piece_inner_offset, block_length, data) - # Notify listeners - await asyncio.gather(*(listener.on_piece(piece) for listener in self.listeners)) + # Notify the listeners + await asyncio.gather( + *(listener.on_piece(piece) for listener in self.listeners), + return_exceptions=True + ) elif message_type == 3: # Bitfield message @@ -80,13 +86,18 @@ async def _listen_on_reader(self): has_piece_i = bool((bytes_parsed[i // 8] >> (7 - i % 8)) & 1) bitfield.bitfield[i] = has_piece_i - # Notify listeners - await asyncio.gather(*(listener.on_bitfield(bitfield) for listener in self.listeners)) + # Notify the listeners + await asyncio.gather( + *(listener.on_bitfield(bitfield) for listener in self.listeners), + return_exceptions=True + ) except Exception as e: # For now close the connection in case of any exception - for listener in self.listeners: - await listener.on_close(e) + await asyncio.gather( + *(listener.on_close(e) for listener in self.listeners), + return_exceptions=True + ) finally: self.writer.close() await self.writer.wait_closed() @@ -96,8 +107,9 @@ async def send_message(self, message: Message): await self.writer.drain() async def listen(self): - loop = asyncio.get_running_loop() - self._listen_on_reader_task = loop.create_task(self._listen_on_reader()) + if self._listen_on_reader_task is None: + loop = asyncio.get_running_loop() + self._listen_on_reader_task = loop.create_task(self._listen_on_reader()) async def close(self): if self._listen_on_reader_task is not None: @@ -109,20 +121,23 @@ async def close(self): # Create the connection with some peer async def establish_connection( - host_peer: PeerInfo, - receiver_peer: PeerInfo, + host_peer_id: str, + destination_peer: PeerInfo, resource: Resource -) -> (asyncio.StreamReader, asyncio.StreamWriter): - reader, writer = await asyncio.open_connection(host_peer.public_ip, receiver_peer.public_port) +) -> Connection: + reader, writer = await asyncio.open_connection(destination_peer.public_ip, destination_peer.public_port) + info_hash = resource.get_info_hash() + handshake = Handshake(host_peer_id, info_hash) try: - info_hash = resource.get_info_hash() - handshake = Handshake(host_peer.peer_id, info_hash) writer.write(handshake.to_bytes()) - response = await reader.read(74) + response = await reader.readexactly(75) assert response[0:11].decode() == 'TorrentInno' - assert response[11:43].hex() == info_hash + assert response[11:43].hex() == destination_peer.peer_id assert response[43:75].hex() == info_hash - finally: + except Exception: writer.close() await writer.wait_closed() + raise # re-raise the error + + return Connection(reader, writer, resource) \ No newline at end of file diff --git a/client/core/p2p/message.py b/client/core/p2p/message.py index 53300f2..e97b1fe 100644 --- a/client/core/p2p/message.py +++ b/client/core/p2p/message.py @@ -21,7 +21,7 @@ class Handshake(Message): info_hash: str def to_bytes(self) -> bytes: - return b'TorrentInno' + bytes.fromhex(self.peer_id) + bytes.fromhex(self.info_hash) + return "TorrentInno".encode() + bytes.fromhex(self.peer_id) + bytes.fromhex(self.info_hash) @dataclass diff --git a/client/core/p2p/resource_manager.py b/client/core/p2p/resource_manager.py index 09b2525..516262a 100644 --- a/client/core/p2p/resource_manager.py +++ b/client/core/p2p/resource_manager.py @@ -3,7 +3,9 @@ from pathlib import Path import random -from core.p2p.connection import Connection + +from core.common.peer_info import PeerInfo +from core.p2p.connection import Connection, establish_connection from core.p2p.message import Handshake, Request, Piece, Bitfield from core.p2p.resource_file import ResourceFile from core.common.resource import Resource @@ -12,31 +14,87 @@ import logging -class ResourceManager(ConnectionListener): +class ResourceManager: class PieceStatus(Enum): FREE = 1 # The piece is not in work IN_PROGRESS = 2 # Waiting for reply from some peer RECEIVED = 3 # The data has been fetched from network and now is saving on disk SAVED = 4 # Piece is successfully saved on disk + def __init__( + self, + host_peer_id: str, + destination: Path, + resource: Resource, + has_file + ): + self.host_peer_id = host_peer_id + self.destination = destination + self.resource = resource + self.has_file = has_file + + self.resource_file = ResourceFile(destination, resource, fresh_install=False) + self.info_hash = resource.get_info_hash() + + # If the peer can give file pieces + self.share_file = False + + # dict peer_id <-> Connection + self.connections: dict[str, Connection] = dict() + + # dict peer_id <-> pieces (bitfield) this peer has + self.bitfields: dict[str, list[bool]] = dict() + + if has_file and not destination.exists(): + raise RuntimeError("The caller does not have the file but has_file=True") + + # TODO: add normal restoring procedure + # For now, simply delete the destination and begin from zero downloaded file + if not has_file: + destination.unlink(missing_ok=True) + + # List of currently not working peers + self._free_peers: set[str] = set() + + # Fill the initial status of the pieces + # If the caller passes True to the has_file, then we assume that all pieces are downloaded + _default_piece_status = ResourceManager.PieceStatus.SAVED if has_file else ResourceManager.PieceStatus.FREE + self.piece_status: list[ResourceManager.PieceStatus] = \ + [_default_piece_status] * len(self.resource.pieces) + + # Current peer that handles this piece + self._peer_in_charge: list[str] = [''] * len(self.resource.pieces) + + # Various asyncio background tasks + self._download_task: asyncio.Task | None = None + self._server_task: asyncio.Task | None = None + + def _log(self, msg: str): + return f"[ResourceManager peer_id={self.host_peer_id[:6]} info_hash={self.info_hash[:6]}] {msg}" + async def _send_bitfield(self, connection: Connection): stored_pieces = list( - piece_status == ResourceManager.PieceStatus.SAVED for piece_status in self._piece_status + piece_status == ResourceManager.PieceStatus.SAVED for piece_status in self.piece_status ) await connection.send_message(Bitfield(stored_pieces)) # Some new peer wants to connect to this peer async def _handle_incoming_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): host, port = writer.get_extra_info('peername') + logging.info(self._log(f"{host}:{port} is trying to connect")) try: - response = await reader.readexactly(74) + response = await reader.readexactly(75) assert response[0:11].decode() == 'TorrentInno' - assert response[43:75].hex() == self.info_hash + info_hash = response[43:75].hex() + assert info_hash == self.info_hash peer_id: str = response[11:43].hex() + if self.host_peer_id < peer_id: + raise RuntimeError(f"Peer {peer_id} has greater id and is trying to establish connection") + # If we already have connection with this peer id -> abort the incoming connection - if peer_id in self.connections.keys(): + if peer_id in self.connections: return # If everything is correct, then send the response handshake message @@ -45,16 +103,11 @@ async def _handle_incoming_connection(self, reader: asyncio.StreamReader, writer # Create the connection object connection = Connection(reader, writer, self.resource) - connection.add_listener(self._create_connection_listener(peer_id)) - self._add_peer(peer_id, connection) + await self._add_peer(peer_id, connection) - # Send the message about the stored pieces - await self._send_bitfield(connection) - - logging.info(f"Establish connection with {peer_id} on port {port}") + logging.info(self._log(f"Establish connection with {peer_id[:6]}")) except Exception as e: - logging.warning(f"Failed to handle incoming connection with {host}") - finally: + logging.exception(self._log(f"Failed to handle incoming connection with {host}")) writer.close() await writer.wait_closed() @@ -64,7 +117,10 @@ def __init__(self, resource_manager: 'ResourceManager'): self.resource_manager = resource_manager async def on_request(self, request: Request): - logging.debug(f"Request message from {peer_id}: {request}") + if not self.resource_manager.share_file: + logging.info(self.resource_manager._log(f"Ignore Request message from peer {peer_id[:6]}")) + return + try: data = await self.resource_manager.resource_file.get_block( request.piece_index, @@ -80,23 +136,29 @@ async def on_request(self, request: Request): data ) ) - logging.info(f"Send piece {request.piece_index} on Request message for peer {peer_id}") + logging.info(self.resource_manager._log( + f"Send piece {request.piece_index} on Request message to peer {peer_id[:6]}")) except Exception: - logging.exception(f"Exception on request message from peer {peer_id}") + logging.exception(self.resource_manager._log(f"Exception on request message from peer {peer_id[:6]}")) pass async def on_piece(self, piece: Piece): - logging.debug(f"Piece message from {peer_id}: {piece}") - # This peer is not anymore in charge on this piece + # This peer is not in charge on this piece if self.resource_manager._peer_in_charge[piece.piece_index] != peer_id: - logging.info(f"Discard piece {piece.piece_index} from {peer_id} as not in charge") + logging.info( + self.resource_manager._log( + f"Discard piece {piece.piece_index} from {peer_id[:6]} as not in charge" + ) + ) return - self.resource_manager._piece_status[piece.piece_index] = ResourceManager.PieceStatus.RECEIVED + self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.RECEIVED try: # Check that the received piece matches the hash - assert hashlib.sha256(piece.data).hexdigest() == self.resource_manager.resource.pieces[ - piece.piece_index].sha256 + assert ( + hashlib.sha256(piece.data).hexdigest() == + self.resource_manager.resource.pieces[piece.piece_index].sha256 + ) await self.resource_manager.resource_file.save_block( piece.piece_index, @@ -105,34 +167,60 @@ async def on_piece(self, piece: Piece): ) # If the piece is saved, then broadcast the bitfield to all connections and change the status - self.resource_manager._piece_status[piece.piece_index] = ResourceManager.PieceStatus.SAVED + self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.SAVED + + saved_pieces = sum( + piece_status == ResourceManager.PieceStatus.SAVED + for piece_status in self.resource_manager.piece_status + ) + logging.info(self.resource_manager._log(f"Save piece {piece.piece_index} from {peer_id[:6]}")) + + if saved_pieces == len(self.resource_manager.resource.pieces): + # The file is successfully downloaded! + await self.resource_manager._confirm_download_complete() + await asyncio.gather( *(self.resource_manager._send_bitfield(connection) - for connection in self.resource_manager.connections.values()) + for connection in self.resource_manager.connections.values()), + return_exceptions=True ) - logging.info(f"Save piece {piece.piece_index} from {peer_id}") except Exception: - logging.exception(f"Exception on piece message from peer {peer_id}") - self.resource_manager._piece_status[piece.piece_index] = ResourceManager.PieceStatus.FREE + logging.exception(self.resource_manager._log(f"Exception on piece message from peer {peer_id[:6]}")) + self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.FREE pass async def on_bitfield(self, bitfield: Bitfield): - logging.debug(f"Bitfield message from {peer_id}: {bitfield}") self.resource_manager.bitfields[peer_id] = bitfield.bitfield - logging.info(f"Bitfield from {peer_id}") + logging.info(self.resource_manager._log(f"Bitfield from {peer_id[:6]}: {bitfield.bitfield}")) async def on_close(self, cause): # The connection with peer for some reason is closed - logging.info(f"The connection with {peer_id} is closed") + logging.info(self.resource_manager._log(f"The connection with {peer_id[:6]} is closed")) await self.resource_manager._remove_peer(peer_id) return Listener(self) - def _add_peer(self, peer_id: str, connection: Connection): + async def _confirm_download_complete(self): + saved_pieces = sum( + piece_status == ResourceManager.PieceStatus.SAVED + for piece_status in self.piece_status + ) + assert saved_pieces == len(self.resource.pieces) + + await self.resource_file.accept_download() + await self.stop_download() + logging.info(self._log("Download is completed")) + + async def _add_peer(self, peer_id: str, connection: Connection): self.connections[peer_id] = connection self.bitfields[peer_id] = [False] * len(self.resource.pieces) self._free_peers.add(peer_id) + connection.add_listener(self._create_connection_listener(peer_id)) + await connection.listen() + # Send the message about the stored pieces + await self._send_bitfield(connection) + async def _remove_peer(self, peer_id: str): try: await self.connections[peer_id].close() @@ -141,11 +229,12 @@ async def _remove_peer(self, peer_id: str): pass self.connections.pop(peer_id, None) self.bitfields.pop(peer_id, None) - self._free_peers.remove(peer_id) + self._free_peers.discard(peer_id) # -----MAIN DOWNLOAD LOGIC BEGINS HERE----- async def _download_work(self, peer_id: str, piece_index: int): + logging.info(self._log(f"Download Work on piece {piece_index} from peer {peer_id[:6]}")) connection = self.connections[peer_id] await connection.send_message( Request( @@ -155,18 +244,19 @@ async def _download_work(self, peer_id: str, piece_index: int): ) ) await asyncio.sleep(60) # Sleep 1 minute - if self._piece_status[piece_index] == ResourceManager.PieceStatus.IN_PROGRESS: + if self.piece_status[piece_index] == ResourceManager.PieceStatus.IN_PROGRESS: # If after one minute, the piece is still in progress, # then something is wrong with peer (slow download speed or smth) self._peer_in_charge[piece_index] = '' # This peer is no more responsible for this piece - self._piece_status[piece_index] = ResourceManager.PieceStatus.FREE + self.piece_status[piece_index] = ResourceManager.PieceStatus.FREE async def _download_loop(self): + logging.info(self._log("Start download loop")) works = set() while True: # Find free pieces free_pieces: list[int] = [] - for i, status in enumerate(self._piece_status): + for i, status in enumerate(self.piece_status): if status == ResourceManager.PieceStatus.FREE: free_pieces.append(i) @@ -174,17 +264,22 @@ async def _download_loop(self): random.shuffle(free_pieces) # Try to find piece and peer that has this piece + found_work = False for piece_index in free_pieces: for peer_id in self._free_peers: if self._peer_has_piece(peer_id, piece_index): # Peer has this piece -> run the work # Update the status and related peer - self._piece_status[piece_index] = ResourceManager.PieceStatus.IN_PROGRESS + self.piece_status[piece_index] = ResourceManager.PieceStatus.IN_PROGRESS self._peer_in_charge[piece_index] = peer_id task = asyncio.create_task(self._download_work(peer_id, piece_index)) - task.add_done_callback(works.discard) - works.add(asyncio.create_task(self._download_work)) + task.add_done_callback(lambda t: works.discard(t)) + works.add(task) + found_work = True + break + if found_work: + break await asyncio.sleep(0.2) # -----END OF DOWNLOAD LOGIC----- @@ -196,52 +291,11 @@ async def _run_server_task(self, server: asyncio.Server): async with server: await server.serve_forever() - def __init__( - self, - host_peer_id: str, - destination: Path, - resource: Resource, - has_file - ): - self.host_peer_id = host_peer_id - self.destination = destination - self.resource = resource - self.has_file = has_file - - self.resource_file = ResourceFile(destination, resource, fresh_install=False) - self.info_hash = resource.get_info_hash() - - # dict peer_id <-> Connection - self.connections: dict[str, Connection] = dict() - - # dict peer_id <-> pieces (bitfield) this peer has - self.bitfields: dict[str, list[bool]] = dict() - - if has_file and not destination.exists(): - raise RuntimeError("The caller does not have the file") - - # These fields are needed for download - self._free_peers: set[str] = set() - - # Fill the initial status of the pieces - # If the caller passes True to the has_file, then we assume that all pieces are downloaded - _default_piece_status = ResourceManager.PieceStatus.SAVED if has_file else ResourceManager.PieceStatus.FREE - self._piece_status: list[ResourceManager.PieceStatus] = \ - [ResourceManager.PieceStatus.SAVED] * len(self.resource.pieces) - - # Current peer that handles this piece - self._peer_in_charge: list[str] = [''] * len(self.resource.pieces) - - # Various asyncio background tasks - self._download_task: asyncio.Task | None = None - self._server_task: asyncio.Task | None = None - # PUBLIC METHODS: - async def open_public_port(self) -> int: # Start accepting peer connections on some random port public_server = await asyncio.start_server( - lambda r, w: self._handle_incoming_connection(r, w), + self._handle_incoming_connection, host='0.0.0.0', port=0 ) @@ -257,10 +311,37 @@ async def close_public_port(self): async def start_download(self): # Start downloading the resource + if self.has_file: + raise RuntimeError("Cannot download existing file") + if self._download_task is None: self._download_task = asyncio.create_task(self._download_loop()) async def stop_download(self): + logging.info(self._log("Stop download")) + # Stop downloading the resource self._download_task.cancel() self._download_task = None + + async def submit_peers(self, peers: list[PeerInfo]): + for peer in peers: + if peer.peer_id == self.host_peer_id: + logging.warning(self._log("host_peer_id is passed in submit_peers")) + continue + + # IMPORTANT RULE: The initiator of connection is always peer with the smaller id + if self.host_peer_id < peer.peer_id: + try: + if peer.peer_id not in self.connections: # We do not want repeating connections + connection = await establish_connection(self.host_peer_id, peer, self.resource) + await self._add_peer(peer.peer_id, connection) + logging.info(self._log(f"Establish connection with {peer.peer_id[:6]}")) + except Exception: + logging.exception(self._log(f"Exception while establishing connection with {peer.peer_id[:6]}")) + + async def start_sharing_file(self): + self.share_file = True + + async def stop_sharing_file(self): + self.share_file = False diff --git a/client/core/tests/resource_manager_test.py b/client/core/tests/resource_manager_test.py new file mode 100644 index 0000000..d6b4ba3 --- /dev/null +++ b/client/core/tests/resource_manager_test.py @@ -0,0 +1,110 @@ +import asyncio +import datetime +import hashlib +from pathlib import Path +import random +import shutil +import logging + +from core.common.peer_info import PeerInfo +from core.common.resource import Resource +from core.p2p.resource_manager import ResourceManager + + +def random_bits(size) -> bytes: + return bytes(random.randint(0, 255) for _ in range(size)) + + +def random_peer_id() -> str: + return random_bits(32).hex() + + +async def main(): + logging.basicConfig(level=logging.DEBUG) + + # Temporary directory + tmp = Path(__file__).parent.joinpath('tmp') + shutil.rmtree(tmp, ignore_errors=True) + tmp.mkdir(parents=True) + + # Generate stub data + data: list[bytes] = [random_bits(random.randint(100, 1000)) for _ in range(10)] + pieces: list[Resource.Piece] = [ + Resource.Piece( + sha256=hashlib.sha256(piece_data).hexdigest(), + size_bytes=len(piece_data) + ) + for piece_data in data + ] + resource = Resource( + tracker_ip='0.0.0.0', + tracker_port=8080, + comment='Test file', + creation_date=datetime.datetime.now(), + name='Random testing file', + pieces=pieces + ) + + # Write the stub data to file + source_file = tmp.joinpath(resource.name) + with open(source_file, mode='wb') as f: + for piece_data in data: + f.write(piece_data) + + # Set up source peer_id. This can be used as an example of working with ResourceManager + source_peer_id = random_peer_id() # peer_id is unique PER PEER (not per ResourceManager) + source_destination = source_file + source_resource_manager = ResourceManager(source_peer_id, source_destination, resource, has_file=True) + source_port = await source_resource_manager.open_public_port() + await source_resource_manager.start_sharing_file() + source_peer_info = PeerInfo('127.0.0.1', source_port, source_peer_id) + + consumer_peer_ids = [random_peer_id() for _ in range(5)] + consumer_destinations = [tmp.joinpath(peer_id, resource.name) for peer_id in consumer_peer_ids] + for consumer_destination in consumer_destinations: + consumer_destination.parent.mkdir(parents=True) + + consumer_resource_managers = [ + ResourceManager(consumer_peer_id, consumer_destination, resource, has_file=False) + for consumer_peer_id, consumer_destination in zip(consumer_peer_ids, consumer_destinations) + ] + for consumer_resource_manager in consumer_resource_managers: + await consumer_resource_manager.start_sharing_file() + consumer_ports = [ + await resource_manager.open_public_port() + for resource_manager in consumer_resource_managers + ] + consumer_peer_infos = [ + PeerInfo('127.0.0.1', port, peer_id) + for port, peer_id in zip(consumer_ports, consumer_peer_ids) + ] + + all_peer_infos = consumer_peer_infos + [source_peer_info] + + for resource_manager in consumer_resource_managers: + await resource_manager.start_download() + + # For all the peers, submit the PeerInfo list + await asyncio.gather( + source_resource_manager.submit_peers(all_peer_infos), + *( + consumer_resource_manager.submit_peers(all_peer_infos) + for consumer_resource_manager in consumer_resource_managers + ) + ) + + # ...watch the peers talking! + + # Clean everything + +if __name__ == "__main__": + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.create_task(main()) + loop.run_forever() + finally: + pass + tmp = Path(__file__).parent.joinpath('tmp') + shutil.rmtree(tmp) + diff --git a/client/core/tests/test_message.py b/client/core/tests/test_message.py index 10d1009..c14cdc3 100644 --- a/client/core/tests/test_message.py +++ b/client/core/tests/test_message.py @@ -6,7 +6,7 @@ def test_handshake_to_bytes(): info_hash = '99887766554433221100ffeeddccbbaa' handshake = Handshake(peer_id, info_hash) expected = ( - b'TorrentInno' + + 'TorrentInno'.encode() + bytes.fromhex(peer_id) + bytes.fromhex(info_hash) ) From d29a97f13501e6b396d310127b16c85e35c05030 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Sat, 26 Apr 2025 17:57:45 +0300 Subject: [PATCH 07/46] Create peer server communication class --- .../server_communication/communication.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 client/core/server_communication/communication.py diff --git a/client/core/server_communication/communication.py b/client/core/server_communication/communication.py new file mode 100644 index 0000000..3ad612c --- /dev/null +++ b/client/core/server_communication/communication.py @@ -0,0 +1,37 @@ +import requests +import time +import asyncio + +SERVER_URL = "http://localhost:8080/peers" # * Replace with server URL + +async def update_peer(peer): + ''' + Using post requests create or update peer information on tracker server + ''' + try: + responce = requests.post(SERVER_URL, json=peer, timeout=5) + if responce.status_code == 200: + print("Peer updated successfully.") + else: + print(f"Failed to update peer. Status code: {responce.status_code}") + except requests.exceptions.RequestException as e: + print(f"Error updating peer: {e}") + +async def heart_beat(peer, interval=30): + ''' + Using update_peer function send peer information to tracker server to show + what peer also in live status + ''' + while True: + while True: + update_peer(peer) + time.sleep(interval) + +# peer = { +# "peerId": "peer123", +# "infoHash": "hash456", +# "publicIp": "127.0.0.1", +# "publicPort": "6881" +# } + +# update_peer(peer) \ No newline at end of file From 79a1ee18947d55c69bf3d22575e99e9fd28bbba8 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Sat, 26 Apr 2025 19:45:31 +0300 Subject: [PATCH 08/46] Hard merge dev and GUI --- client/GUI/.gitignore | 1 + client/GUI/LICENSE | 674 +++++++++++++++++++++++++ client/GUI/app.py | 475 +++++++++++++++++ client/GUI/data/file_icons/mp3.png | 11 + client/GUI/data/file_icons/mp4.png | 12 + client/GUI/data/file_icons/txt.png | 15 + client/GUI/data/file_icons/unknown.png | 11 + client/GUI/torrentinno.kv | 146 ++++++ 8 files changed, 1345 insertions(+) create mode 100644 client/GUI/.gitignore create mode 100644 client/GUI/LICENSE create mode 100644 client/GUI/app.py create mode 100644 client/GUI/data/file_icons/mp3.png create mode 100644 client/GUI/data/file_icons/mp4.png create mode 100644 client/GUI/data/file_icons/txt.png create mode 100644 client/GUI/data/file_icons/unknown.png create mode 100644 client/GUI/torrentinno.kv diff --git a/client/GUI/.gitignore b/client/GUI/.gitignore new file mode 100644 index 0000000..f9606a3 --- /dev/null +++ b/client/GUI/.gitignore @@ -0,0 +1 @@ +/venv diff --git a/client/GUI/LICENSE b/client/GUI/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/client/GUI/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/client/GUI/app.py b/client/GUI/app.py new file mode 100644 index 0000000..8724ef8 --- /dev/null +++ b/client/GUI/app.py @@ -0,0 +1,475 @@ +from kivymd.app import MDApp +from kivy.lang import Builder +from kivy.clock import Clock +from kivy.uix.screenmanager import ScreenManager, Screen +from kivy.properties import ListProperty, StringProperty, NumericProperty +from kivy.metrics import dp +from kivymd.uix.boxlayout import MDBoxLayout +import random + +# Mock data for torrent files +MOCK_FILES = [ + { + 'name': 'example.txt', + 'size': '1.23 kb', + 'type': 'txt', + 'download_speed': '1kb/s', + 'upload_speed': '0kb/s', + 'blocks': [0] * 20 # 0 means not downloaded, 1 means downloaded + }, + { + 'name': 'music.mp3', + 'size': '2.03 mb', + 'type': 'mp3', + 'download_speed': '2mb/s', + 'upload_speed': '1mb/s', + 'blocks': [0] * 20 + }, + { + 'name': 'video.mp4', + 'size': '12.7 mb', + 'type': 'mp4', + 'download_speed': '1mb/s', + 'upload_speed': '1mb/s', + 'blocks': [0] * 20 + }, + { + 'name': 'unknown', + 'size': '1.097 Gb', + 'type': 'unknown', + 'download_speed': '3mb/s', + 'upload_speed': '2 mb/s', + 'blocks': [0] * 20 + } +] + +# Mock function to update download progress +def update_download_progress(files): + """Simulate download progress by randomly updating blocks""" + for file in files: + # Randomly select a block to mark as downloaded + if 0 in file['blocks']: # If there are still blocks to download + zero_indices = [i for i, x in enumerate(file['blocks']) if x == 0] + if zero_indices: # If there are blocks that are not downloaded yet + # Randomly select 1-3 blocks to mark as downloaded + num_blocks = min(random.randint(1, 3), len(zero_indices)) + for _ in range(num_blocks): + if zero_indices: # Check again in case we've used all indices + idx = random.choice(zero_indices) + file['blocks'][idx] = 1 + zero_indices.remove(idx) + + # Update download and upload speeds randomly + download_value = float(file['download_speed'].split('mb/s')[0].split('kb/s')[0].strip()) + upload_value = float(file['upload_speed'].split('mb/s')[0].split('kb/s')[0].strip()) + + # Randomly adjust speeds + download_value += random.uniform(-0.5, 0.5) + upload_value += random.uniform(-0.3, 0.3) + + # Ensure speeds don't go below 0.1 + download_value = max(0.1, download_value) + upload_value = max(0.1, upload_value) + + # Update the speed values + if 'kb/s' in file['download_speed']: + file['download_speed'] = f"{download_value:.1f}kb/s" + else: + file['download_speed'] = f"{download_value:.1f}mb/s" + + if 'kb/s' in file['upload_speed']: + file['upload_speed'] = f"{upload_value:.1f}kb/s" + else: + file['upload_speed'] = f"{upload_value:.1f}mb/s" + + return files + +class TorrentFileItem(MDBoxLayout): + """Class representing a single torrent file in the list""" + file_name = StringProperty('') + file_size = StringProperty('') + file_type = StringProperty('') + download_speed = StringProperty('') + upload_speed = StringProperty('') + blocks = ListProperty([]) + index = NumericProperty(-1) # Store the index of this item in the files list + + def __init__(self, **kwargs): + # Initialize blocks properly to avoid shared list issue + blocks_data = kwargs.pop('blocks', [0] * 20) + self.index = kwargs.pop('index', -1) # Get the index from kwargs + super(TorrentFileItem, self).__init__(**kwargs) + self.blocks = blocks_data + # Variables for double click detection + self.last_click_time = 0 + self.double_click_timeout = 0.3 # 300ms for double click detection + + def on_touch_down(self, touch): + """Handle touch down event""" + if self.collide_point(*touch.pos): + # Start a clock to detect long press + touch.ud['long_press'] = Clock.schedule_once(lambda dt: self.on_long_press(touch), 0.5) # 500ms for long press + + # Double click detection + current_time = Clock.get_time() + if current_time - self.last_click_time < self.double_click_timeout: + # This is a double click + self.on_double_click() + # Cancel the long press detection since we've detected a double click + if 'long_press' in touch.ud: + Clock.unschedule(touch.ud['long_press']) + self.last_click_time = current_time + + return super(TorrentFileItem, self).on_touch_down(touch) + + def on_touch_up(self, touch): + """Handle touch up event""" + if 'long_press' in touch.ud: + # Cancel the long press clock if touch is released + Clock.unschedule(touch.ud['long_press']) + return super(TorrentFileItem, self).on_touch_up(touch) + + def on_double_click(self): + """Handle double click event""" + # Show the same options dialog as for long press + self.show_options_dialog() + + def on_long_press(self, touch): + """Handle long press event""" + if self.collide_point(*touch.pos): + self.show_options_dialog() + + def show_options_dialog(self): + """Show options dialog for the torrent file""" + from kivymd.uix.dialog import MDDialog + from kivymd.uix.button import MDFlatButton + + self.dialog = MDDialog( + title=f"Действия с {self.file_name}", + text="Выберите действие:", + buttons=[ + MDFlatButton( + text="Удалить", + on_release=lambda x: self.delete_item() + ), + MDFlatButton( + text="Назад", + on_release=lambda x: self.dialog.dismiss() + ), + ], + ) + self.dialog.open() + + def delete_item(self): + """Delete this item""" + # Close the dialog + self.dialog.dismiss() + # Call the remove_torrent method of MainScreen + app = MDApp.get_running_app() + main_screen = app.root.get_screen('main') + main_screen.remove_torrent(self.index) + +class MainScreen(Screen): + """Main screen of the application showing the list of torrent files""" + files = ListProperty([]) + + def __init__(self, **kwargs): + super(MainScreen, self).__init__(**kwargs) + # Create a deep copy of MOCK_FILES to avoid modifying the original + self.files = [] + for file in MOCK_FILES: + self.files.append(file.copy()) + # Start the clock to update download progress every second + Clock.schedule_interval(self.update_progress, 1) + + def on_kv_post(self, base_widget): + """Called after the kv file is loaded""" + self.update_file_list() + + def update_progress(self, dt): + """Update the download progress of files""" + self.files = update_download_progress(self.files) + self.update_file_list() + + def update_file_list(self): + """Update the file list with current data""" + file_list = self.ids.file_list + file_list.clear_widgets() + + for i, file in enumerate(self.files): + item = TorrentFileItem( + file_name=file['name'], + file_size=file['size'], + file_type=file['type'], + download_speed=file['download_speed'], + upload_speed=file['upload_speed'], + blocks=file['blocks'].copy(), # Use a copy to prevent reference issues + index=i # Pass the index to the item + ) + + # Create progress blocks + progress_container = item.ids.progress_container + progress_container.clear_widgets() + + # Use item.blocks instead of file['blocks'] to avoid duplication + for i, block in enumerate(item.blocks): + block_widget = MDBoxLayout(size_hint_x=1/len(item.blocks)) + if block == 1: # Downloaded block + block_widget.md_bg_color = (0.3, 0.8, 0.3, 1) # Green + else: # Not downloaded block + block_widget.md_bg_color = (1, 1, 1, 1) # White + + progress_container.add_widget(block_widget) + + file_list.add_widget(item) + + def on_back_pressed(self): + """Handle back button press""" + pass # this would navigate back + + def show_info(self): + """Show app information""" + pass # this would show an info dialog + + def show_menu(self): + """Show app menu""" + pass # this would show a menu + + def add_torrent(self): + """Add a new torrent""" + from kivymd.uix.dialog import MDDialog + from kivymd.uix.button import MDFlatButton + from kivymd.uix.textfield import MDTextField + from kivymd.uix.boxlayout import MDBoxLayout + from kivymd.uix.filemanager import MDFileManager + from kivy.core.window import Window + from os.path import expanduser + import os + + # Create a custom content for the dialog + self.dialog_content = MDBoxLayout( + orientation='vertical', + spacing=dp(10), + padding=dp(10), + adaptive_height=True + ) + + # Add a text field for torrent URL + self.url_field = MDTextField( + hint_text="Введите торрент-ссылку", + helper_text="Например: magnet:?xt=urn:btih:...", + helper_text_mode="on_focus", + size_hint_y=None, + height=dp(48) + ) + self.dialog_content.add_widget(self.url_field) + + # Create the dialog + self.add_dialog = MDDialog( + title="Добавить торрент", + type="custom", + content_cls=self.dialog_content, + buttons=[ + MDFlatButton( + text="ВЫБРАТЬ ФАЙЛ", + on_release=self.open_file_manager + ), + MDFlatButton( + text="ОТМЕНА", + on_release=lambda x: self.add_dialog.dismiss() + ), + MDFlatButton( + text="ДОБАВИТЬ", + on_release=self.process_torrent_url + ), + ], + ) + + # Initialize file manager + self.file_manager = MDFileManager( + exit_manager=self.exit_file_manager, + select_path=self.select_torrent_file, + preview=True, + ) + + # Show the dialog + self.add_dialog.open() + + def open_file_manager(self, *args): + """Open file manager to select a torrent file""" + # Set the starting path to user's home directory + home_dir = expanduser("~") + self.file_manager.show(home_dir) + + def exit_file_manager(self, *args): + """Close the file manager""" + self.file_manager.close() + + def select_torrent_file(self, path): + """Handle torrent file selection""" + self.file_manager.close() + # Check if the file is a torrent file + if path.endswith('.torrent'): + self.show_torrent_content_dialog(path) + else: + from kivymd.toast import toast + toast("Выбранный файл не является торрент-файлом") + + def process_torrent_url(self, *args): + """Process the torrent URL entered by the user""" + url = self.url_field.text.strip() + if url.startswith('magnet:') or url.endswith('.torrent'): + self.add_dialog.dismiss() + self.show_torrent_content_dialog(url) + else: + from kivymd.toast import toast + toast("Введите корректную торрент-ссылку") + + def show_torrent_content_dialog(self, source): + """Show dialog with torrent content for selection""" + # Mock data for torrent content + mock_content = [ + {"name": "file1.mp4", "size": "1.2 GB", "selected": True}, + {"name": "file2.txt", "size": "15 KB", "selected": True}, + {"name": "file3.mp3", "size": "5.7 MB", "selected": True} + ] + + from kivymd.uix.list import MDList, OneLineAvatarIconListItem, IconLeftWidget, IconRightWidget + from kivymd.uix.selectioncontrol import MDCheckbox + + # Create content for the dialog + content = MDBoxLayout( + orientation='vertical', + spacing=dp(10), + padding=dp(10), + size_hint_y=None, + height=dp(300) + ) + + # Add a label for save path + save_path_box = MDBoxLayout( + orientation='horizontal', + size_hint_y=None, + height=dp(48) + ) + + from kivymd.uix.label import MDLabel + save_path_label = MDLabel( + text="Путь сохранения:", + size_hint_x=0.3 + ) + + self.save_path_field = MDTextField( + text=expanduser("~/Downloads"), + size_hint_x=0.7 + ) + + save_path_box.add_widget(save_path_label) + save_path_box.add_widget(self.save_path_field) + content.add_widget(save_path_box) + + # Create a list for files + file_list = MDList() + + # Add files to the list + for file in mock_content: + item = OneLineAvatarIconListItem(text=f"{file['name']} ({file['size']})") + + # Add file icon based on extension + file_ext = file['name'].split('.')[-1] if '.' in file['name'] else 'unknown' + icon_name = "file" + if file_ext in ['mp3', 'wav', 'ogg']: + icon_name = "music-note" + elif file_ext in ['mp4', 'avi', 'mkv']: + icon_name = "video" + elif file_ext in ['txt', 'pdf', 'doc', 'docx']: + icon_name = "file-document" + + item.add_widget(IconLeftWidget(icon=icon_name)) + + # Add checkbox for selection + check = MDCheckbox(active=file['selected']) + check.bind(active=lambda checkbox, value, file=file: self.toggle_file_selection(file, value)) + right_icon = IconRightWidget(widget=check) + item.add_widget(right_icon) + + file_list.add_widget(item) + + # Add the list to a scroll view + from kivy.uix.scrollview import ScrollView + scroll = ScrollView() + scroll.add_widget(file_list) + content.add_widget(scroll) + + # Create the dialog + from kivymd.uix.dialog import MDDialog + from kivymd.uix.button import MDFlatButton + + self.content_dialog = MDDialog( + title="Выберите файлы для загрузки", + type="custom", + content_cls=content, + buttons=[ + MDFlatButton( + text="ОТМЕНА", + on_release=lambda x: self.content_dialog.dismiss() + ), + MDFlatButton( + text="ЗАГРУЗИТЬ", + on_release=self.start_download + ), + ], + ) + + self.content_dialog.open() + + def toggle_file_selection(self, file, value): + """Toggle file selection""" + file['selected'] = value + + def start_download(self, *args): + """Start downloading selected files""" + # Here you would start the actual download process + # For now, we'll just close the dialog and add a mock file to the list + self.content_dialog.dismiss() + + # Add a new mock file to the list + new_file = { + 'name': 'new_download.mp4', + 'size': '45.8 mb', + 'type': 'mp4', + 'download_speed': '0mb/s', + 'upload_speed': '0mb/s', + 'blocks': [0] * 20 + } + + self.files.append(new_file) + self.update_file_list() + + def remove_torrent(self, index=None): + """Remove a torrent""" + if index is not None and 0 <= index < len(self.files): + # Remove the file at the specified index + del self.files[index] + # Update the file list to reflect the changes + self.update_file_list() + +class TorrentInnoApp(MDApp): + def build(self): + self.theme_cls.primary_palette = "BlueGray" + self.theme_cls.accent_palette = "Teal" + self.theme_cls.theme_style = "Light" + + return super().build() + + """ [DEPRECATED] + This part of code cause doublicationg of blocks + as in https://stackoverflow.com/questions/62752997/all-elements-rendering-twice-in-kivy-kivymd + """ + # root = Builder.load_file('torrentinno.kv') + # return root + + # screen manager and add it to the root widget + +if __name__ == '__main__': + TorrentInnoApp().run() \ No newline at end of file diff --git a/client/GUI/data/file_icons/mp3.png b/client/GUI/data/file_icons/mp3.png new file mode 100644 index 0000000..a887480 --- /dev/null +++ b/client/GUI/data/file_icons/mp3.png @@ -0,0 +1,11 @@ + + + + + + + + + + MP3 + \ No newline at end of file diff --git a/client/GUI/data/file_icons/mp4.png b/client/GUI/data/file_icons/mp4.png new file mode 100644 index 0000000..75febf3 --- /dev/null +++ b/client/GUI/data/file_icons/mp4.png @@ -0,0 +1,12 @@ + + + + + + + + + + + MP4 + \ No newline at end of file diff --git a/client/GUI/data/file_icons/txt.png b/client/GUI/data/file_icons/txt.png new file mode 100644 index 0000000..700ea63 --- /dev/null +++ b/client/GUI/data/file_icons/txt.png @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + TXT + \ No newline at end of file diff --git a/client/GUI/data/file_icons/unknown.png b/client/GUI/data/file_icons/unknown.png new file mode 100644 index 0000000..15f3a58 --- /dev/null +++ b/client/GUI/data/file_icons/unknown.png @@ -0,0 +1,11 @@ + + + + + + ? + + + + UNKNOWN + \ No newline at end of file diff --git a/client/GUI/torrentinno.kv b/client/GUI/torrentinno.kv new file mode 100644 index 0000000..3a3d299 --- /dev/null +++ b/client/GUI/torrentinno.kv @@ -0,0 +1,146 @@ +#:kivy 2.0.0 +#:import get_color_from_hex kivy.utils.get_color_from_hex + +: + orientation: 'vertical' + size_hint_y: None + height: dp(100) + padding: dp(5) + spacing: dp(2) + file_name: '' + file_size: '' + file_type: '' + download_speed: '' + upload_speed: '' + blocks: [] + + canvas.before: + Color: + rgba: 0.95, 0.95, 0.95, 1 + Rectangle: + pos: self.pos + size: self.size + Color: + rgba: 0.9, 0.9, 0.9, 1 + Line: + points: [self.x, self.y, self.x + self.width, self.y] + width: 1 + + MDBoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: dp(70) + spacing: dp(10) + padding: dp(5) + + # File icon + MDBoxLayout: + size_hint_x: None + width: dp(60) + + Image: + source: 'data/file_icons/' + root.file_type + '.png' if root.file_type else 'data/file_icons/unknown.png' + size_hint: None, None + size: dp(50), dp(50) + pos_hint: {'center_x': 0.5, 'center_y': 0.5} + + #MDLabel: + # text: root.file_type + # halign: 'center' + # font_size: dp(14) + # size_hint_y: None + # height: dp(20) + # pos_hint: {'center_x': 0.5, 'bottom': 0} + + # File info + MDBoxLayout: + orientation: 'vertical' + spacing: dp(5) + + MDBoxLayout: + orientation: 'horizontal' + + MDLabel: + text: root.file_name + font_size: dp(18) + halign: 'left' + size_hint_x: 0.6 + + MDLabel: + text: root.download_speed + font_size: dp(16) + halign: 'right' + color: get_color_from_hex('#4CAF50') # Green color for download + size_hint_x: 0.2 + + MDLabel: + text: root.upload_speed + font_size: dp(16) + halign: 'right' + color: get_color_from_hex('#F44336') # Red color for upload + size_hint_x: 0.2 + + MDLabel: + text: root.file_size + font_size: dp(16) + halign: 'left' + + # Progress bar with blocks + MDBoxLayout: + size_hint_y: None + height: dp(20) + padding: [dp(70), 0, dp(10), 0] + + MDBoxLayout: + id: progress_container + orientation: 'horizontal' + spacing: dp(1) + + # This will be filled with block widgets in Python code + canvas: + Color: + rgba: 0.9, 0.9, 0.9, 1 + Rectangle: + pos: self.pos + size: self.size + + # Generate progress blocks + Widget: + canvas: + Color: + rgba: 0, 0, 0, 1 + Line: + rectangle: [self.x, self.y, self.width, self.height] + width: 1 + +: + name: 'main' + + MDBoxLayout: + orientation: 'vertical' + + # App bar + MDTopAppBar: + title: "TorrentInno" + elevation: 4 + left_action_items: [['arrow-left', lambda x: app.root.current_screen.on_back_pressed()]] + right_action_items: [['information', lambda x: app.root.current_screen.show_info()], ['dots-vertical', lambda x: app.root.current_screen.show_menu()]] + + # Scrollable list of torrent files + ScrollView: + do_scroll_x: False + + MDList: + id: file_list + spacing: dp(2) + padding: dp(5) + + # Add button (floating action button) + MDFloatingActionButton: + icon: 'plus' + pos_hint: {'right': 0.95, 'y': 0.01} + on_release: app.root.current_screen.add_torrent() + +ScreenManager: + MainScreen: + name: 'main' \ No newline at end of file From 7a904f6961f5820012b69e3bc2594b9f983f95f8 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Sat, 26 Apr 2025 19:46:44 +0300 Subject: [PATCH 09/46] delete some file --- client/GUI/.gitignore | 1 - 1 file changed, 1 deletion(-) delete mode 100644 client/GUI/.gitignore diff --git a/client/GUI/.gitignore b/client/GUI/.gitignore deleted file mode 100644 index f9606a3..0000000 --- a/client/GUI/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/venv From 2f0dba9692934567d10eda48d2ae7c0d07a9632c Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Sat, 26 Apr 2025 19:48:59 +0300 Subject: [PATCH 10/46] GUI -> gui --- client/{GUI => gui}/LICENSE | 0 client/{GUI => gui}/app.py | 0 client/{GUI => gui}/data/file_icons/mp3.png | 0 client/{GUI => gui}/data/file_icons/mp4.png | 0 client/{GUI => gui}/data/file_icons/txt.png | 0 client/{GUI => gui}/data/file_icons/unknown.png | 0 client/{GUI => gui}/torrentinno.kv | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename client/{GUI => gui}/LICENSE (100%) rename client/{GUI => gui}/app.py (100%) rename client/{GUI => gui}/data/file_icons/mp3.png (100%) rename client/{GUI => gui}/data/file_icons/mp4.png (100%) rename client/{GUI => gui}/data/file_icons/txt.png (100%) rename client/{GUI => gui}/data/file_icons/unknown.png (100%) rename client/{GUI => gui}/torrentinno.kv (100%) diff --git a/client/GUI/LICENSE b/client/gui/LICENSE similarity index 100% rename from client/GUI/LICENSE rename to client/gui/LICENSE diff --git a/client/GUI/app.py b/client/gui/app.py similarity index 100% rename from client/GUI/app.py rename to client/gui/app.py diff --git a/client/GUI/data/file_icons/mp3.png b/client/gui/data/file_icons/mp3.png similarity index 100% rename from client/GUI/data/file_icons/mp3.png rename to client/gui/data/file_icons/mp3.png diff --git a/client/GUI/data/file_icons/mp4.png b/client/gui/data/file_icons/mp4.png similarity index 100% rename from client/GUI/data/file_icons/mp4.png rename to client/gui/data/file_icons/mp4.png diff --git a/client/GUI/data/file_icons/txt.png b/client/gui/data/file_icons/txt.png similarity index 100% rename from client/GUI/data/file_icons/txt.png rename to client/gui/data/file_icons/txt.png diff --git a/client/GUI/data/file_icons/unknown.png b/client/gui/data/file_icons/unknown.png similarity index 100% rename from client/GUI/data/file_icons/unknown.png rename to client/gui/data/file_icons/unknown.png diff --git a/client/GUI/torrentinno.kv b/client/gui/torrentinno.kv similarity index 100% rename from client/GUI/torrentinno.kv rename to client/gui/torrentinno.kv From 9478e344321636087fa634a1f8759f6540800880 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Sat, 26 Apr 2025 20:49:21 +0300 Subject: [PATCH 11/46] 1. Edited server_manager.py 2.Created torrentinno.py and edited share_file() function --- client/core/s2p/__init__.py | 0 client/core/s2p/server_manager.py | 18 +++++++ .../server_communication/communication.py | 37 -------------- client/torentinno.py | 48 +++++++++++++++++++ 4 files changed, 66 insertions(+), 37 deletions(-) create mode 100644 client/core/s2p/__init__.py create mode 100644 client/core/s2p/server_manager.py delete mode 100644 client/core/server_communication/communication.py create mode 100644 client/torentinno.py diff --git a/client/core/s2p/__init__.py b/client/core/s2p/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/core/s2p/server_manager.py b/client/core/s2p/server_manager.py new file mode 100644 index 0000000..e69e956 --- /dev/null +++ b/client/core/s2p/server_manager.py @@ -0,0 +1,18 @@ +import requests +import time +import asyncio + +async def update_peer(server_url,peer) -> str: + ''' + Using post requests create or update peer information on tracker server + ''' + try: + response = requests.post(server_url, json=peer, timeout=5) + if response.status_code == 200: + print("Peer updated successfully.") + else: + print(f"Failed to update peer. Status code: {response.status_code}") + return response.text + except requests.exceptions.RequestException as e: + print(f"Error updating peer: {e}") + return '' \ No newline at end of file diff --git a/client/core/server_communication/communication.py b/client/core/server_communication/communication.py deleted file mode 100644 index 3ad612c..0000000 --- a/client/core/server_communication/communication.py +++ /dev/null @@ -1,37 +0,0 @@ -import requests -import time -import asyncio - -SERVER_URL = "http://localhost:8080/peers" # * Replace with server URL - -async def update_peer(peer): - ''' - Using post requests create or update peer information on tracker server - ''' - try: - responce = requests.post(SERVER_URL, json=peer, timeout=5) - if responce.status_code == 200: - print("Peer updated successfully.") - else: - print(f"Failed to update peer. Status code: {responce.status_code}") - except requests.exceptions.RequestException as e: - print(f"Error updating peer: {e}") - -async def heart_beat(peer, interval=30): - ''' - Using update_peer function send peer information to tracker server to show - what peer also in live status - ''' - while True: - while True: - update_peer(peer) - time.sleep(interval) - -# peer = { -# "peerId": "peer123", -# "infoHash": "hash456", -# "publicIp": "127.0.0.1", -# "publicPort": "6881" -# } - -# update_peer(peer) \ No newline at end of file diff --git a/client/torentinno.py b/client/torentinno.py new file mode 100644 index 0000000..c5cc45c --- /dev/null +++ b/client/torentinno.py @@ -0,0 +1,48 @@ +import asyncio +import random +import json +import requests +import time + +from client.core.s2p.server_manager import update_peer +from core.p2p import resource_manager +from core.s2p.server_manager import update_peer +from core.common.peer_info import PeerInfo +from core.common.resource import Resource + +def generate_random_bits(size) -> bytes: + return bytes(random.randint(0, 255) for _ in range(size)) + +def generate_random_peer_id() -> str: + return generate_random_bits(32).hex() + +def get_peer_public_ip(): + try: + response = requests.get("https://api.ipify.org?format=json", timeout=5) + return response.json()['ip'] + except Exception as e: + return None + +async def share_file(path, resource: Resource): #name can be changed + peer_id = generate_random_peer_id() + file_path = path + resource_manager_instance = resource_manager.ResourceManager(peer_id, file_path, resource, has_file=True) + port = await resource_manager_instance.open_public_port() + await resource_manager_instance.start_sharing_file() + peer_ip =get_peer_public_ip() + peer_info = PeerInfo(peer_ip, port, peer_id) + + peer_json = { + "peerId": peer_info.peer_id, + "infoHash": resource.get_info_hash(), + "publicIp": peer_info.public_ip, + "publicPort": peer_info.public_port, + } + + server_url = f"http://{resource.tracker_ip}:{resource.tracker_port}/peers" + + while True: + peer_list_str = await update_peer(server_url,peer_json) + peer_list = json.loads(peer_list_str) + await resource_manager_instance.submit_peers(peer_list) + time.sleep(25) From 597b8bb357d081b72bea3f578f5ac20929319eba Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Sat, 26 Apr 2025 23:24:21 +0300 Subject: [PATCH 12/46] Correct specs --- specs/README.md | 2 +- specs/{torrentinno.json => resource.json} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename specs/{torrentinno.json => resource.json} (100%) diff --git a/specs/README.md b/specs/README.md index 753e4b2..daedd98 100644 --- a/specs/README.md +++ b/specs/README.md @@ -1,7 +1,7 @@ # The overall flow 1) The peer announces itself to the tracker. In order to do that, the peer -a) computes the *info-hash* of the `torrentinno` file. To do so, it sorts all the keys in alphabetic order, then removes any whitespaces, and then takes the sha256 hash of the resulting string encoded in UTF-8. +a) computes the *info-hash* of the `torrentinno` file. The details on computation can be found in the client `core.common.Resource` class (method `get_info_hash`). b) sends the http query with `peer-announce.json` as request body. 2) The tracker accepts the peer request and returns the list of all currently online peers that have sent the announcement to the tracker with the same *info-hash*. The server response body is formatted according to `tracker-response.json` diff --git a/specs/torrentinno.json b/specs/resource.json similarity index 100% rename from specs/torrentinno.json rename to specs/resource.json From de3b68555416cdcdc060ac530e0c61fa84a3b5e1 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Sat, 26 Apr 2025 23:44:18 +0300 Subject: [PATCH 13/46] Change ResourceFile to states --- client/core/p2p/resource_file.py | 25 +++++++++++++++---------- client/core/tests/test_resource_file.py | 3 +++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/client/core/p2p/resource_file.py b/client/core/p2p/resource_file.py index 1efc566..3dd5604 100644 --- a/client/core/p2p/resource_file.py +++ b/client/core/p2p/resource_file.py @@ -6,6 +6,7 @@ import aiofiles.os from core.common.resource import Resource +from enum import Enum class ResourceFile: @@ -14,19 +15,22 @@ class ResourceFile: the complexities of managing the "hidden file" and the aiofiles-related operations. Everything else (checking and validating pieces, for example) is the caller's responsibility. - There basically two operating modes: - 1) The destination file exists. Then this file will be used in all read operations. The write operations - will raise RuntimeException. - 2) The destination file does not exist. Then the auxiliary downloading_destination file will be used. - This file is created with the size of the initial destination file. When the client decides that all pieces - are written, then it calls the accept_download and the file gets renamed to the original destination name. + The class has two states + 1) Downloading state. In this state the temporary file is created and uses to write/read operations + 2) Downloaded state. In this state the destination itself is used to read operations. Write operations + raise exception. """ + class State(Enum): + DOWNLOADING = 1 + DOWNLOADED = 2 + def __init__(self, destination: Path, resource: Resource, fresh_install=True): self.destination = destination self.resource = resource self.downloading_destination = self.get_downloading_destination() self.lock = asyncio.Lock() + self.state = ResourceFile.State.DOWNLOADING if fresh_install: destination.unlink(missing_ok=True) @@ -59,7 +63,7 @@ async def get_block(self, piece_index: int, piece_inner_offset: int, block_lengt raise RuntimeError("The requested read portion does not fit the file") take_from: Path - if self.destination.exists(): + if self.state == ResourceFile.State.DOWNLOADED: take_from = self.destination else: await self._ensure_downloading_destination() @@ -70,13 +74,13 @@ async def get_block(self, piece_index: int, piece_inner_offset: int, block_lengt return await f.read(block_length) async def save_block(self, piece_index: int, piece_inner_offset: int, data: bytes): + if self.state == ResourceFile.State.DOWNLOADED: + raise RuntimeError("Cannot perform write operation in DOWNLOADED state") + offset = self._calculate_offset(piece_index, piece_inner_offset) if offset + len(data) > self.offsets[-1]: raise RuntimeError("The write portion overflows the file") - if self.destination.exists(): - raise RuntimeError("File is already downloaded") - await self._ensure_downloading_destination() async with aiofiles.open(self.downloading_destination, mode='r+b') as f: @@ -88,3 +92,4 @@ async def save_validated_piece(self, piece_index: int, data: bytes): async def accept_download(self): await aiofiles.os.rename(self.downloading_destination, self.destination) + self.state = ResourceFile.State.DOWNLOADED diff --git a/client/core/tests/test_resource_file.py b/client/core/tests/test_resource_file.py index 33116c2..fececb8 100644 --- a/client/core/tests/test_resource_file.py +++ b/client/core/tests/test_resource_file.py @@ -38,3 +38,6 @@ async def write_piece(piece_index: int, piece_data: bytes): # Ensure the downloading resource is moved and no longer exists assert not resource_file.get_downloading_destination().exists() + + with pytest.raises(Exception): + await resource_file.save_validated_piece(2, "\x02\xa0".encode()) From 5734c338cfac08938d1dc08ca1411e0f6d57d911 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Sun, 27 Apr 2025 00:06:21 +0300 Subject: [PATCH 14/46] Add the explicit initial_state parameter to the constructor of ResourceFile --- client/core/p2p/resource_file.py | 12 ++++++-- client/core/p2p/resource_manager.py | 47 ++++++++++++++--------------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/client/core/p2p/resource_file.py b/client/core/p2p/resource_file.py index 3dd5604..56d0069 100644 --- a/client/core/p2p/resource_file.py +++ b/client/core/p2p/resource_file.py @@ -25,14 +25,22 @@ class State(Enum): DOWNLOADING = 1 DOWNLOADED = 2 - def __init__(self, destination: Path, resource: Resource, fresh_install=True): + def __init__( + self, + destination: Path, + resource: Resource, + fresh_install=True, + initial_state=State.DOWNLOADING + ): self.destination = destination self.resource = resource self.downloading_destination = self.get_downloading_destination() self.lock = asyncio.Lock() - self.state = ResourceFile.State.DOWNLOADING + self.state = initial_state if fresh_install: + assert initial_state == ResourceFile.State.DOWNLOADING + destination.unlink(missing_ok=True) self.downloading_destination.unlink(missing_ok=True) diff --git a/client/core/p2p/resource_manager.py b/client/core/p2p/resource_manager.py index 516262a..693372e 100644 --- a/client/core/p2p/resource_manager.py +++ b/client/core/p2p/resource_manager.py @@ -33,36 +33,35 @@ def __init__( self.resource = resource self.has_file = has_file - self.resource_file = ResourceFile(destination, resource, fresh_install=False) self.info_hash = resource.get_info_hash() # If the peer can give file pieces self.share_file = False - # dict peer_id <-> Connection - self.connections: dict[str, Connection] = dict() - - # dict peer_id <-> pieces (bitfield) this peer has - self.bitfields: dict[str, list[bool]] = dict() - - if has_file and not destination.exists(): - raise RuntimeError("The caller does not have the file but has_file=True") - - # TODO: add normal restoring procedure - # For now, simply delete the destination and begin from zero downloaded file - if not has_file: - destination.unlink(missing_ok=True) - - # List of currently not working peers - self._free_peers: set[str] = set() - - # Fill the initial status of the pieces - # If the caller passes True to the has_file, then we assume that all pieces are downloaded - _default_piece_status = ResourceManager.PieceStatus.SAVED if has_file else ResourceManager.PieceStatus.FREE - self.piece_status: list[ResourceManager.PieceStatus] = \ - [_default_piece_status] * len(self.resource.pieces) + # Peer dictionaries + self.connections: dict[str, Connection] = dict() # peer_id <-> Connection + self.bitfields: dict[str, list[bool]] = dict() # peer_id <-> bitfield (owned chunks) + self._free_peers: set[str] = set() # set of peer ids that are not involved in any work + + self.piece_status: list[ResourceManager.PieceStatus] = [] + if has_file: # The caller claims to already have the file + self.resource_file = ResourceFile( + destination, + resource, + fresh_install=False, + initial_state=ResourceFile.State.DOWNLOADED + ) + self.piece_status = [ResourceManager.PieceStatus.SAVED] * len(self.resource.pieces) + else: # The caller does not the complete downloaded file + self.resource_file = ResourceFile( + destination, + resource, + fresh_install=True, # TODO: add normal restoring procedure (for now simply delete any previous files) + initial_state=ResourceFile.State.DOWNLOADING + ) + self.piece_status = [ResourceManager.PieceStatus.FREE] * len(self.resource.pieces) - # Current peer that handles this piece + # Current peer id that handles the piece (empty string=no peer) self._peer_in_charge: list[str] = [''] * len(self.resource.pieces) # Various asyncio background tasks From ce282427a5bba39a15d539af7d3299dc1af4ab0f Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Sun, 27 Apr 2025 15:59:28 +0300 Subject: [PATCH 15/46] Some edits in GUI by GEOS --- client/gui/LICENSE | 674 ---------------------- client/gui/app.py | 874 +++++++++++++---------------- client/gui/torrent_manager.py | 254 +++++++++ client/gui/torrent_manager_docs.md | 142 +++++ client/gui/torrentinno.kv | 290 +++++----- client/torentinno.py | 14 +- 6 files changed, 954 insertions(+), 1294 deletions(-) delete mode 100644 client/gui/LICENSE create mode 100644 client/gui/torrent_manager.py create mode 100644 client/gui/torrent_manager_docs.md diff --git a/client/gui/LICENSE b/client/gui/LICENSE deleted file mode 100644 index f288702..0000000 --- a/client/gui/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/client/gui/app.py b/client/gui/app.py index 8724ef8..a6a2c95 100644 --- a/client/gui/app.py +++ b/client/gui/app.py @@ -1,475 +1,401 @@ -from kivymd.app import MDApp -from kivy.lang import Builder -from kivy.clock import Clock -from kivy.uix.screenmanager import ScreenManager, Screen -from kivy.properties import ListProperty, StringProperty, NumericProperty -from kivy.metrics import dp -from kivymd.uix.boxlayout import MDBoxLayout -import random - -# Mock data for torrent files -MOCK_FILES = [ - { - 'name': 'example.txt', - 'size': '1.23 kb', - 'type': 'txt', - 'download_speed': '1kb/s', - 'upload_speed': '0kb/s', - 'blocks': [0] * 20 # 0 means not downloaded, 1 means downloaded - }, - { - 'name': 'music.mp3', - 'size': '2.03 mb', - 'type': 'mp3', - 'download_speed': '2mb/s', - 'upload_speed': '1mb/s', - 'blocks': [0] * 20 - }, - { - 'name': 'video.mp4', - 'size': '12.7 mb', - 'type': 'mp4', - 'download_speed': '1mb/s', - 'upload_speed': '1mb/s', - 'blocks': [0] * 20 - }, - { - 'name': 'unknown', - 'size': '1.097 Gb', - 'type': 'unknown', - 'download_speed': '3mb/s', - 'upload_speed': '2 mb/s', - 'blocks': [0] * 20 - } -] - -# Mock function to update download progress -def update_download_progress(files): - """Simulate download progress by randomly updating blocks""" - for file in files: - # Randomly select a block to mark as downloaded - if 0 in file['blocks']: # If there are still blocks to download - zero_indices = [i for i, x in enumerate(file['blocks']) if x == 0] - if zero_indices: # If there are blocks that are not downloaded yet - # Randomly select 1-3 blocks to mark as downloaded - num_blocks = min(random.randint(1, 3), len(zero_indices)) - for _ in range(num_blocks): - if zero_indices: # Check again in case we've used all indices - idx = random.choice(zero_indices) - file['blocks'][idx] = 1 - zero_indices.remove(idx) - - # Update download and upload speeds randomly - download_value = float(file['download_speed'].split('mb/s')[0].split('kb/s')[0].strip()) - upload_value = float(file['upload_speed'].split('mb/s')[0].split('kb/s')[0].strip()) - - # Randomly adjust speeds - download_value += random.uniform(-0.5, 0.5) - upload_value += random.uniform(-0.3, 0.3) - - # Ensure speeds don't go below 0.1 - download_value = max(0.1, download_value) - upload_value = max(0.1, upload_value) - - # Update the speed values - if 'kb/s' in file['download_speed']: - file['download_speed'] = f"{download_value:.1f}kb/s" - else: - file['download_speed'] = f"{download_value:.1f}mb/s" - - if 'kb/s' in file['upload_speed']: - file['upload_speed'] = f"{upload_value:.1f}kb/s" - else: - file['upload_speed'] = f"{upload_value:.1f}mb/s" - - return files - -class TorrentFileItem(MDBoxLayout): - """Class representing a single torrent file in the list""" - file_name = StringProperty('') - file_size = StringProperty('') - file_type = StringProperty('') - download_speed = StringProperty('') - upload_speed = StringProperty('') - blocks = ListProperty([]) - index = NumericProperty(-1) # Store the index of this item in the files list - - def __init__(self, **kwargs): - # Initialize blocks properly to avoid shared list issue - blocks_data = kwargs.pop('blocks', [0] * 20) - self.index = kwargs.pop('index', -1) # Get the index from kwargs - super(TorrentFileItem, self).__init__(**kwargs) - self.blocks = blocks_data - # Variables for double click detection - self.last_click_time = 0 - self.double_click_timeout = 0.3 # 300ms for double click detection - - def on_touch_down(self, touch): - """Handle touch down event""" - if self.collide_point(*touch.pos): - # Start a clock to detect long press - touch.ud['long_press'] = Clock.schedule_once(lambda dt: self.on_long_press(touch), 0.5) # 500ms for long press - - # Double click detection - current_time = Clock.get_time() - if current_time - self.last_click_time < self.double_click_timeout: - # This is a double click - self.on_double_click() - # Cancel the long press detection since we've detected a double click - if 'long_press' in touch.ud: - Clock.unschedule(touch.ud['long_press']) - self.last_click_time = current_time - - return super(TorrentFileItem, self).on_touch_down(touch) - - def on_touch_up(self, touch): - """Handle touch up event""" - if 'long_press' in touch.ud: - # Cancel the long press clock if touch is released - Clock.unschedule(touch.ud['long_press']) - return super(TorrentFileItem, self).on_touch_up(touch) - - def on_double_click(self): - """Handle double click event""" - # Show the same options dialog as for long press - self.show_options_dialog() - - def on_long_press(self, touch): - """Handle long press event""" - if self.collide_point(*touch.pos): - self.show_options_dialog() - - def show_options_dialog(self): - """Show options dialog for the torrent file""" - from kivymd.uix.dialog import MDDialog - from kivymd.uix.button import MDFlatButton - - self.dialog = MDDialog( - title=f"Действия с {self.file_name}", - text="Выберите действие:", - buttons=[ - MDFlatButton( - text="Удалить", - on_release=lambda x: self.delete_item() - ), - MDFlatButton( - text="Назад", - on_release=lambda x: self.dialog.dismiss() - ), - ], - ) - self.dialog.open() - - def delete_item(self): - """Delete this item""" - # Close the dialog - self.dialog.dismiss() - # Call the remove_torrent method of MainScreen - app = MDApp.get_running_app() - main_screen = app.root.get_screen('main') - main_screen.remove_torrent(self.index) - -class MainScreen(Screen): - """Main screen of the application showing the list of torrent files""" - files = ListProperty([]) - - def __init__(self, **kwargs): - super(MainScreen, self).__init__(**kwargs) - # Create a deep copy of MOCK_FILES to avoid modifying the original - self.files = [] - for file in MOCK_FILES: - self.files.append(file.copy()) - # Start the clock to update download progress every second - Clock.schedule_interval(self.update_progress, 1) - - def on_kv_post(self, base_widget): - """Called after the kv file is loaded""" - self.update_file_list() - - def update_progress(self, dt): - """Update the download progress of files""" - self.files = update_download_progress(self.files) - self.update_file_list() - - def update_file_list(self): - """Update the file list with current data""" - file_list = self.ids.file_list - file_list.clear_widgets() - - for i, file in enumerate(self.files): - item = TorrentFileItem( - file_name=file['name'], - file_size=file['size'], - file_type=file['type'], - download_speed=file['download_speed'], - upload_speed=file['upload_speed'], - blocks=file['blocks'].copy(), # Use a copy to prevent reference issues - index=i # Pass the index to the item - ) - - # Create progress blocks - progress_container = item.ids.progress_container - progress_container.clear_widgets() - - # Use item.blocks instead of file['blocks'] to avoid duplication - for i, block in enumerate(item.blocks): - block_widget = MDBoxLayout(size_hint_x=1/len(item.blocks)) - if block == 1: # Downloaded block - block_widget.md_bg_color = (0.3, 0.8, 0.3, 1) # Green - else: # Not downloaded block - block_widget.md_bg_color = (1, 1, 1, 1) # White - - progress_container.add_widget(block_widget) - - file_list.add_widget(item) - - def on_back_pressed(self): - """Handle back button press""" - pass # this would navigate back - - def show_info(self): - """Show app information""" - pass # this would show an info dialog - - def show_menu(self): - """Show app menu""" - pass # this would show a menu - - def add_torrent(self): - """Add a new torrent""" - from kivymd.uix.dialog import MDDialog - from kivymd.uix.button import MDFlatButton - from kivymd.uix.textfield import MDTextField - from kivymd.uix.boxlayout import MDBoxLayout - from kivymd.uix.filemanager import MDFileManager - from kivy.core.window import Window - from os.path import expanduser - import os - - # Create a custom content for the dialog - self.dialog_content = MDBoxLayout( - orientation='vertical', - spacing=dp(10), - padding=dp(10), - adaptive_height=True - ) - - # Add a text field for torrent URL - self.url_field = MDTextField( - hint_text="Введите торрент-ссылку", - helper_text="Например: magnet:?xt=urn:btih:...", - helper_text_mode="on_focus", - size_hint_y=None, - height=dp(48) - ) - self.dialog_content.add_widget(self.url_field) - - # Create the dialog - self.add_dialog = MDDialog( - title="Добавить торрент", - type="custom", - content_cls=self.dialog_content, - buttons=[ - MDFlatButton( - text="ВЫБРАТЬ ФАЙЛ", - on_release=self.open_file_manager - ), - MDFlatButton( - text="ОТМЕНА", - on_release=lambda x: self.add_dialog.dismiss() - ), - MDFlatButton( - text="ДОБАВИТЬ", - on_release=self.process_torrent_url - ), - ], - ) - - # Initialize file manager - self.file_manager = MDFileManager( - exit_manager=self.exit_file_manager, - select_path=self.select_torrent_file, - preview=True, - ) - - # Show the dialog - self.add_dialog.open() - - def open_file_manager(self, *args): - """Open file manager to select a torrent file""" - # Set the starting path to user's home directory - home_dir = expanduser("~") - self.file_manager.show(home_dir) - - def exit_file_manager(self, *args): - """Close the file manager""" - self.file_manager.close() - - def select_torrent_file(self, path): - """Handle torrent file selection""" - self.file_manager.close() - # Check if the file is a torrent file - if path.endswith('.torrent'): - self.show_torrent_content_dialog(path) - else: - from kivymd.toast import toast - toast("Выбранный файл не является торрент-файлом") - - def process_torrent_url(self, *args): - """Process the torrent URL entered by the user""" - url = self.url_field.text.strip() - if url.startswith('magnet:') or url.endswith('.torrent'): - self.add_dialog.dismiss() - self.show_torrent_content_dialog(url) - else: - from kivymd.toast import toast - toast("Введите корректную торрент-ссылку") - - def show_torrent_content_dialog(self, source): - """Show dialog with torrent content for selection""" - # Mock data for torrent content - mock_content = [ - {"name": "file1.mp4", "size": "1.2 GB", "selected": True}, - {"name": "file2.txt", "size": "15 KB", "selected": True}, - {"name": "file3.mp3", "size": "5.7 MB", "selected": True} - ] - - from kivymd.uix.list import MDList, OneLineAvatarIconListItem, IconLeftWidget, IconRightWidget - from kivymd.uix.selectioncontrol import MDCheckbox - - # Create content for the dialog - content = MDBoxLayout( - orientation='vertical', - spacing=dp(10), - padding=dp(10), - size_hint_y=None, - height=dp(300) - ) - - # Add a label for save path - save_path_box = MDBoxLayout( - orientation='horizontal', - size_hint_y=None, - height=dp(48) - ) - - from kivymd.uix.label import MDLabel - save_path_label = MDLabel( - text="Путь сохранения:", - size_hint_x=0.3 - ) - - self.save_path_field = MDTextField( - text=expanduser("~/Downloads"), - size_hint_x=0.7 - ) - - save_path_box.add_widget(save_path_label) - save_path_box.add_widget(self.save_path_field) - content.add_widget(save_path_box) - - # Create a list for files - file_list = MDList() - - # Add files to the list - for file in mock_content: - item = OneLineAvatarIconListItem(text=f"{file['name']} ({file['size']})") - - # Add file icon based on extension - file_ext = file['name'].split('.')[-1] if '.' in file['name'] else 'unknown' - icon_name = "file" - if file_ext in ['mp3', 'wav', 'ogg']: - icon_name = "music-note" - elif file_ext in ['mp4', 'avi', 'mkv']: - icon_name = "video" - elif file_ext in ['txt', 'pdf', 'doc', 'docx']: - icon_name = "file-document" - - item.add_widget(IconLeftWidget(icon=icon_name)) - - # Add checkbox for selection - check = MDCheckbox(active=file['selected']) - check.bind(active=lambda checkbox, value, file=file: self.toggle_file_selection(file, value)) - right_icon = IconRightWidget(widget=check) - item.add_widget(right_icon) - - file_list.add_widget(item) - - # Add the list to a scroll view - from kivy.uix.scrollview import ScrollView - scroll = ScrollView() - scroll.add_widget(file_list) - content.add_widget(scroll) - - # Create the dialog - from kivymd.uix.dialog import MDDialog - from kivymd.uix.button import MDFlatButton - - self.content_dialog = MDDialog( - title="Выберите файлы для загрузки", - type="custom", - content_cls=content, - buttons=[ - MDFlatButton( - text="ОТМЕНА", - on_release=lambda x: self.content_dialog.dismiss() - ), - MDFlatButton( - text="ЗАГРУЗИТЬ", - on_release=self.start_download - ), - ], - ) - - self.content_dialog.open() - - def toggle_file_selection(self, file, value): - """Toggle file selection""" - file['selected'] = value - - def start_download(self, *args): - """Start downloading selected files""" - # Here you would start the actual download process - # For now, we'll just close the dialog and add a mock file to the list - self.content_dialog.dismiss() - - # Add a new mock file to the list - new_file = { - 'name': 'new_download.mp4', - 'size': '45.8 mb', - 'type': 'mp4', - 'download_speed': '0mb/s', - 'upload_speed': '0mb/s', - 'blocks': [0] * 20 - } - - self.files.append(new_file) - self.update_file_list() - - def remove_torrent(self, index=None): - """Remove a torrent""" - if index is not None and 0 <= index < len(self.files): - # Remove the file at the specified index - del self.files[index] - # Update the file list to reflect the changes - self.update_file_list() - -class TorrentInnoApp(MDApp): - def build(self): - self.theme_cls.primary_palette = "BlueGray" - self.theme_cls.accent_palette = "Teal" - self.theme_cls.theme_style = "Light" - - return super().build() - - """ [DEPRECATED] - This part of code cause doublicationg of blocks - as in https://stackoverflow.com/questions/62752997/all-elements-rendering-twice-in-kivy-kivymd - """ - # root = Builder.load_file('torrentinno.kv') - # return root - - # screen manager and add it to the root widget - -if __name__ == '__main__': +from kivymd.app import MDApp +from kivy.lang import Builder +from kivy.clock import Clock +from kivy.uix.screenmanager import ScreenManager, Screen +from kivy.properties import ListProperty, StringProperty, NumericProperty +from kivy.metrics import dp +from kivymd.uix.boxlayout import MDBoxLayout +import torrent_manager + +class TorrentFileItem(MDBoxLayout): + """Class representing a single torrent file in the list""" + file_name = StringProperty('') + file_size = StringProperty('') + file_type = StringProperty('') + download_speed = StringProperty('') + upload_speed = StringProperty('') + blocks = ListProperty([]) + index = NumericProperty(-1) # Store the index of this item in the files list + + def __init__(self, **kwargs): + # Initialize blocks properly to avoid shared list issue + blocks_data = kwargs.pop('blocks', [0] * 20) + self.index = kwargs.pop('index', -1) # Get the index from kwargs + super(TorrentFileItem, self).__init__(**kwargs) + self.blocks = blocks_data + # Variables for double click detection + self.last_click_time = 0 + self.double_click_timeout = 0.3 # 300ms for double click detection + + def on_touch_down(self, touch): + """Handle touch down event""" + if self.collide_point(*touch.pos): + # Start a clock to detect long press + touch.ud['long_press'] = Clock.schedule_once(lambda dt: self.on_long_press(touch), 0.5) # 500ms for long press + + # Double click detection + current_time = Clock.get_time() + if current_time - self.last_click_time < self.double_click_timeout: + # This is a double click + self.on_double_click() + # Cancel the long press detection since we've detected a double click + if 'long_press' in touch.ud: + Clock.unschedule(touch.ud['long_press']) + self.last_click_time = current_time + + return super(TorrentFileItem, self).on_touch_down(touch) + + def on_touch_up(self, touch): + """Handle touch up event""" + if 'long_press' in touch.ud: + # Cancel the long press clock if touch is released + Clock.unschedule(touch.ud['long_press']) + return super(TorrentFileItem, self).on_touch_up(touch) + + def on_double_click(self): + """Handle double click event""" + # Show the same options dialog as for long press + self.show_options_dialog() + + def on_long_press(self, touch): + """Handle long press event""" + if self.collide_point(*touch.pos): + self.show_options_dialog() + + def show_options_dialog(self): + """Show options dialog for the torrent file""" + from kivymd.uix.dialog import MDDialog + from kivymd.uix.button import MDFlatButton + + self.dialog = MDDialog( + title=f"Действия с {self.file_name}", + text="Выберите действие:", + buttons=[ + MDFlatButton( + text="Удалить", + on_release=lambda x: self.delete_item() + ), + MDFlatButton( + text="Назад", + on_release=lambda x: self.dialog.dismiss() + ), + ], + ) + self.dialog.open() + + def delete_item(self): + """Delete this item""" + # Close the dialog + self.dialog.dismiss() + # Call the remove_torrent method of MainScreen + app = MDApp.get_running_app() + main_screen = app.root.get_screen('main') + main_screen.remove_torrent(self.index) + +class MainScreen(Screen): + """Main screen of the application showing the list of torrent files""" + files = ListProperty([]) + + def __init__(self, **kwargs): + super(MainScreen, self).__init__(**kwargs) + # Инициализируем менеджер торрентов + torrent_manager.initialize() + # Получаем список файлов из менеджера торрентов + self.files = torrent_manager.get_files() + # Start the clock to update download progress every second + Clock.schedule_interval(self.update_progress, 1) + + def on_kv_post(self, base_widget): + """Called after the kv file is loaded""" + self.update_file_list() + + def update_progress(self, dt): + """Update the download progress of files""" + self.files = torrent_manager.update_files() + self.update_file_list() + + def update_file_list(self): + """Update the file list with current data""" + file_list = self.ids.file_list + file_list.clear_widgets() + + for i, file in enumerate(self.files): + item = TorrentFileItem( + file_name=file['name'], + file_size=file['size'], + file_type=file['type'], + download_speed=file['download_speed'], + upload_speed=file['upload_speed'], + blocks=file['blocks'].copy(), # Use a copy to prevent reference issues + index=i # Pass the index to the item + ) + + # Create progress blocks + progress_container = item.ids.progress_container + progress_container.clear_widgets() + + # Use item.blocks instead of file['blocks'] to avoid duplication + for i, block in enumerate(item.blocks): + block_widget = MDBoxLayout(size_hint_x=1/len(item.blocks)) + if block == 1: # Downloaded block + block_widget.md_bg_color = (0.3, 0.8, 0.3, 1) # Green + else: # Not downloaded block + block_widget.md_bg_color = (1, 1, 1, 1) # White + + progress_container.add_widget(block_widget) + + file_list.add_widget(item) + + def on_back_pressed(self): + """Handle back button press""" + pass # this would navigate back + + def show_info(self): + """Show app information""" + pass # this would show an info dialog + + def show_menu(self): + """Show app menu""" + pass # this would show a menu + + def add_torrent(self): + """Add a new torrent""" + from kivymd.uix.dialog import MDDialog + from kivymd.uix.button import MDFlatButton + from kivymd.uix.textfield import MDTextField + from kivymd.uix.boxlayout import MDBoxLayout + from kivymd.uix.filemanager import MDFileManager + from kivy.core.window import Window + from os.path import expanduser + import os + + # Create a custom content for the dialog + self.dialog_content = MDBoxLayout( + orientation='vertical', + spacing=dp(10), + padding=dp(10), + adaptive_height=True + ) + + # Add a text field for torrent URL + self.url_field = MDTextField( + hint_text="Введите торрент-ссылку", + helper_text="Например: magnet:?xt=urn:btih:...", + helper_text_mode="on_focus", + size_hint_y=None, + height=dp(48) + ) + self.dialog_content.add_widget(self.url_field) + + # Create the dialog + self.add_dialog = MDDialog( + title="Добавить торрент", + type="custom", + content_cls=self.dialog_content, + buttons=[ + MDFlatButton( + text="ВЫБРАТЬ ФАЙЛ", + on_release=self.open_file_manager + ), + MDFlatButton( + text="ОТМЕНА", + on_release=lambda x: self.add_dialog.dismiss() + ), + MDFlatButton( + text="ДОБАВИТЬ", + on_release=self.process_torrent_url + ), + ], + ) + + # Initialize file manager + self.file_manager = MDFileManager( + exit_manager=self.exit_file_manager, + select_path=self.select_torrent_file, + preview=True, + ) + + # Show the dialog + self.add_dialog.open() + + def open_file_manager(self, *args): + """Open file manager to select a torrent file""" + # Set the starting path to user's home directory + home_dir = expanduser("~") + self.file_manager.show(home_dir) + + def exit_file_manager(self, *args): + """Close the file manager""" + self.file_manager.close() + + def select_torrent_file(self, path): + """Handle torrent file selection""" + self.file_manager.close() + # Check if the file is a torrent file + if path.endswith('.torrent'): + # Сохраняем путь к файлу для использования в start_download + self.current_torrent_url = path + self.show_torrent_content_dialog(path) + else: + from kivymd.toast import toast + toast("Выбранный файл не является торрент-файлом") + + def process_torrent_url(self, *args): + """Process the torrent URL entered by the user""" + url = self.url_field.text.strip() + if url.startswith('magnet:') or url.endswith('.torrent'): + self.add_dialog.dismiss() + # Сохраняем URL для использования в start_download + self.current_torrent_url = url + self.show_torrent_content_dialog(url) + else: + from kivymd.toast import toast + toast("Введите корректную торрент-ссылку") + + def show_torrent_content_dialog(self, source): + """Show dialog with torrent content for selection""" + # Получаем данные о содержимом торрента из менеджера + mock_content = torrent_manager.get_mock_content(source) + + from kivymd.uix.list import MDList, OneLineAvatarIconListItem, IconLeftWidget, IconRightWidget + from kivymd.uix.selectioncontrol import MDCheckbox + + # Create content for the dialog + content = MDBoxLayout( + orientation='vertical', + spacing=dp(10), + padding=dp(10), + size_hint_y=None, + height=dp(300) + ) + + # Add a label for save path + save_path_box = MDBoxLayout( + orientation='horizontal', + size_hint_y=None, + height=dp(48) + ) + + from kivymd.uix.label import MDLabel + save_path_label = MDLabel( + text="Путь сохранения:", + size_hint_x=0.3 + ) + + self.save_path_field = MDTextField( + text=expanduser("~/Downloads"), + size_hint_x=0.7 + ) + + save_path_box.add_widget(save_path_label) + save_path_box.add_widget(self.save_path_field) + content.add_widget(save_path_box) + + # Create a list for files + file_list = MDList() + + # Add files to the list + for file in mock_content: + item = OneLineAvatarIconListItem(text=f"{file['name']} ({file['size']})") + + # Add file icon based on extension + file_ext = file['name'].split('.')[-1] if '.' in file['name'] else 'unknown' + icon_name = "file" + if file_ext in ['mp3', 'wav', 'ogg']: + icon_name = "music-note" + elif file_ext in ['mp4', 'avi', 'mkv']: + icon_name = "video" + elif file_ext in ['txt', 'pdf', 'doc', 'docx']: + icon_name = "file-document" + + item.add_widget(IconLeftWidget(icon=icon_name)) + + # Add checkbox for selection + check = MDCheckbox(active=file['selected']) + check.bind(active=lambda checkbox, value, file=file: self.toggle_file_selection(file, value)) + right_icon = IconRightWidget(widget=check) + item.add_widget(right_icon) + + file_list.add_widget(item) + + # Add the list to a scroll view + from kivy.uix.scrollview import ScrollView + scroll = ScrollView() + scroll.add_widget(file_list) + content.add_widget(scroll) + + # Create the dialog + from kivymd.uix.dialog import MDDialog + from kivymd.uix.button import MDFlatButton + + self.content_dialog = MDDialog( + title="Выберите файлы для загрузки", + type="custom", + content_cls=content, + buttons=[ + MDFlatButton( + text="ОТМЕНА", + on_release=lambda x: self.content_dialog.dismiss() + ), + MDFlatButton( + text="ЗАГРУЗИТЬ", + on_release=self.start_download + ), + ], + ) + + self.content_dialog.open() + + def toggle_file_selection(self, file, value): + """Toggle file selection""" + file['selected'] = value + + def start_download(self, *args): + """Start downloading selected files""" + # Закрываем диалог + self.content_dialog.dismiss() + + # Получаем информацию о торренте из менеджера + url = getattr(self, 'current_torrent_url', '') + if url: + file_info = torrent_manager.get_file_info(url) + # Обновляем список файлов + self.files = torrent_manager.get_files() + self.update_file_list() + + def remove_torrent(self, index=None): + """Remove a torrent""" + if index is not None and 0 <= index < len(self.files): + # Получаем имя файла для удаления + file_name = self.files[index]['name'] + # Удаляем торрент через менеджер + torrent_manager.remove_torrent(file_name) + # Обновляем локальный список файлов + self.files = torrent_manager.get_files() + # Обновляем отображение + self.update_file_list() + +class TorrentInnoApp(MDApp): + def build(self): + self.theme_cls.primary_palette = "BlueGray" + self.theme_cls.accent_palette = "Teal" + self.theme_cls.theme_style = "Light" + + return super().build() + + def on_stop(self): + """Вызывается при закрытии приложения""" + # Сохраняем состояние торрентов + torrent_manager.shutdown() + + """ [DEPRECATED] + This part of code cause doublicationg of blocks + as in https://stackoverflow.com/questions/62752997/all-elements-rendering-twice-in-kivy-kivymd + """ + # root = Builder.load_file('torrentinno.kv') + # return root + + # screen manager and add it to the root widget + +if __name__ == '__main__': TorrentInnoApp().run() \ No newline at end of file diff --git a/client/gui/torrent_manager.py b/client/gui/torrent_manager.py new file mode 100644 index 0000000..5c4c802 --- /dev/null +++ b/client/gui/torrent_manager.py @@ -0,0 +1,254 @@ +import random +import json +import os + +# Путь к файлу для сохранения состояния торрентов +TORRENT_STATE_FILE = 'torrent_state.json' + +# Тестовые данные для торрент-файлов +_MOCK_FILES = [ + { + 'name': 'example.txt', + 'size': '1.23 kb', + 'type': 'txt', + 'download_speed': '1kb/s', + 'upload_speed': '0kb/s', + 'blocks': [0] * 20 # 0 означает не загружено, 1 означает загружено + }, + { + 'name': 'music.mp3', + 'size': '2.03 mb', + 'type': 'mp3', + 'download_speed': '2mb/s', + 'upload_speed': '1mb/s', + 'blocks': [0] * 20 + }, + { + 'name': 'video.mp4', + 'size': '12.7 mb', + 'type': 'mp4', + 'download_speed': '1mb/s', + 'upload_speed': '1mb/s', + 'blocks': [0] * 20 + }, + { + 'name': 'unknown', + 'size': '1.097 Gb', + 'type': 'unknown', + 'download_speed': '3mb/s', + 'upload_speed': '2 mb/s', + 'blocks': [0] * 20 + } +] + +# Список активных торрентов +_active_torrents = [] + +def _load_torrent_state(): + """Загружает состояние торрентов из файла""" + if os.path.exists(TORRENT_STATE_FILE): + try: + with open(TORRENT_STATE_FILE, 'r') as f: + return json.load(f) + except Exception as e: + print(f"Ошибка при загрузке состояния торрентов: {e}") + return [] + +def _save_torrent_state(): + """Сохраняет состояние торрентов в файл""" + try: + with open(TORRENT_STATE_FILE, 'w') as f: + json.dump(_active_torrents, f) + except Exception as e: + print(f"Ошибка при сохранении состояния торрентов: {e}") + +def initialize(): + """Инициализирует менеджер торрентов, загружая сохраненное состояние""" + global _active_torrents + saved_state = _load_torrent_state() + if saved_state: + _active_torrents = saved_state + else: + # Если нет сохраненного состояния, используем тестовые данные + _active_torrents = [] + for file in _MOCK_FILES: + _active_torrents.append(file.copy()) + +def shutdown(): + """Завершает работу менеджера торрентов, сохраняя текущее состояние""" + _save_torrent_state() + +def get_files(): + """Возвращает список всех торрент-файлов + + Returns: + list: Список словарей с информацией о торрент-файлах + """ + return [file.copy() for file in _active_torrents] + +def update_file(file_name): + """Обновляет информацию о конкретном торрент-файле + + Args: + file_name (str): Имя файла для обновления + + Returns: + dict: Словарь с обновленной информацией о скорости и блоках, + или None если файл не найден + """ + for file in _active_torrents: + if file['name'] == file_name: + # Обновляем скорость загрузки и выгрузки + _update_file_speeds(file) + # Обновляем блоки загрузки + _update_file_blocks(file) + + # Возвращаем только нужные поля + return { + 'download_speed': file['download_speed'], + 'upload_speed': file['upload_speed'], + 'blocks': file['blocks'].copy() + } + return None + +def _update_file_speeds(file): + """Обновляет скорость загрузки и выгрузки для файла + + Args: + file (dict): Словарь с информацией о файле + """ + # Извлекаем текущие значения скорости + download_value = float(file['download_speed'].split('mb/s')[0].split('kb/s')[0].strip()) + upload_value = float(file['upload_speed'].split('mb/s')[0].split('kb/s')[0].strip()) + + # Случайно изменяем скорость + download_value += random.uniform(-0.5, 0.5) + upload_value += random.uniform(-0.3, 0.3) + + # Убеждаемся, что скорость не опускается ниже 0.1 + download_value = max(0.1, download_value) + upload_value = max(0.1, upload_value) + + # Обновляем значения скорости + if 'kb/s' in file['download_speed']: + file['download_speed'] = f"{download_value:.1f}kb/s" + else: + file['download_speed'] = f"{download_value:.1f}mb/s" + + if 'kb/s' in file['upload_speed']: + file['upload_speed'] = f"{upload_value:.1f}kb/s" + else: + file['upload_speed'] = f"{upload_value:.1f}mb/s" + +def _update_file_blocks(file): + """Обновляет блоки загрузки для файла + + Args: + file (dict): Словарь с информацией о файле + """ + # Случайно выбираем блок для отметки как загруженный + if 0 in file['blocks']: # Если есть еще блоки для загрузки + zero_indices = [i for i, x in enumerate(file['blocks']) if x == 0] + if zero_indices: # Если есть блоки, которые еще не загружены + # Случайно выбираем 1-3 блока для отметки как загруженные + num_blocks = min(random.randint(1, 3), len(zero_indices)) + for _ in range(num_blocks): + if zero_indices: # Проверяем снова, на случай если мы использовали все индексы + idx = random.choice(zero_indices) + file['blocks'][idx] = 1 + zero_indices.remove(idx) + +def update_files(): + """Обновляет информацию о всех торрент-файлах + + Returns: + list: Список обновленных торрент-файлов + """ + for file in _active_torrents: + _update_file_speeds(file) + _update_file_blocks(file) + + return get_files() + +def get_file_info(url): + """Получает информацию о торрент-файле по URL + + Args: + url (str): URL торрент-файла или magnet-ссылка + + Returns: + dict: Словарь с информацией о торрент-файле + """ + # В реальном приложении здесь был бы код для получения информации о торренте + # Сейчас просто возвращаем тестовые данные + + # Генерируем случайное имя файла на основе URL + import hashlib + name_hash = hashlib.md5(url.encode()).hexdigest()[:8] + + # Создаем новый файл с тестовыми данными + new_file = { + 'name': f'torrent_{name_hash}', + 'size': '1.097 Gb', + 'type': 'unknown', + 'download_speed': '0mb/s', + 'upload_speed': '0mb/s', + 'blocks': [0] * 20 + } + + # Добавляем файл в список активных торрентов + _active_torrents.append(new_file) + + return new_file.copy() + +def add_torrent(file_info): + """Добавляет новый торрент в список активных + + Args: + file_info (dict): Информация о торрент-файле + + Returns: + bool: True если торрент успешно добавлен, иначе False + """ + if not file_info or 'name' not in file_info: + return False + + # Проверяем, не существует ли уже торрент с таким именем + for file in _active_torrents: + if file['name'] == file_info['name']: + return False + + # Добавляем новый торрент + _active_torrents.append(file_info.copy()) + return True + +def remove_torrent(file_name): + """Удаляет торрент из списка активных + + Args: + file_name (str): Имя файла для удаления + + Returns: + bool: True если торрент успешно удален, иначе False + """ + for i, file in enumerate(_active_torrents): + if file['name'] == file_name: + del _active_torrents[i] + return True + return False + +def get_mock_content(source): + """Возвращает тестовый список файлов в торренте + + Args: + source (str): URL или путь к торрент-файлу + + Returns: + list: Список словарей с информацией о файлах в торренте + """ + # Тестовые данные для содержимого торрента + return [ + {"name": "file1.mp4", "size": "1.2 GB", "selected": True}, + {"name": "file2.txt", "size": "15 KB", "selected": True}, + {"name": "file3.mp3", "size": "5.7 MB", "selected": True} + ] \ No newline at end of file diff --git a/client/gui/torrent_manager_docs.md b/client/gui/torrent_manager_docs.md new file mode 100644 index 0000000..461e1fe --- /dev/null +++ b/client/gui/torrent_manager_docs.md @@ -0,0 +1,142 @@ +## Функции + +### initialize() + +```python +def initialize() +``` + +Инициализирует менеджер торрентов, загружая сохраненное состояние из файла. Если сохраненное состояние отсутствует, используются тестовые данные. + +**Возвращаемое значение:** Нет + +### shutdown() + +```python +def shutdown() +``` + +Завершает работу менеджера торрентов, сохраняя текущее состояние в файл. + +**Возвращаемое значение:** Нет + +### get_files() + +```python +def get_files() +``` + +Возвращает список всех активных торрент-файлов. + +**Возвращаемое значение:** Список словарей с информацией о торрент-файлах. Каждый словарь содержит следующие ключи: +- `name`: Имя файла +- `size`: Размер файла (строка с единицами измерения) +- `type`: Тип файла +- `download_speed`: Скорость загрузки (строка с единицами измерения) +- `upload_speed`: Скорость выгрузки (строка с единицами измерения) +- `blocks`: Список блоков файла (0 - не загружен, 1 - загружен) + +### update_file(file_name) + +```python +def update_file(file_name) +``` + +Обновляет информацию о конкретном торрент-файле. + +**Параметры:** +- `file_name`: Имя файла для обновления + +**Возвращаемое значение:** Словарь с обновленной информацией о скорости и блоках, или None если файл не найден. Словарь содержит следующие ключи: +- `download_speed`: Скорость загрузки +- `upload_speed`: Скорость выгрузки +- `blocks`: Список блоков файла + +### update_files() + +```python +def update_files() +``` + +Обновляет информацию о всех торрент-файлах. + +**Возвращаемое значение:** Список обновленных торрент-файлов (аналогично `get_files()`) + +### get_file_info(url) + +```python +def get_file_info(url) +``` + +Получает информацию о торрент-файле по URL. + +**Параметры:** +- `url`: URL торрент-файла или magnet-ссылка + +**Возвращаемое значение:** Словарь с информацией о торрент-файле, содержащий следующие ключи: +- `name`: Имя файла +- `size`: Размер файла +- `type`: Тип файла +- `download_speed`: Скорость загрузки (начальное значение) +- `upload_speed`: Скорость выгрузки (начальное значение) +- `blocks`: Список блоков файла (все блоки изначально не загружены) + +### add_torrent(file_info) + +```python +def add_torrent(file_info) +``` + +Добавляет новый торрент в список активных. + +**Параметры:** +- `file_info`: Словарь с информацией о торрент-файле + +**Возвращаемое значение:** `True` если торрент успешно добавлен, иначе `False` + +### remove_torrent(file_name) + +```python +def remove_torrent(file_name) +``` + +Удаляет торрент из списка активных. + +**Параметры:** +- `file_name`: Имя файла для удаления + +**Возвращаемое значение:** `True` если торрент успешно удален, иначе `False` + +### get_mock_content(source) + +```python +def get_mock_content(source) +``` + +Возвращает тестовый список файлов в торренте. + +**Параметры:** +- `source`: URL или путь к торрент-файлу + +**Возвращаемое значение:** Список словарей с информацией о файлах в торренте. Каждый словарь содержит следующие ключи: +- `name`: Имя файла +- `size`: Размер файла +- `selected`: Флаг выбора файла для загрузки + +## Внутренние функции + +### _load_torrent_state() + +Загружает состояние торрентов из файла. + +### _save_torrent_state() + +Сохраняет состояние торрентов в файл. + +### _update_file_speeds(file) + +Обновляет скорость загрузки и выгрузки для файла. + +### _update_file_blocks(file) + +Обновляет блоки загрузки для файла. \ No newline at end of file diff --git a/client/gui/torrentinno.kv b/client/gui/torrentinno.kv index 3a3d299..6e66c0e 100644 --- a/client/gui/torrentinno.kv +++ b/client/gui/torrentinno.kv @@ -1,146 +1,146 @@ -#:kivy 2.0.0 -#:import get_color_from_hex kivy.utils.get_color_from_hex - -: - orientation: 'vertical' - size_hint_y: None - height: dp(100) - padding: dp(5) - spacing: dp(2) - file_name: '' - file_size: '' - file_type: '' - download_speed: '' - upload_speed: '' - blocks: [] - - canvas.before: - Color: - rgba: 0.95, 0.95, 0.95, 1 - Rectangle: - pos: self.pos - size: self.size - Color: - rgba: 0.9, 0.9, 0.9, 1 - Line: - points: [self.x, self.y, self.x + self.width, self.y] - width: 1 - - MDBoxLayout: - orientation: 'horizontal' - size_hint_y: None - height: dp(70) - spacing: dp(10) - padding: dp(5) - - # File icon - MDBoxLayout: - size_hint_x: None - width: dp(60) - - Image: - source: 'data/file_icons/' + root.file_type + '.png' if root.file_type else 'data/file_icons/unknown.png' - size_hint: None, None - size: dp(50), dp(50) - pos_hint: {'center_x': 0.5, 'center_y': 0.5} - - #MDLabel: - # text: root.file_type - # halign: 'center' - # font_size: dp(14) - # size_hint_y: None - # height: dp(20) - # pos_hint: {'center_x': 0.5, 'bottom': 0} - - # File info - MDBoxLayout: - orientation: 'vertical' - spacing: dp(5) - - MDBoxLayout: - orientation: 'horizontal' - - MDLabel: - text: root.file_name - font_size: dp(18) - halign: 'left' - size_hint_x: 0.6 - - MDLabel: - text: root.download_speed - font_size: dp(16) - halign: 'right' - color: get_color_from_hex('#4CAF50') # Green color for download - size_hint_x: 0.2 - - MDLabel: - text: root.upload_speed - font_size: dp(16) - halign: 'right' - color: get_color_from_hex('#F44336') # Red color for upload - size_hint_x: 0.2 - - MDLabel: - text: root.file_size - font_size: dp(16) - halign: 'left' - - # Progress bar with blocks - MDBoxLayout: - size_hint_y: None - height: dp(20) - padding: [dp(70), 0, dp(10), 0] - - MDBoxLayout: - id: progress_container - orientation: 'horizontal' - spacing: dp(1) - - # This will be filled with block widgets in Python code - canvas: - Color: - rgba: 0.9, 0.9, 0.9, 1 - Rectangle: - pos: self.pos - size: self.size - - # Generate progress blocks - Widget: - canvas: - Color: - rgba: 0, 0, 0, 1 - Line: - rectangle: [self.x, self.y, self.width, self.height] - width: 1 - -: - name: 'main' - - MDBoxLayout: - orientation: 'vertical' - - # App bar - MDTopAppBar: - title: "TorrentInno" - elevation: 4 - left_action_items: [['arrow-left', lambda x: app.root.current_screen.on_back_pressed()]] - right_action_items: [['information', lambda x: app.root.current_screen.show_info()], ['dots-vertical', lambda x: app.root.current_screen.show_menu()]] - - # Scrollable list of torrent files - ScrollView: - do_scroll_x: False - - MDList: - id: file_list - spacing: dp(2) - padding: dp(5) - - # Add button (floating action button) - MDFloatingActionButton: - icon: 'plus' - pos_hint: {'right': 0.95, 'y': 0.01} - on_release: app.root.current_screen.add_torrent() - -ScreenManager: - MainScreen: +#:kivy 2.0.0 +#:import get_color_from_hex kivy.utils.get_color_from_hex + +: + orientation: 'vertical' + size_hint_y: None + height: dp(100) + padding: dp(5) + spacing: dp(2) + file_name: '' + file_size: '' + file_type: '' + download_speed: '' + upload_speed: '' + blocks: [] + + canvas.before: + Color: + rgba: 0.95, 0.95, 0.95, 1 + Rectangle: + pos: self.pos + size: self.size + Color: + rgba: 0.9, 0.9, 0.9, 1 + Line: + points: [self.x, self.y, self.x + self.width, self.y] + width: 1 + + MDBoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: dp(70) + spacing: dp(10) + padding: dp(5) + + # File icon + MDBoxLayout: + size_hint_x: None + width: dp(60) + + Image: + source: 'data/file_icons/' + root.file_type + '.png' if root.file_type else 'data/file_icons/unknown.png' + size_hint: None, None + size: dp(50), dp(50) + pos_hint: {'center_x': 0.5, 'center_y': 0.5} + + #MDLabel: + # text: root.file_type + # halign: 'center' + # font_size: dp(14) + # size_hint_y: None + # height: dp(20) + # pos_hint: {'center_x': 0.5, 'bottom': 0} + + # File info + MDBoxLayout: + orientation: 'vertical' + spacing: dp(5) + + MDBoxLayout: + orientation: 'horizontal' + + MDLabel: + text: root.file_name + font_size: dp(18) + halign: 'left' + size_hint_x: 0.6 + + MDLabel: + text: root.download_speed + font_size: dp(16) + halign: 'right' + color: get_color_from_hex('#4CAF50') # Green color for download + size_hint_x: 0.2 + + MDLabel: + text: root.upload_speed + font_size: dp(16) + halign: 'right' + color: get_color_from_hex('#F44336') # Red color for upload + size_hint_x: 0.2 + + MDLabel: + text: root.file_size + font_size: dp(16) + halign: 'left' + + # Progress bar with blocks + MDBoxLayout: + size_hint_y: None + height: dp(20) + padding: [dp(70), 0, dp(10), 0] + + MDBoxLayout: + id: progress_container + orientation: 'horizontal' + spacing: dp(1) + + # This will be filled with block widgets in Python code + canvas: + Color: + rgba: 0.9, 0.9, 0.9, 1 + Rectangle: + pos: self.pos + size: self.size + + # Generate progress blocks + Widget: + canvas: + Color: + rgba: 0, 0, 0, 1 + Line: + rectangle: [self.x, self.y, self.width, self.height] + width: 1 + +: + name: 'main' + + MDBoxLayout: + orientation: 'vertical' + + # App bar + MDTopAppBar: + title: "TorrentInno" + elevation: 4 + left_action_items: [['arrow-left', lambda x: app.root.current_screen.on_back_pressed()]] + right_action_items: [['information', lambda x: app.root.current_screen.show_info()], ['dots-vertical', lambda x: app.root.current_screen.show_menu()]] + + # Scrollable list of torrent files + ScrollView: + do_scroll_x: False + + MDList: + id: file_list + spacing: dp(2) + padding: dp(5) + + # Add button (floating action button) + MDFloatingActionButton: + icon: 'plus' + pos_hint: {'right': 0.95, 'y': 0.01} + on_release: app.root.current_screen.add_torrent() + +ScreenManager: + MainScreen: name: 'main' \ No newline at end of file diff --git a/client/torentinno.py b/client/torentinno.py index c5cc45c..7ff55ea 100644 --- a/client/torentinno.py +++ b/client/torentinno.py @@ -11,12 +11,21 @@ from core.common.resource import Resource def generate_random_bits(size) -> bytes: + ''' + Generate random bits usign randint + ''' return bytes(random.randint(0, 255) for _ in range(size)) def generate_random_peer_id() -> str: + ''' + function what generate peerid + ''' return generate_random_bits(32).hex() def get_peer_public_ip(): + ''' + using request return public ip of the peer + ''' try: response = requests.get("https://api.ipify.org?format=json", timeout=5) return response.json()['ip'] @@ -24,6 +33,9 @@ def get_peer_public_ip(): return None async def share_file(path, resource: Resource): #name can be changed + ''' + Function to start sharing files + ''' peer_id = generate_random_peer_id() file_path = path resource_manager_instance = resource_manager.ResourceManager(peer_id, file_path, resource, has_file=True) @@ -41,7 +53,7 @@ async def share_file(path, resource: Resource): #name can be changed server_url = f"http://{resource.tracker_ip}:{resource.tracker_port}/peers" - while True: + while resource_manager_instance.share_file: # ? how to stop sharing of the file peer_list_str = await update_peer(server_url,peer_json) peer_list = json.loads(peer_list_str) await resource_manager_instance.submit_peers(peer_list) From 5e488cf40fa1f2cf78e9c93abd1dc7a9640e3463 Mon Sep 17 00:00:00 2001 From: Bulat Gazizov Date: Sun, 27 Apr 2025 16:59:46 +0300 Subject: [PATCH 16/46] feat(tracker): add peer tracker --- tracker/Dockerfile | 16 +++++++ tracker/go.mod | 34 +++++++++++++++ tracker/go.sum | 89 ++++++++++++++++++++++++++++++++++++++ tracker/main.go | 105 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 tracker/Dockerfile create mode 100644 tracker/go.mod create mode 100644 tracker/go.sum create mode 100644 tracker/main.go diff --git a/tracker/Dockerfile b/tracker/Dockerfile new file mode 100644 index 0000000..a3a5f90 --- /dev/null +++ b/tracker/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.23-bookworm AS base + +WORKDIR /build + +COPY go.mod go.sum ./ + +RUN go mod download + +COPY . . + +RUN go build -o peers-tracker + +EXPOSE 8080 + +# Start the application +CMD ["/build/peers-tracker"] diff --git a/tracker/go.mod b/tracker/go.mod new file mode 100644 index 0000000..4750fa7 --- /dev/null +++ b/tracker/go.mod @@ -0,0 +1,34 @@ +module peers-tracker + +go 1.23.0 + +require github.com/gin-gonic/gin v1.10.0 + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tracker/go.sum b/tracker/go.sum new file mode 100644 index 0000000..7f08abb --- /dev/null +++ b/tracker/go.sum @@ -0,0 +1,89 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/tracker/main.go b/tracker/main.go new file mode 100644 index 0000000..825cb89 --- /dev/null +++ b/tracker/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + "github.com/gin-gonic/gin" + "net/http" + "sync" + "time" +) + +type Peer struct { + PeerId string `json:"peerId"` + InfoHash string `json:"infoHash"` + PublicIp string `json:"publicIp"` + PublicPort string `json:"publicPort"` + UpdatedAt int64 `json:"-"` +} + +const PeerLifespan = 35 + +type Peers struct { + mu sync.Mutex + v map[string]map[string]Peer +} + +var peers = Peers{v: make(map[string]map[string]Peer)} + +func main() { + + //start peers cleaning task + go tick(1) + + router := gin.New() + router.Use( + gin.Recovery(), + ) + router.GET("/peers", getPeers) + router.POST("/peers", updatePeer) + s := &http.Server{ + Addr: ":8080", + Handler: router, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + } + err := s.ListenAndServe() + + if err != nil { + fmt.Print(err) + return + } + +} + +func updatePeer(context *gin.Context) { + peers.mu.Lock() + defer peers.mu.Unlock() + var updatedPeer Peer + if err := context.BindJSON(&updatedPeer); err != nil { + fmt.Println(err) + err = context.AbortWithError(http.StatusBadRequest, err) + return + } + + updatedPeer.UpdatedAt = time.Now().Unix() + if _, ok := peers.v[updatedPeer.InfoHash]; !ok { + peers.v[updatedPeer.InfoHash] = make(map[string]Peer) + } + peers.v[updatedPeer.InfoHash][updatedPeer.PeerId] = updatedPeer + + type Response struct { + InfoHash string `json:"infoHash"` + Peers []Peer `json:"peers"` + } + + var response Response + response.InfoHash = updatedPeer.InfoHash + for _, peer := range peers.v[updatedPeer.InfoHash] { + response.Peers = append(response.Peers, peer) + } + + context.JSON(http.StatusOK, response) +} + +func getPeers(context *gin.Context) { + peers.mu.Lock() + defer peers.mu.Unlock() + context.JSON(http.StatusOK, peers.v) +} + +func tick(n time.Duration) { + for range time.Tick(n * time.Second) { + peers.mu.Lock() + currentTime := time.Now().Unix() + for hash, friends := range peers.v { + for peerId, peer := range friends { + if peer.UpdatedAt+PeerLifespan < currentTime { + fmt.Printf("Peer %s seems to death. Removing..", peerId) + delete(peers.v[hash], peerId) + } + } + } + peers.mu.Unlock() + } +} From be88d672cae5fa7aed3f4a7622fce440046c0e69 Mon Sep 17 00:00:00 2001 From: Bulat Gazizov Date: Sun, 27 Apr 2025 17:11:00 +0300 Subject: [PATCH 17/46] fix(tracker): correct typo in module name --- tracker/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tracker/go.mod b/tracker/go.mod index 4750fa7..e2a79ae 100644 --- a/tracker/go.mod +++ b/tracker/go.mod @@ -1,4 +1,4 @@ -module peers-tracker +module tracker go 1.23.0 From a7ff03115ded22e4b8d8b6b53f473f746745e2ef Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Mon, 28 Apr 2025 01:50:42 +0300 Subject: [PATCH 18/46] Small fixes --- client/core/p2p/connection_listener.py | 2 +- client/core/p2p/resource_manager.py | 293 ++++++++++++--------- client/core/tests/resource_manager_test.py | 2 - 3 files changed, 171 insertions(+), 126 deletions(-) diff --git a/client/core/p2p/connection_listener.py b/client/core/p2p/connection_listener.py index c666ce4..65b8d55 100644 --- a/client/core/p2p/connection_listener.py +++ b/client/core/p2p/connection_listener.py @@ -1,4 +1,4 @@ -from core.p2p.message import Message, Request, Piece, Bitfield +from core.p2p.message import Request, Piece, Bitfield class ConnectionListener: diff --git a/client/core/p2p/resource_manager.py b/client/core/p2p/resource_manager.py index 693372e..87dc48f 100644 --- a/client/core/p2p/resource_manager.py +++ b/client/core/p2p/resource_manager.py @@ -6,7 +6,7 @@ from core.common.peer_info import PeerInfo from core.p2p.connection import Connection, establish_connection -from core.p2p.message import Handshake, Request, Piece, Bitfield +from core.p2p.message import Handshake, Request, Bitfield, Piece from core.p2p.resource_file import ResourceFile from core.common.resource import Resource from core.p2p.connection_listener import ConnectionListener @@ -39,12 +39,12 @@ def __init__( self.share_file = False # Peer dictionaries - self.connections: dict[str, Connection] = dict() # peer_id <-> Connection - self.bitfields: dict[str, list[bool]] = dict() # peer_id <-> bitfield (owned chunks) - self._free_peers: set[str] = set() # set of peer ids that are not involved in any work + self._connections: dict[str, Connection] = dict() # peer_id <-> Connection + self._bitfields: dict[str, list[bool]] = dict() # peer_id <-> bitfield (owned chunks) + self._free_peers: set[str] = set() # set of peer ids that are not involved in any work self.piece_status: list[ResourceManager.PieceStatus] = [] - if has_file: # The caller claims to already have the file + if has_file: # The caller claims to already have the file self.resource_file = ResourceFile( destination, resource, @@ -52,11 +52,11 @@ def __init__( initial_state=ResourceFile.State.DOWNLOADED ) self.piece_status = [ResourceManager.PieceStatus.SAVED] * len(self.resource.pieces) - else: # The caller does not the complete downloaded file + else: # The caller does not the complete downloaded file self.resource_file = ResourceFile( destination, resource, - fresh_install=True, # TODO: add normal restoring procedure (for now simply delete any previous files) + fresh_install=True, # TODO: add normal restoring procedure (for now simply delete any previous files) initial_state=ResourceFile.State.DOWNLOADING ) self.piece_status = [ResourceManager.PieceStatus.FREE] * len(self.resource.pieces) @@ -67,20 +67,15 @@ def __init__( # Various asyncio background tasks self._download_task: asyncio.Task | None = None self._server_task: asyncio.Task | None = None + self._broadcast_task: asyncio.Task | None = None - def _log(self, msg: str): + def _log_prefix(self, msg: str) -> str: return f"[ResourceManager peer_id={self.host_peer_id[:6]} info_hash={self.info_hash[:6]}] {msg}" - async def _send_bitfield(self, connection: Connection): - stored_pieces = list( - piece_status == ResourceManager.PieceStatus.SAVED for piece_status in self.piece_status - ) - await connection.send_message(Bitfield(stored_pieces)) - # Some new peer wants to connect to this peer async def _handle_incoming_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): host, port = writer.get_extra_info('peername') - logging.info(self._log(f"{host}:{port} is trying to connect")) + logging.info(self._log_prefix(f"{host}:{port} is trying to connect")) try: response = await reader.readexactly(75) assert response[0:11].decode() == 'TorrentInno' @@ -93,7 +88,7 @@ async def _handle_incoming_connection(self, reader: asyncio.StreamReader, writer raise RuntimeError(f"Peer {peer_id} has greater id and is trying to establish connection") # If we already have connection with this peer id -> abort the incoming connection - if peer_id in self.connections: + if peer_id in self._connections: return # If everything is correct, then send the response handshake message @@ -104,100 +99,14 @@ async def _handle_incoming_connection(self, reader: asyncio.StreamReader, writer connection = Connection(reader, writer, self.resource) await self._add_peer(peer_id, connection) - logging.info(self._log(f"Establish connection with {peer_id[:6]}")) + logging.info(self._log_prefix(f"Establish connection with {peer_id[:6]}")) except Exception as e: - logging.exception(self._log(f"Failed to handle incoming connection with {host}")) + logging.exception(self._log_prefix(f"Failed to handle incoming connection with {host}")) writer.close() await writer.wait_closed() def _create_connection_listener(self, peer_id: str) -> ConnectionListener: - class Listener(ConnectionListener): - def __init__(self, resource_manager: 'ResourceManager'): - self.resource_manager = resource_manager - - async def on_request(self, request: Request): - if not self.resource_manager.share_file: - logging.info(self.resource_manager._log(f"Ignore Request message from peer {peer_id[:6]}")) - return - - try: - data = await self.resource_manager.resource_file.get_block( - request.piece_index, - request.piece_inner_offset, - request.block_length - ) - connection = self.resource_manager.connections[peer_id] - await connection.send_message( - Piece( - request.piece_index, - request.piece_inner_offset, - request.block_length, - data - ) - ) - logging.info(self.resource_manager._log( - f"Send piece {request.piece_index} on Request message to peer {peer_id[:6]}")) - except Exception: - logging.exception(self.resource_manager._log(f"Exception on request message from peer {peer_id[:6]}")) - pass - - async def on_piece(self, piece: Piece): - # This peer is not in charge on this piece - if self.resource_manager._peer_in_charge[piece.piece_index] != peer_id: - logging.info( - self.resource_manager._log( - f"Discard piece {piece.piece_index} from {peer_id[:6]} as not in charge" - ) - ) - return - - self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.RECEIVED - try: - # Check that the received piece matches the hash - assert ( - hashlib.sha256(piece.data).hexdigest() == - self.resource_manager.resource.pieces[piece.piece_index].sha256 - ) - - await self.resource_manager.resource_file.save_block( - piece.piece_index, - piece.piece_inner_offset, - piece.data - ) - - # If the piece is saved, then broadcast the bitfield to all connections and change the status - self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.SAVED - - saved_pieces = sum( - piece_status == ResourceManager.PieceStatus.SAVED - for piece_status in self.resource_manager.piece_status - ) - logging.info(self.resource_manager._log(f"Save piece {piece.piece_index} from {peer_id[:6]}")) - - if saved_pieces == len(self.resource_manager.resource.pieces): - # The file is successfully downloaded! - await self.resource_manager._confirm_download_complete() - - await asyncio.gather( - *(self.resource_manager._send_bitfield(connection) - for connection in self.resource_manager.connections.values()), - return_exceptions=True - ) - except Exception: - logging.exception(self.resource_manager._log(f"Exception on piece message from peer {peer_id[:6]}")) - self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.FREE - pass - - async def on_bitfield(self, bitfield: Bitfield): - self.resource_manager.bitfields[peer_id] = bitfield.bitfield - logging.info(self.resource_manager._log(f"Bitfield from {peer_id[:6]}: {bitfield.bitfield}")) - - async def on_close(self, cause): - # The connection with peer for some reason is closed - logging.info(self.resource_manager._log(f"The connection with {peer_id[:6]} is closed")) - await self.resource_manager._remove_peer(peer_id) - - return Listener(self) + return ConnectionListenerImpl(peer_id, self) async def _confirm_download_complete(self): saved_pieces = sum( @@ -208,33 +117,33 @@ async def _confirm_download_complete(self): await self.resource_file.accept_download() await self.stop_download() - logging.info(self._log("Download is completed")) + logging.info(self._log_prefix("Download is completed")) async def _add_peer(self, peer_id: str, connection: Connection): - self.connections[peer_id] = connection - self.bitfields[peer_id] = [False] * len(self.resource.pieces) + self._connections[peer_id] = connection + self._bitfields[peer_id] = [False] * len(self.resource.pieces) self._free_peers.add(peer_id) connection.add_listener(self._create_connection_listener(peer_id)) await connection.listen() # Send the message about the stored pieces - await self._send_bitfield(connection) + await self._send_bitfield(peer_id) async def _remove_peer(self, peer_id: str): try: - await self.connections[peer_id].close() + await self._connections[peer_id].close() except Exception as e: # Ignore any exception with closing (probably peer_id either is not in list or connection is already closed pass - self.connections.pop(peer_id, None) - self.bitfields.pop(peer_id, None) + self._connections.pop(peer_id, None) + self._bitfields.pop(peer_id, None) self._free_peers.discard(peer_id) # -----MAIN DOWNLOAD LOGIC BEGINS HERE----- async def _download_work(self, peer_id: str, piece_index: int): - logging.info(self._log(f"Download Work on piece {piece_index} from peer {peer_id[:6]}")) - connection = self.connections[peer_id] + logging.info(self._log_prefix(f"Download Work on piece {piece_index} from peer {peer_id[:6]}")) + connection = self._connections[peer_id] await connection.send_message( Request( piece_index, @@ -250,7 +159,7 @@ async def _download_work(self, peer_id: str, piece_index: int): self.piece_status[piece_index] = ResourceManager.PieceStatus.FREE async def _download_loop(self): - logging.info(self._log("Start download loop")) + logging.info(self._log_prefix("Start download loop")) works = set() while True: # Find free pieces @@ -284,14 +193,41 @@ async def _download_loop(self): # -----END OF DOWNLOAD LOGIC----- def _peer_has_piece(self, peer_id: str, piece_index: int) -> bool: - return self.bitfields[peer_id][piece_index] == True + return self._bitfields[peer_id][piece_index] == True + + def _get_bitfield(self) -> list[bool]: + return list( + piece_status == ResourceManager.PieceStatus.SAVED for piece_status in self.piece_status + ) + + async def _send_bitfield(self, peer_id: str): + connection = self._connections[peer_id] + bitfield = Bitfield(self._get_bitfield()) + await connection.send_message(bitfield) + + async def _send_bitfield_to_all_peers(self): + bitfield = Bitfield(self._get_bitfield()) + await asyncio.gather( + *(connection.send_message(bitfield) + for connection in self._connections.values()), + return_exceptions=True + ) + + # Periodic broadcast with bitfield to compensate possible exceptions in the Piece message + async def _periodic_broadcast(self): + while True: + await self._send_bitfield_to_all_peers() + await asyncio.sleep(30) # Sleep for 30 seconds - async def _run_server_task(self, server: asyncio.Server): + async def _serve_forever(self, server: asyncio.Server): async with server: await server.serve_forever() # PUBLIC METHODS: async def open_public_port(self) -> int: + if self._server_task is not None: + raise RuntimeError("Peer is already accepting connections") + # Start accepting peer connections on some random port public_server = await asyncio.start_server( self._handle_incoming_connection, @@ -299,7 +235,12 @@ async def open_public_port(self) -> int: port=0 ) host, port = public_server.sockets[0].getsockname() - self._server_task = asyncio.create_task(self._run_server_task(public_server)) + self._server_task = asyncio.create_task(self._serve_forever(public_server)) + + # Also run the bitfield broadcast task + if self._broadcast_task is None: + self._broadcast_task = asyncio.create_task(self._periodic_broadcast()) + # Return port on which connection has been opened return port @@ -307,6 +248,11 @@ async def close_public_port(self): # Close the server_task connection if self._server_task is not None: self._server_task.cancel() + self._server_task = None + + if self._broadcast_task is not None: + self._broadcast_task.cancel() + self._broadcast_task = None async def start_download(self): # Start downloading the resource @@ -317,7 +263,7 @@ async def start_download(self): self._download_task = asyncio.create_task(self._download_loop()) async def stop_download(self): - logging.info(self._log("Stop download")) + logging.info(self._log_prefix("Stop download")) # Stop downloading the resource self._download_task.cancel() @@ -326,21 +272,122 @@ async def stop_download(self): async def submit_peers(self, peers: list[PeerInfo]): for peer in peers: if peer.peer_id == self.host_peer_id: - logging.warning(self._log("host_peer_id is passed in submit_peers")) + logging.warning(self._log_prefix("host_peer_id is passed in submit_peers")) continue # IMPORTANT RULE: The initiator of connection is always peer with the smaller id if self.host_peer_id < peer.peer_id: try: - if peer.peer_id not in self.connections: # We do not want repeating connections + if peer.peer_id not in self._connections: # We do not want repeating connections connection = await establish_connection(self.host_peer_id, peer, self.resource) await self._add_peer(peer.peer_id, connection) - logging.info(self._log(f"Establish connection with {peer.peer_id[:6]}")) + logging.info(self._log_prefix(f"Establish connection with {peer.peer_id[:6]}")) except Exception: - logging.exception(self._log(f"Exception while establishing connection with {peer.peer_id[:6]}")) + logging.exception( + self._log_prefix(f"Exception while establishing connection with {peer.peer_id[:6]}")) async def start_sharing_file(self): self.share_file = True async def stop_sharing_file(self): self.share_file = False + + +class ConnectionListenerImpl(ConnectionListener): + def __init__(self, connected_peer_id: str, resource_manager: ResourceManager): + self.resource_manager = resource_manager + self.connected_peer_id = connected_peer_id + + def _info_log(self, msg: str): + logging.info( + self.resource_manager._log_prefix(msg) + ) + + async def on_request(self, request: Request): + if not self.resource_manager.share_file: + self._info_log(f"Ignore Request message from peer {self.connected_peer_id[:6]} as sharing is disabled") + return + + try: + data = await self.resource_manager.resource_file.get_block( + request.piece_index, + request.piece_inner_offset, + request.block_length + ) + connection = self.resource_manager._connections[self.connected_peer_id] + await connection.send_message( + Piece( + request.piece_index, + request.piece_inner_offset, + request.block_length, + data + ) + ) + self._info_log(f"Send piece {request.piece_index} on Request message to peer {self.connected_peer_id[:6]}") + except Exception: + logging.exception( + self.resource_manager._log_prefix( + f"Exception on request message from peer {self.connected_peer_id[:6]}" + ) + ) + pass + + async def on_piece(self, piece: Piece): + # This peer is not in charge on this piece + if self.resource_manager._peer_in_charge[piece.piece_index] != self.connected_peer_id: + self._info_log(f"Discard piece {piece.piece_index} from {self.connected_peer_id[:6]} as not in charge") + return + + self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.RECEIVED + try: + # Check that the received piece matches the hash + expected_hash = hashlib.sha256(piece.data).hexdigest() + received_hash = self.resource_manager.resource.pieces[piece.piece_index].sha256 + + if expected_hash != received_hash: + logging.warning( + self.resource_manager._log_prefix( + f"Incorrect hash of piece {piece.piece_index} on Piece message from peer {self.connected_peer_id}\n" + + f"Expected: {expected_hash}\n" + f"Received: {received_hash}" + ) + ) + return + + await self.resource_manager.resource_file.save_block( + piece.piece_index, + piece.piece_inner_offset, + piece.data + ) + + # If the piece is saved, then broadcast the bitfield to all connections and change the status + self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.SAVED + + saved_pieces = sum( + piece_status == ResourceManager.PieceStatus.SAVED + for piece_status in self.resource_manager.piece_status + ) + self._info_log(f"Save piece {piece.piece_index} from {self.connected_peer_id[:6]}") + + if saved_pieces == len(self.resource_manager.resource.pieces): + # The file is successfully downloaded! + await self.resource_manager._confirm_download_complete() + + await self.resource_manager._send_bitfield_to_all_peers() + except Exception: + logging.exception( + self.resource_manager._log_prefix( + f"Exception on piece message from peer {self.connected_peer_id[:6]}" + ) + ) + self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.FREE + pass + + async def on_bitfield(self, bitfield: Bitfield): + self.resource_manager._bitfields[self.connected_peer_id] = bitfield.bitfield + self._info_log(f"Bitfield from {self.connected_peer_id[:6]}: {bitfield.bitfield}") + + async def on_close(self, cause): + # The connection with peer for some reason is closed + self._info_log(f"The connection with {self.connected_peer_id[:6]} is closed") + await self.resource_manager._remove_peer(self.connected_peer_id) diff --git a/client/core/tests/resource_manager_test.py b/client/core/tests/resource_manager_test.py index d6b4ba3..04b187f 100644 --- a/client/core/tests/resource_manager_test.py +++ b/client/core/tests/resource_manager_test.py @@ -95,8 +95,6 @@ async def main(): # ...watch the peers talking! - # Clean everything - if __name__ == "__main__": try: loop = asyncio.new_event_loop() From 50c616e190f6b6e3aa535f110b415842d6bafadc Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Mon, 28 Apr 2025 13:24:14 +0300 Subject: [PATCH 19/46] edited Torrentinno.py --- client/{torentinno.py => torrentInno.py} | 40 +++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) rename client/{torentinno.py => torrentInno.py} (75%) diff --git a/client/torentinno.py b/client/torrentInno.py similarity index 75% rename from client/torentinno.py rename to client/torrentInno.py index 7ff55ea..c38e9f6 100644 --- a/client/torentinno.py +++ b/client/torrentInno.py @@ -10,9 +10,11 @@ from core.common.peer_info import PeerInfo from core.common.resource import Resource +# --- utility functions --- + def generate_random_bits(size) -> bytes: ''' - Generate random bits usign randint + Generate random bits usign randint ''' return bytes(random.randint(0, 255) for _ in range(size)) @@ -32,7 +34,9 @@ def get_peer_public_ip(): except Exception as e: return None -async def share_file(path, resource: Resource): #name can be changed +# --- Torrent logic --- + +async def share_file(path, resource: Resource): ''' Function to start sharing files ''' @@ -41,7 +45,7 @@ async def share_file(path, resource: Resource): #name can be changed resource_manager_instance = resource_manager.ResourceManager(peer_id, file_path, resource, has_file=True) port = await resource_manager_instance.open_public_port() await resource_manager_instance.start_sharing_file() - peer_ip =get_peer_public_ip() + peer_ip = get_peer_public_ip() # TODO: Edit function to get IP peer_info = PeerInfo(peer_ip, port, peer_id) peer_json = { @@ -53,8 +57,36 @@ async def share_file(path, resource: Resource): #name can be changed server_url = f"http://{resource.tracker_ip}:{resource.tracker_port}/peers" - while resource_manager_instance.share_file: # ? how to stop sharing of the file + while resource_manager_instance.share_file: # ? how to stop sharing of the file peer_list_str = await update_peer(server_url,peer_json) peer_list = json.loads(peer_list_str) await resource_manager_instance.submit_peers(peer_list) time.sleep(25) + + +async def initialize(): + ... + +async def shutdown(): + ... + +async def get_files(): + ... + +async def update_file(file_name): + ... + +async def update_files(): + ... + +async def get_file_info(url): + ... + +async def add_torrent(file_info): + ... + +async def remove_torrent(file_name): + ... + +def get_mock_content(source): + ... From 8fdec909c4e6a445a39cbab15f0e9d33dd6416e4 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Mon, 28 Apr 2025 16:20:54 +0300 Subject: [PATCH 20/46] Add state saving to resource manager. Add documentation to public methods --- client/core/p2p/resource_file.py | 9 +- client/core/p2p/resource_manager.py | 246 +++++++++++++----- client/core/p2p/resource_manager_api.md | 121 +++++++++ client/core/p2p/resource_save.py | 24 ++ .../tests/advanved_resource_manager_test.py | 164 ++++++++++++ client/core/tests/resource_manager_test.py | 4 +- 6 files changed, 504 insertions(+), 64 deletions(-) create mode 100644 client/core/p2p/resource_manager_api.md create mode 100644 client/core/p2p/resource_save.py create mode 100644 client/core/tests/advanved_resource_manager_test.py diff --git a/client/core/p2p/resource_file.py b/client/core/p2p/resource_file.py index 56d0069..da73e5b 100644 --- a/client/core/p2p/resource_file.py +++ b/client/core/p2p/resource_file.py @@ -53,15 +53,20 @@ def _calculate_offset(self, piece_index: int, piece_inner_offset: int) -> int: return self.offsets[piece_index] + piece_inner_offset async def _create_downloading_destination(self): + self.downloading_destination.unlink(missing_ok=True) async with aiofiles.open(self.downloading_destination, mode='wb') as f: for piece in self.resource.pieces: await f.write(bytes([0] * piece.size_bytes)) async def _ensure_downloading_destination(self): async with self.lock: - if not self.downloading_destination.exists(): + if ( + not self.downloading_destination.exists() or + self.downloading_destination.stat().st_size != self.offsets[-1] + ): await self._create_downloading_destination() + async def get_piece(self, index: int) -> bytes: return await self.get_block(index, 0, self.resource.pieces[index].size_bytes) @@ -99,5 +104,7 @@ async def save_validated_piece(self, piece_index: int, data: bytes): await self.save_block(piece_index, 0, data) async def accept_download(self): + if self.destination.exists(): + self.destination.unlink() await aiofiles.os.rename(self.downloading_destination, self.destination) self.state = ResourceFile.State.DOWNLOADED diff --git a/client/core/p2p/resource_manager.py b/client/core/p2p/resource_manager.py index 87dc48f..e35b8a1 100644 --- a/client/core/p2p/resource_manager.py +++ b/client/core/p2p/resource_manager.py @@ -1,7 +1,7 @@ import asyncio import hashlib from pathlib import Path - +from dataclasses import dataclass import random from core.common.peer_info import PeerInfo @@ -13,6 +13,8 @@ from enum import Enum import logging +from core.p2p.resource_save import ResourceSave + class ResourceManager: class PieceStatus(Enum): @@ -21,53 +23,9 @@ class PieceStatus(Enum): RECEIVED = 3 # The data has been fetched from network and now is saving on disk SAVED = 4 # Piece is successfully saved on disk - def __init__( - self, - host_peer_id: str, - destination: Path, - resource: Resource, - has_file - ): - self.host_peer_id = host_peer_id - self.destination = destination - self.resource = resource - self.has_file = has_file - - self.info_hash = resource.get_info_hash() - - # If the peer can give file pieces - self.share_file = False - - # Peer dictionaries - self._connections: dict[str, Connection] = dict() # peer_id <-> Connection - self._bitfields: dict[str, list[bool]] = dict() # peer_id <-> bitfield (owned chunks) - self._free_peers: set[str] = set() # set of peer ids that are not involved in any work - - self.piece_status: list[ResourceManager.PieceStatus] = [] - if has_file: # The caller claims to already have the file - self.resource_file = ResourceFile( - destination, - resource, - fresh_install=False, - initial_state=ResourceFile.State.DOWNLOADED - ) - self.piece_status = [ResourceManager.PieceStatus.SAVED] * len(self.resource.pieces) - else: # The caller does not the complete downloaded file - self.resource_file = ResourceFile( - destination, - resource, - fresh_install=True, # TODO: add normal restoring procedure (for now simply delete any previous files) - initial_state=ResourceFile.State.DOWNLOADING - ) - self.piece_status = [ResourceManager.PieceStatus.FREE] * len(self.resource.pieces) - - # Current peer id that handles the piece (empty string=no peer) - self._peer_in_charge: list[str] = [''] * len(self.resource.pieces) - - # Various asyncio background tasks - self._download_task: asyncio.Task | None = None - self._server_task: asyncio.Task | None = None - self._broadcast_task: asyncio.Task | None = None + @dataclass + class State: + piece_status: list[bool] def _log_prefix(self, msg: str) -> str: return f"[ResourceManager peer_id={self.host_peer_id[:6]} info_hash={self.info_hash[:6]}] {msg}" @@ -117,6 +75,7 @@ async def _confirm_download_complete(self): await self.resource_file.accept_download() await self.stop_download() + await self.resource_save.remove_save() logging.info(self._log_prefix("Download is completed")) async def _add_peer(self, peer_id: str, connection: Connection): @@ -164,9 +123,15 @@ async def _download_loop(self): while True: # Find free pieces free_pieces: list[int] = [] + saved_pieces = 0 for i, status in enumerate(self.piece_status): if status == ResourceManager.PieceStatus.FREE: free_pieces.append(i) + if status == ResourceManager.PieceStatus.SAVED: + saved_pieces += 1 + + if saved_pieces == len(self.resource.pieces): + break # Shuffle the pieces random.shuffle(free_pieces) @@ -200,6 +165,12 @@ def _get_bitfield(self) -> list[bool]: piece_status == ResourceManager.PieceStatus.SAVED for piece_status in self.piece_status ) + async def _save_loading_state(self): + try: + await self.resource_save.write_bitfield(self._get_bitfield()) + except Exception: + logging.exception(self._log_prefix("Can't save bitfield")) + async def _send_bitfield(self, peer_id: str): connection = self._connections[peer_id] bitfield = Bitfield(self._get_bitfield()) @@ -223,8 +194,74 @@ async def _serve_forever(self, server: asyncio.Server): async with server: await server.serve_forever() + def __init__( + self, + host_peer_id: str, + destination: Path, + resource: Resource, + ): + """ + Create a new ResourceManager instance + + :param host_peer_id: the peer_id that will host the resource + :param destination: The destination of the file on the filesystem. Important: if the destination exists + on the moment the class is instantiated, then it's assumed that the caller has the `destination` file + and therefore the file will only be shared (and not downloaded) + :param resource: the resource class representing the class to be uploaded/downloaded + """ + self.host_peer_id = host_peer_id + self.destination = destination + self.resource = resource + + self.info_hash = resource.get_info_hash() + + # Save state for resource + self.resource_save = ResourceSave(destination, resource) + + # If the peer can give file pieces + self.share_file = False + + # Peer dictionaries + self._connections: dict[str, Connection] = dict() # peer_id <-> Connection + self._bitfields: dict[str, list[bool]] = dict() # peer_id <-> bitfield (owned chunks) + self._free_peers: set[str] = set() # set of peer ids that are not involved in any work + + self.piece_status: list[ResourceManager.PieceStatus] = [] + + has_file = destination.exists() + + if has_file: # The caller claims to already have the file + self.resource_file = ResourceFile( + destination, + resource, + fresh_install=False, + initial_state=ResourceFile.State.DOWNLOADED + ) + self.piece_status = [ResourceManager.PieceStatus.SAVED] * len(self.resource.pieces) + else: # The caller does not the complete downloaded file + self.resource_file = ResourceFile( + destination, + resource, + fresh_install=False, + initial_state=ResourceFile.State.DOWNLOADING + ) + self.piece_status = [ResourceManager.PieceStatus.FREE] * len(self.resource.pieces) + + # Current peer id that handles the piece (empty string=no peer) + self._peer_in_charge: list[str] = [''] * len(self.resource.pieces) + + # Various asyncio background tasks + self._download_task: asyncio.Task | None = None + self._server_task: asyncio.Task | None = None + self._broadcast_task: asyncio.Task | None = None + # PUBLIC METHODS: async def open_public_port(self) -> int: + """ + Opens a new socket that will be used to accept incoming connections from other peers + + :return: port on which the new socket is opened + """ if self._server_task is not None: raise RuntimeError("Peer is already accepting connections") @@ -245,6 +282,10 @@ async def open_public_port(self) -> int: return port async def close_public_port(self): + """ + Closes the socket that accepts the new connections (NOTE: after calling this method, the old port received in + `open_public_port` cannot be reused anymore as this port is received randomly from the OS) + """ # Close the server_task connection if self._server_task is not None: self._server_task.cancel() @@ -254,22 +295,99 @@ async def close_public_port(self): self._broadcast_task.cancel() self._broadcast_task = None + async def restore_previous(self): + """ + Attempts to restore the saved state and start download with this state (for example, to get which pieces + are already downloaded in order to not repeat the download work). + + NOTE: If the destination file exists, then this method + assumes that the file is already completely downloaded + (and so sets the status of all pieces as SAVED (or downloaded)) + """ + if self.destination.exists(): + self.piece_status = [ResourceManager.PieceStatus.SAVED] * len(self.resource.pieces) + self._peer_in_charge = [''] * len(self.resource.pieces) + return + + try: + bitfield = await self.resource_save.read_bitfield() + for i in range(len(self.piece_status)): + if bitfield[i]: + self.piece_status[i] = ResourceManager.PieceStatus.SAVED + self._peer_in_charge[i] = '' + logging.info(self._log_prefix(f"Restored bitfield: {bitfield}")) + except Exception as e: + logging.info(self._log_prefix(f"Failed to read bitfield: {e}")) + async def start_download(self): - # Start downloading the resource - if self.has_file: - raise RuntimeError("Cannot download existing file") + """ + Start downloading the file. If the destination file already exists, then this method does nothing. The file + will usually be downloaded into a specifically named temporary file located at the same folder as `destination`. + Once the ResourceManager detects, that the file is completely downloaded + it terminates the download and renames the temporary file into the `destination`. + """ + if self.destination.exists(): + return if self._download_task is None: self._download_task = asyncio.create_task(self._download_loop()) async def stop_download(self): + """ + Stop downloading the file. + """ logging.info(self._log_prefix("Stop download")) - # Stop downloading the resource - self._download_task.cancel() - self._download_task = None + if self._download_task is not None: + # Stop downloading the resource + self._download_task.cancel() + self._download_task = None + + async def start_sharing_file(self): + """ + Sets the flags that allows ResourceManager to share file (file pieces) with other peers. + This is the default behaviour. + """ + self.share_file = True + + async def stop_sharing_file(self): + """ + Forbid the ResourceManager to share file (file pieces) with other peers. + """ + self.share_file = False + + async def full_start(self) -> int: + """ + A convenience methods that automatically opens the public port of the resource manager, + starts download, attempt to restore the previous download state etc. + + :return: the same as `open_public_port()` + """ + await self.restore_previous() + await self.start_sharing_file() + await self.start_download() + listen_port = await self.open_public_port() + return listen_port + + async def shutdown(self): + """ + A convenience method that is the opposite of `full_start()` (i.e. close everything that was started/launched) + """ + await self.close_public_port() + await asyncio.gather( + *(connection.close() for connection in self._connections.values()), + return_exceptions=True + ) + await self.stop_download() + await self.stop_sharing_file() async def submit_peers(self, peers: list[PeerInfo]): + """ + The only way to tell ResourceManager about peers related to the resource. Usually these will be the peers fetched + from the tracker response on announce request with the *same* info hash as the RequestManager was created with. + + :param peers: The list of peers known to be related with the resource + """ for peer in peers: if peer.peer_id == self.host_peer_id: logging.warning(self._log_prefix("host_peer_id is passed in submit_peers")) @@ -286,11 +404,12 @@ async def submit_peers(self, peers: list[PeerInfo]): logging.exception( self._log_prefix(f"Exception while establishing connection with {peer.peer_id[:6]}")) - async def start_sharing_file(self): - self.share_file = True - - async def stop_sharing_file(self): - self.share_file = False + async def get_state(self) -> 'ResourceManager.State': + """ + Get the current state of the resource (i.e. downloaded pieces, upload/download speed etc.) + :return: The current state of the resource (file) + """ + return ResourceManager.State(self._get_bitfield()) class ConnectionListenerImpl(ConnectionListener): @@ -337,7 +456,6 @@ async def on_piece(self, piece: Piece): if self.resource_manager._peer_in_charge[piece.piece_index] != self.connected_peer_id: self._info_log(f"Discard piece {piece.piece_index} from {self.connected_peer_id[:6]} as not in charge") return - self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.RECEIVED try: # Check that the received piece matches the hash @@ -369,9 +487,15 @@ async def on_piece(self, piece: Piece): ) self._info_log(f"Save piece {piece.piece_index} from {self.connected_peer_id[:6]}") + # Also update the information about saved piece in the file: + await self.resource_manager._save_loading_state() + if saved_pieces == len(self.resource_manager.resource.pieces): # The file is successfully downloaded! - await self.resource_manager._confirm_download_complete() + try: + await self.resource_manager._confirm_download_complete() + except Exception: + logging.exception(self.resource_manager._log_prefix("Cannot complete download")) await self.resource_manager._send_bitfield_to_all_peers() except Exception: diff --git a/client/core/p2p/resource_manager_api.md b/client/core/p2p/resource_manager_api.md new file mode 100644 index 0000000..1618592 --- /dev/null +++ b/client/core/p2p/resource_manager_api.md @@ -0,0 +1,121 @@ +# Resource Manager API + +## Constructor +```python +def __init__( + self, + host_peer_id: str, + destination: Path, + resource: Resource, +): + """ + Create a new ResourceManager instance + + :param host_peer_id: the peer_id that will host the resource + :param destination: The destination of the file on the filesystem. Important: if the destination exists + on the moment the class is instantiated, then it's assumed that the caller has the `destination` file + and therefore the file will only be shared (and not downloaded) + :param resource: the resource class representing the class to be uploaded/downloaded + """ + ... +``` +## Public methods: +### The most useful ones: +```python +async def full_start(self) -> int: + """ + A convenience methods that automatically opens the public port of the resource manager, + starts download, attempt to restore the previous download state etc. + + :return: the same as `open_public_port()` + """ + ... +``` +```python +async def shutdown(self): + """ + A convenience method that is the opposite of `full_start()` (i.e. close everything that was started/launched) + """ + ... +``` +```python +async def submit_peers(self, peers: list[PeerInfo]): + """ + The only way to tell ResourceManager about peers related to the resource. Usually these will be peers fetched + from the tracker response on announce request with the *same* info hash as the RequestManager was created with. + + :param peers: The list of peers known to be related with the resource + """ + ... +``` +```python +async def get_state(self) -> 'ResourceManager.State': + """ + Get the current state of the resource (i.e. downloaded pieces, upload/download speed etc.) + :return: The current state of the resource (file) + """ + ... +``` + +### The rest public methods: +```python +async def open_public_port(self) -> int: + """ + Opens a new socket that will be used to accept incoming connections from other peers + + :return: port on which the new socket is opened + """ + ... +``` +```python +async def close_public_port(self): + """ + Closes the socket that accepts the new connections (NOTE: after calling this method, the old port received in + `open_public_port` cannot be reused anymore as this port is received randomly from the OS) + """ + ... +``` +```python +async def restore_previous(self): + """ + Attempts to restore the saved state and start download with this state (for example, to get which pieces + are already downloaded in order to not repeat the download work). + + NOTE: If the destination file exists, then this method + assumes that the file is already completely downloaded + (and so sets the status of all pieces as SAVED (or downloaded)) + """ + ... +``` +```python +async def start_download(self): + """ + Start downloading the file. If the destination file already exists, then this method does nothing. The file + will usually be downloaded into a specifically named temporary file located at the same folder as `destination`. + Once the ResourceManager detects, that the file is completely downloaded + it terminates the download and renames the temporary file into the `destination`. + """ + ... +``` +```python +async def stop_download(self): + """ + Stop downloading the file. + """ + ... +``` +```python +async def start_sharing_file(self): + """ + Sets the flags that allows ResourceManager to share file (file pieces) with other peers. + This is the default behaviour. + """ + ... +``` +```python +async def stop_sharing_file(self): + """ + Forbid the ResourceManager to share file (file pieces) with other peers. + """ + ... +``` \ No newline at end of file diff --git a/client/core/p2p/resource_save.py b/client/core/p2p/resource_save.py new file mode 100644 index 0000000..dbb0eea --- /dev/null +++ b/client/core/p2p/resource_save.py @@ -0,0 +1,24 @@ +from pathlib import Path +import json + +import aiofiles + +from core.common.resource import Resource + + +class ResourceSave: + def __init__(self, destination: Path, resource: Resource): + self.save_file = \ + destination.parent.joinpath(f".torrentinno_save-file_{destination.name}_{resource.get_info_hash()}") + + async def remove_save(self): + self.save_file.unlink(missing_ok=True) + + async def read_bitfield(self) -> list[bool]: + async with aiofiles.open(self.save_file, mode='r') as f: + result = json.loads(await f.read()) + return result + + async def write_bitfield(self, bitfield: list[bool]): + async with aiofiles.open(self.save_file, mode='w') as f: + await f.write(json.dumps(bitfield)) diff --git a/client/core/tests/advanved_resource_manager_test.py b/client/core/tests/advanved_resource_manager_test.py new file mode 100644 index 0000000..4dacdd6 --- /dev/null +++ b/client/core/tests/advanved_resource_manager_test.py @@ -0,0 +1,164 @@ +import asyncio +import datetime +import hashlib +from pathlib import Path +import random +import json +import shutil +import logging + +from core.common.peer_info import PeerInfo +from core.common.resource import Resource +from core.p2p.resource_file import ResourceFile +from core.p2p.resource_manager import ResourceManager +from core.p2p.resource_save import ResourceSave + +random.seed(0) + + +def random_bits(size) -> bytes: + return bytes(random.randint(0, 255) for _ in range(size)) + + +def random_peer_id() -> str: + return random_bits(32).hex() + + +async def simulate_ownership(bitfield: list[bool], data: list[bytes], destination: Path, resource: Resource): + resource_file = ResourceFile(destination, resource) + resource_save = ResourceSave(destination, resource) + await resource_save.write_bitfield(bitfield) + for i, piece_status in enumerate(bitfield): + if piece_status: + await resource_file.save_validated_piece(i, data[i]) + + +test_run = 1 + + +async def main(): + logging.basicConfig(level=logging.DEBUG) + + # Temporary directory + tmp = Path(__file__).parent.joinpath('tmp') + # shutil.rmtree(tmp, ignore_errors=True) + tmp.mkdir(parents=True, exist_ok=True) + + # Generate stub data + data: list[bytes] = [random_bits(random.randint(100, 1000)) for _ in range(10)] + pieces: list[Resource.Piece] = [ + Resource.Piece( + sha256=hashlib.sha256(piece_data).hexdigest(), + size_bytes=len(piece_data) + ) + for piece_data in data + ] + resource = Resource( + tracker_ip='0.0.0.0', + tracker_port=8080, + comment='Test file', + creation_date=datetime.datetime(year=2000, month=1, day=1, hour=1, minute=1, second=1), + name='Random testing file', + pieces=pieces + ) + + source_peer_id = random_peer_id() # peer_id is unique PER PEER (not per ResourceManager) + source_destination = tmp.joinpath('source', resource.name) + source_destination.parent.mkdir(parents=True, exist_ok=True) + + # Example where the initial peer has only some parts + source_bitfield = [False] * len(resource.pieces) + source_bitfield[0] = True + source_bitfield[1] = True + source_bitfield[-1] = True + await simulate_ownership(source_bitfield, data, source_destination, resource) + + # Set up source peer_id. This can be used as an example of working with ResourceManager + source_resource_manager = ResourceManager(source_peer_id, source_destination, resource) + source_port = await source_resource_manager.full_start() + source_peer_info = PeerInfo('127.0.0.1', source_port, source_peer_id) + + consumer_peer_ids = [random_peer_id() for _ in range(5)] + consumer_destinations = [tmp.joinpath(peer_id, resource.name) for peer_id in consumer_peer_ids] + for consumer_destination in consumer_destinations: + consumer_destination.parent.mkdir(parents=True, exist_ok=True) + + # For first consumer simulate ownership of some other parts + consumer_0_bitfield = [False] * len(resource.pieces) + consumer_0_bitfield[1] = True + consumer_0_bitfield[2] = True + consumer_0_bitfield[3] = True + consumer_0_bitfield[-2] = True + await simulate_ownership(consumer_0_bitfield, data, consumer_destinations[0], resource) + + consumer_resource_managers = [ + ResourceManager(consumer_peer_id, consumer_destination, resource) + for consumer_peer_id, consumer_destination in zip(consumer_peer_ids, consumer_destinations) + ] + consumer_ports = [ + await resource_manager.full_start() + for resource_manager in consumer_resource_managers + ] + consumer_peer_infos = [ + PeerInfo('127.0.0.1', port, peer_id) + for port, peer_id in zip(consumer_ports, consumer_peer_ids) + ] + + all_peer_infos = consumer_peer_infos + [source_peer_info] + + for resource_manager in consumer_resource_managers: + await resource_manager.start_download() + + # For all the peers, submit the PeerInfo list + await asyncio.gather( + source_resource_manager.submit_peers(all_peer_infos), + *( + consumer_resource_manager.submit_peers(all_peer_infos) + for consumer_resource_manager in consumer_resource_managers + ) + ) + + await asyncio.sleep(5) + + print("Source peer disconnected!") + await source_resource_manager.shutdown() # Sudden shutdown of source resource manager + + await asyncio.sleep(2) + print("Adding new sudden peer") + + sudden_peer_id = random_peer_id() + sudden_peer_destination = tmp.joinpath(sudden_peer_id, resource.name) + sudden_peer_destination.parent.mkdir(parents=True) + sudden_peer_bitfield = [False] * 10 + sudden_peer_bitfield[3:-2] = [True] * len(sudden_peer_bitfield[3:-2]) + await simulate_ownership(sudden_peer_bitfield, data, sudden_peer_destination, resource) + sudden_peer_resource_manager = ResourceManager(sudden_peer_id, sudden_peer_destination, resource) + sudden_peer_port = await sudden_peer_resource_manager.full_start() + sudden_peer_info = PeerInfo('127.0.0.1', sudden_peer_port, sudden_peer_id) + + all_peer_infos = all_peer_infos + [sudden_peer_info] + + # For all the peers, submit the PeerInfo (yes, again) + await asyncio.gather( + source_resource_manager.submit_peers(all_peer_infos), + *( + consumer_resource_manager.submit_peers(all_peer_infos) + for consumer_resource_manager in consumer_resource_managers + ) + ) + + +if __name__ == "__main__": + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.create_task(main()) + loop.run_forever() + print("\nSTOPPING LOOP\n") + loop.stop() + print("\nSTARTING AGAIN, NOW WITH SAVED DATA\n") + loop.create_task(main()) + finally: + pass + tmp = Path(__file__).parent.joinpath('tmp') + shutil.rmtree(tmp) diff --git a/client/core/tests/resource_manager_test.py b/client/core/tests/resource_manager_test.py index 04b187f..7a8365b 100644 --- a/client/core/tests/resource_manager_test.py +++ b/client/core/tests/resource_manager_test.py @@ -54,7 +54,7 @@ async def main(): # Set up source peer_id. This can be used as an example of working with ResourceManager source_peer_id = random_peer_id() # peer_id is unique PER PEER (not per ResourceManager) source_destination = source_file - source_resource_manager = ResourceManager(source_peer_id, source_destination, resource, has_file=True) + source_resource_manager = ResourceManager(source_peer_id, source_destination, resource) source_port = await source_resource_manager.open_public_port() await source_resource_manager.start_sharing_file() source_peer_info = PeerInfo('127.0.0.1', source_port, source_peer_id) @@ -65,7 +65,7 @@ async def main(): consumer_destination.parent.mkdir(parents=True) consumer_resource_managers = [ - ResourceManager(consumer_peer_id, consumer_destination, resource, has_file=False) + ResourceManager(consumer_peer_id, consumer_destination, resource) for consumer_peer_id, consumer_destination in zip(consumer_peer_ids, consumer_destinations) ] for consumer_resource_manager in consumer_resource_managers: From 79706b6fe9e40ae4389e9635c6c6bf1576a35fb8 Mon Sep 17 00:00:00 2001 From: Timofey Ivlev Date: Mon, 28 Apr 2025 21:43:19 +0300 Subject: [PATCH 21/46] Add Report.md --- Report.md | 231 +++++++++++++++++++++++++++++++++++++++++++++++ specs/schema.jpg | Bin 0 -> 118778 bytes 2 files changed, 231 insertions(+) create mode 100644 Report.md create mode 100644 specs/schema.jpg diff --git a/Report.md b/Report.md new file mode 100644 index 0000000..afda2f7 --- /dev/null +++ b/Report.md @@ -0,0 +1,231 @@ + +# TorrentInno Project Report + +## Introduction + +**Peer-to-peer (P2P)** file sharing protocols allow users to distribute files directly between themselves without relying on a central server for the file data itself. Systems like **BitTorrent** are widely used for distributing large files efficiently by breaking them into smaller pieces that peers download from each other and upload to others simultaneously. A central component, the "***tracker***" helps peers discover each other for a specific file (identified by an "***info-hash***"). + + **TorrentInno** is a custom implementation inspired by these principles, providing a framework for P2P file exchange coordinated by a tracker. Such systems are relevant for decentralized file distribution, reducing server load, and potentially increasing download speeds through parallel sourcing. + +## Methods + +### Overall Architecture + +The TorrentInno system consists of two main components: + +1. **Tracker:** A server application (implemented in Go) responsible for registering peers interested in a specific resource (file) and providing lists of peers to each other. +2. **Client:** A client application (implemented in Python) that interacts with the tracker to find peers and then communicates directly with those peers to download or upload file pieces. It includes a core P2P engine and a GUI layer (see [gui](client/gui)). + +Schema of the interaction: + +![Schema](specs/schema.jpg) + +### Peer Discovery (Tracker Interaction) + +As described in specifications [README.md](specs/README.md), the process begins with a peer announcing itself to the tracker: + +1. **Compute Info-Hash:** The client calculates a unique identifier (`info-hash`) for the resource (e.g., the `.torrentinno` file). The details are mentioned to be in the `client.core.common.Resource` class (method `get_info_hash`). +2. **Announce:** The client sends an HTTP request to the tracker's `/peers` endpoint. The request body contains the peer's ID (`peerId`), the `infoHash`, and the peer's public IP and port ([peer-announce.json](specs/peer-announce.json)). From [torentinno.py](client/torentinno.py): + * Peer ID generation: + ```python + def generate_random_bits(size) -> bytes: + ''' + Generate random bits usign randint + ''' + return bytes(random.randint(0, 255) for _ in range(size)) + + def generate_random_peer_id() -> str: + ''' + function what generate peerid + ''' + return generate_random_bits(32).hex() + ``` +3. **Receive Peer List:** The tracker responds with a list of other peers currently sharing the same `info-hash` (see [tracker-response.json](specs/tracker-response.json)). +4. **Periodic Updates:** The client periodically re-announces itself (approx. every 30 seconds) to stay listed by the tracker. The tracker removes peers that haven't announced recently. + +### Peer-to-Peer Communication + +Once a client has a list of peers, it attempts to establish direct connections ([peer-message-exchange.md](specs/peer-message-exchange.md)). + +1. **Handshake:** The initiating peer (determined by comparing peer IDs; the one with the smaller ID initiates) sends a 75-byte handshake message: + ``` + TorrentInno[peer-id (32 bytes)][info-hash (32 bytes)] + ``` + The receiving peer verifies the `info-hash`. If it matches a resource it manages, it replies with its own handshake message containing its `peer-id`. A successful handshake establishes a persistent, bidirectional connection for that specific resource. + +2. **Length-Prefixed Messages:** All subsequent communication uses length-prefixed messages: + ``` + [body-length (4 bytes)][message-body] + ``` + +3. **Message Body:** The `message-body` contains the message type and data: + ``` + [message-type (1 byte)][message-data] + ``` + Supported message types: + * **Request (`0x01`):** Sent by a peer to request a block of data. + * `[message-data]`: `[piece-index (4 bytes)][piece-inner-offset (4 bytes)][block-length (4 bytes)]` + * **Piece (`0x02` - *assumed type based on common practice, not explicitly numbered in docs*):** Sent in response to a Request, containing the actual file data. + * `[message-data]`: `[piece-index (4 bytes)][piece-inner-offset (4 bytes)][block-length (4 bytes)][data]` + * **Bitfield (`0x03` - *assumed type*):** Sent by a peer to inform others which pieces it possesses. + * `[message-data]`: `[bitfield]` (A byte array where each bit represents a piece, 0=missing, 1=present). + +### Client Core Logic ([resource_manager.py](client/core/p2p/resource_manager.py)) + +The [ResourceManager](client/core/p2p/resource_manager.py) class manages the lifecycle of downloading or seeding a specific resource. + +* **Initialization:** Takes the host peer ID, destination path, resource metadata, and whether the peer initially has the file. It sets up internal state, including piece status tracking. From [resource_manager.py](client/core/p2p/resource_manager.py): + ```python + class ResourceManager: + class PieceStatus(Enum): + FREE = 1 # The piece is not in work + IN_PROGRESS = 2 # Waiting for reply from some peer + RECEIVED = 3 # The data has been fetched from network and now is saving on disk + SAVED = 4 # Piece is successfully saved on disk + + def __init__( + self, + host_peer_id: str, + destination: Path, + resource: Resource, + has_file + ): + self.host_peer_id = host_peer_id + self.destination = destination + self.resource = resource + self.has_file = has_file + # ... + self.piece_status: list[ResourceManager.PieceStatus] = [] + if has_file: + self.resource_file = ResourceFile( + destination, + resource, + fresh_install=False, + initial_state=ResourceFile.State.DOWNLOADED + ) + self.piece_status = [ResourceManager.PieceStatus.SAVED] * len(self.resource.pieces) + else: + self.resource_file = ResourceFile( + destination, + resource, + fresh_install=True, # TODO: add normal restoring procedure + initial_state=ResourceFile.State.DOWNLOADING + ) + self.piece_status = [ResourceManager.PieceStatus.FREE] * len(self.resource.pieces) + # ... + ``` +* **File Handling:** Uses the [ResourceFile](client/core/p2p/resource_file.py) class to manage reading/writing pieces to a temporary file during download (`.torrentinno-filename`) and renaming it upon completion. From [resource_file.py](client/core/p2p/resource_file.py): + ```python + class ResourceFile: + # ... + class State(Enum): + DOWNLOADING = 1 + DOWNLOADED = 2 + # ... + async def save_block(self, piece_index: int, piece_inner_offset: int, data: bytes): + if self.state == ResourceFile.State.DOWNLOADED: + raise RuntimeError("Cannot perform write operation in DOWNLOADED state") + # ... + await self._ensure_downloading_destination() + async with aiofiles.open(self.downloading_destination, mode='r+b') as f: + await f.seek(offset) + await f.write(data) + # ... + async def accept_download(self): + await aiofiles.os.rename(self.downloading_destination, self.destination) + self.state = ResourceFile.State.DOWNLOADED + ``` +* **Download Loop:** Runs as an `asyncio` task, continuously finding `FREE` pieces and assigning them to available peers (`_free_peers`) that have the piece (checked via `_bitfields`). From [resource_manager.py](client/core/p2p/resource_manager.py): + ```python + async def _download_loop(self): + # ... + while True: + # Find free pieces + free_pieces: list[int] = [] + for i, status in enumerate(self.piece_status): + if status == ResourceManager.PieceStatus.FREE: + free_pieces.append(i) + # ... + # Try to find piece and peer that has this piece + found_work = False + for piece_index in free_pieces: + for peer_id in self._free_peers: + if self._peer_has_piece(peer_id, piece_index): # Peer has this piece -> run the work + # Update the status and related peer + self.piece_status[piece_index] = ResourceManager.PieceStatus.IN_PROGRESS + self._peer_in_charge[piece_index] = peer_id + # ... + task = asyncio.create_task(self._download_work(peer_id, piece_index)) + # ... + found_work = True + break + if found_work: + break + await asyncio.sleep(0.2) + ``` +* **Connection Handling:** Listens for incoming connections and initiates outgoing connections Uses a to handle messages ([see resource_manager.py](client/core/p2p/resource_manager.py)). +* **Message Processing:** + * `on_request`: Reads the requested block using [ResourceFile.get_block](client/core/p2p/resource_file.py) and sends a `Piece` message back. + * `on_piece`: Validates the received piece data against the expected hash from the resource metadata. If valid, saves it using [ResourceFile.save_block](client/core/p2p/resource_file.py), updates `piece_status` to `SAVED`, and broadcasts an updated `Bitfield` to all connected peers. + * `on_bitfield`: Updates the internal record (`_bitfields`) of which pieces the sending peer possesses. + * `on_close`: Cleans up connection state when a peer disconnects. + +### GUI Layer (see [torrent_manager_docs.md](client/gui/torrent_manager_docs.md)) + +A GUI torrent manager provides user interaction. Key functions include: + +* `initialize()`: Loads saved state or uses test data. +* `shutdown()`: Saves the current state. +* `get_files()`: Returns a list of active torrents with details (name, size, speed, blocks). +* `update_file(file_name)`: Refreshes information (speed, blocks) for a specific torrent. +* `update_files()`: Refreshes information for all torrents. +* `get_file_info(url)`: Fetches metadata for a new torrent URL/magnet link. +* `add_torrent(file_info)`: Adds a new torrent to the manager. +* `remove_torrent(file_name)`: Removes a torrent. +* `get_mock_content(source)`: Provides mock file list data (likely for UI development/testing). + +## Results + +> [!NOTE] TODO +> * Screenshots of the GUI showing active downloads/uploads. +> * Logs demonstrating successful peer connections, handshakes, and piece exchanges. +> * Performance metrics (e.g., download/upload speed charts, time to download a specific file with varying numbers of peers). +> * Tested [resource_manager_test.py](client/core/tests/resource_manager_test.py) +## Discussion + +The TorrentInno project successfully implements the fundamental components of a P2P file-sharing system, including a tracker for peer discovery and a client capable of exchanging file pieces according to a defined protocol. The use of `asyncio` in the Python client allows for efficient handling of concurrent network operations (multiple peer connections, downloads, uploads). The separation into core logic ([core](client/core)) and GUI ([gui](client/gui)) follows Single Responsiblity Principle, enhancing maintainability and scalability. +**Challenges:** + +* **Concurrency:** Managing the state of multiple pieces across numerous peer connections concurrently (assigning pieces, handling timeouts, validating data) is inherently complex. The current implementation uses `asyncio` tasks and status tracking ([PieceStatus](client/core/p2p/resource_manager.py)) to manage this. +* **Network Reliability:** P2P networks involve unreliable peers and network conditions. The system needs robust error handling for connection drops, timeouts (a simple 1-minute timeout is implemented in [_download_work function](client/core/p2p/resource_manager.py)), and potentially corrupt data (hash checking is implemented in [on_piece function](client/core/p2p/resource_manager.py)). +* **State Management:** Ensuring consistent state, especially when resuming downloads, can be challenging. The current implementation lacks this feature, defaulting to a fresh install. + +**Potential Improvements/Optimizations:** + +* **State Restoration:** Implementing robust saving and loading of download progress to allow resuming interrupted downloads. +* **Piece Selection Strategy:** The current download loop shuffles free pieces randomly ([resource_manager.py](client/core/p2p/resource_manager.py)). We might need more advanced strategies (e.g., "rarest first") could improve download performance in swarms with uneven piece distribution. +* **Error Handling:** Enhance error handling needed for network issues and peer misbehavior. +* **Throttling/Rate Limiting:** Implement upload/download rate limiting. +* **Security:** Adding encryption to peer communication. +* **Tracker Reliability :** Implementing features like compact peer lists or UDP tracker protocol support for scalability. +* **GUI Enhancements:** Adding more detailed statistics, configuration options, and potentially integrate magnet link handling directly if not already present. + +## References + +* BitTorrent Protocol Specification (BEP_0003) + https://www.bittorrent.org/beps/bep_0003.html +- Links to relevant academic papers + - P. Sharma, A. Bhakuni and R. Kaushal, "Performance analysis of BitTorrent protocol," 2013 National Conference on Communications (NCC), New Delhi, India, 2013, pp. 1-5, doi: 10.1109/NCC.2013.6488040. keywords: {Protocols;Peer-to-peer computing;Internet;Servers;Bandwidth;Libraries;Thin film transistors;BitTorrent protocol;Peer to Peer (P2P);Internet;Networks} + https://ieeexplore.ieee.org/abstract/document/6488040 + - E. Costa-Montenegro, J.C. Burguillo-Rial, F. Gil-Castiñeira, F.J. González-Castaño, Implementation and analysis of the BitTorrent protocol with a multi-agent model,Journal of Network and Computer Applications, Volume 34, Issue 1, 2011, Pages 368-383, ISSN 1084-8045, https://doi.org/10.1016/j.jnca.2010.06.010. (https://www.sciencedirect.com/science/article/pii/S1084804510001086) + Keywords: P2P; BitTorrent; Multi-agent; Model; JADE + + - Arnaud Legout, Guillaume Urvoy-Keller, Pietro Michiardi. Understanding BitTorrent: An Experimental Perspective. [Technical Report] 2005, pp.16. ⟨inria-00000156v3⟩ + https://inria.hal.science/inria-00000156/ + +Links to documentation for key libraries: +- Python + - `asyncio` https://docs.python.org/3/library/asyncio.html + - `aiofiles` https://pypi.org/project/aiofiles/ +- Go + - `Gin` https://github.com/gin-gonic/gin \ No newline at end of file diff --git a/specs/schema.jpg b/specs/schema.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3e8b0bf4131740fc1095c83ab81a070f544b00fc GIT binary patch literal 118778 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 literal 0 HcmV?d00001 From a25a8d1c245f4075a33860881d64e2308572bf8a Mon Sep 17 00:00:00 2001 From: Timofey Ivlev <133089612+Timofeq1@users.noreply.github.com> Date: Mon, 28 Apr 2025 22:21:53 +0300 Subject: [PATCH 22/46] Add logs to Report.md --- Report.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Report.md b/Report.md index afda2f7..97cb763 100644 --- a/Report.md +++ b/Report.md @@ -188,9 +188,13 @@ A GUI torrent manager provides user interaction. Key functions include: > [!NOTE] TODO > * Screenshots of the GUI showing active downloads/uploads. -> * Logs demonstrating successful peer connections, handshakes, and piece exchanges. > * Performance metrics (e.g., download/upload speed charts, time to download a specific file with varying numbers of peers). -> * Tested [resource_manager_test.py](client/core/tests/resource_manager_test.py) +> * Tested [resource_manager_test.py](client/core/tests/resource_manager_test.py) + + +**Logs from console** +![logs](https://github.com/user-attachments/assets/35d14a34-116e-4676-8a1f-c653fcab5775) + ## Discussion The TorrentInno project successfully implements the fundamental components of a P2P file-sharing system, including a tracker for peer discovery and a client capable of exchanging file pieces according to a defined protocol. The use of `asyncio` in the Python client allows for efficient handling of concurrent network operations (multiple peer connections, downloads, uploads). The separation into core logic ([core](client/core)) and GUI ([gui](client/gui)) follows Single Responsiblity Principle, enhancing maintainability and scalability. @@ -228,4 +232,4 @@ Links to documentation for key libraries: - `asyncio` https://docs.python.org/3/library/asyncio.html - `aiofiles` https://pypi.org/project/aiofiles/ - Go - - `Gin` https://github.com/gin-gonic/gin \ No newline at end of file + - `Gin` https://github.com/gin-gonic/gin From 30f60ebb374fbf513926b77844021e6994c8be37 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Tue, 29 Apr 2025 00:35:48 +0300 Subject: [PATCH 23/46] edited torrentinno.py and server_manager.py --- client/core/s2p/server_manager.py | 10 +- client/torrentInno.py | 179 +++++++++++++++++++++--------- 2 files changed, 136 insertions(+), 53 deletions(-) diff --git a/client/core/s2p/server_manager.py b/client/core/s2p/server_manager.py index e69e956..f265ef2 100644 --- a/client/core/s2p/server_manager.py +++ b/client/core/s2p/server_manager.py @@ -2,7 +2,7 @@ import time import asyncio -async def update_peer(server_url,peer) -> str: +def update_peer(server_url,peer) -> str: ''' Using post requests create or update peer information on tracker server ''' @@ -15,4 +15,10 @@ async def update_peer(server_url,peer) -> str: return response.text except requests.exceptions.RequestException as e: print(f"Error updating peer: {e}") - return '' \ No newline at end of file + return '' + +async def heart_beat(server_url, peer, on_tracker_responce) -> str: + while True: + responce_text = update_peer(server_url, peer) + on_tracker_responce(responce_text) + await asyncio.sleep(30) diff --git a/client/torrentInno.py b/client/torrentInno.py index c38e9f6..1e7c03c 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -3,10 +3,15 @@ import json import requests import time +import socket +import datetime +import os +import hashlib +from typing import Dict -from client.core.s2p.server_manager import update_peer -from core.p2p import resource_manager from core.s2p.server_manager import update_peer +from core.p2p import resource_manager +from core.s2p.server_manager import heart_beat from core.common.peer_info import PeerInfo from core.common.resource import Resource @@ -18,7 +23,7 @@ def generate_random_bits(size) -> bytes: ''' return bytes(random.randint(0, 255) for _ in range(size)) -def generate_random_peer_id() -> str: +def generate_peer_id() -> str: ''' function what generate peerid ''' @@ -28,65 +33,137 @@ def get_peer_public_ip(): ''' using request return public ip of the peer ''' - try: - response = requests.get("https://api.ipify.org?format=json", timeout=5) - return response.json()['ip'] - except Exception as e: - return None + hostname = socket.gethostname() + ip = socket.gethostbyname(hostname) + return ip -# --- Torrent logic --- +TRACKER_IP = '80.71.232.39' +TRACKER_PORT = '8080' +peer_id = generate_peer_id() +resource_manager_dict: Dict[str, Resource] = {} -async def share_file(path, resource: Resource): +def create_resource_json(name: str, comment: str, file_path, piece_size: int = 1024 * 1024): ''' - Function to start sharing files + Create a resource by splitting the file into multiple pieces. ''' - peer_id = generate_random_peer_id() - file_path = path - resource_manager_instance = resource_manager.ResourceManager(peer_id, file_path, resource, has_file=True) - port = await resource_manager_instance.open_public_port() - await resource_manager_instance.start_sharing_file() - peer_ip = get_peer_public_ip() # TODO: Edit function to get IP - peer_info = PeerInfo(peer_ip, port, peer_id) - - peer_json = { - "peerId": peer_info.peer_id, - "infoHash": resource.get_info_hash(), - "publicIp": peer_info.public_ip, - "publicPort": peer_info.public_port, + size_bytes = os.path.getsize(file_path) + pieces = [] + + with open(file_path, 'rb') as f: + while True: + file_bytes = f.read(piece_size) + if not file_bytes: + break + sha256 = hashlib.sha256(file_bytes).hexdigest() + pieces.append({ + 'sha256': sha256, + 'size_bytes': len(file_bytes) + }) + + resource_json = { + 'tracker_ip': TRACKER_IP, + 'tracker_port': TRACKER_PORT, + 'comment': comment, + 'creation_date': datetime.datetime.now().isoformat(), + 'name': name, + 'pieces': pieces } - server_url = f"http://{resource.tracker_ip}:{resource.tracker_port}/peers" - - while resource_manager_instance.share_file: # ? how to stop sharing of the file - peer_list_str = await update_peer(server_url,peer_json) - peer_list = json.loads(peer_list_str) - await resource_manager_instance.submit_peers(peer_list) - time.sleep(25) + return resource_json -async def initialize(): - ... - -async def shutdown(): - ... - -async def get_files(): - ... +def create_resource_from_json(resource_json): + ''' + Create a resource from the given JSON data. + ''' + pieces = [Resource.Piece(**piece) for piece in resource_json['pieces']] + resource = Resource( + tracker_ip=resource_json['tracker_ip'], + tracker_port=resource_json['tracker_port'], + comment=resource_json['comment'], + creation_date=datetime.datetime.fromisoformat(resource_json['creation_date']), + name=resource_json['name'], + pieces=pieces + ) + return resource -async def update_file(file_name): - ... +# --- Torrent logic --- +async def start_share_file(destination, resource: Resource): + ''' + Function what starting sharing of file, and updating peer information + on tracker server + ''' + peer_public_ip = get_peer_public_ip() + local_resource_manager = resource_manager.ResourceManager(peer_id, destination, resource) + resource_manager_dict[destination] = local_resource_manager + peer_public_port = await resource_manager_dict.get(destination).full_start() + resource_info_hash = resource.get_info_hash() + peer = { + "peerId": peer_id, + "infoHash": resource_info_hash, + "publicIp": peer_public_ip, + "publicPort": peer_public_port + } + tracker_url = f'http://{TRACKER_IP}:{TRACKER_PORT}/peers' + + async def parse_peer_list(json_text): + ''' + Function what parse json text and return list of peerInfo elements + ''' + peer_list = [] + + try: + data = json.loads(json_text) + resource_info_hash = resource.get_info_hash() + for peer in data.get("peers", []): + if peer.get("infoHash") == resource_info_hash: + peer_info = PeerInfo( + public_ip=peer["publicIp"], + public_port=int(peer["publicPort"]), + peer_id=peer["peerId"] + ) + peer_list.append(peer_info) + except (json.JSONDecodeError, KeyError, ValueError) as e: + print(f"Error parsing peer list: {e}") + + await resource_manager_dict.get(destination).submit_peers(peer_list) + + task = asyncio.create_task((heart_beat(tracker_url, peer, parse_peer_list))) + +async def stop_share_file(destination): + await resource_manager_dict.get(destination).stop_sharing_file() + +async def start_download_file(destination, resource: Resource): + ''' + Function what starting downloading of file, and updating peer information + on tracker server + ''' + peer_public_ip = get_peer_public_ip() + local_resource_manager = resource_manager.ResourceManager(peer_id, destination, resource) + resource_manager_dict[destination] = local_resource_manager + peer_public_port = await resource_manager_dict.get(destination).full_start() + resource_info_hash = resource.get_info_hash() + peer = { + "peerId": peer_id, + "infoHash": resource_info_hash, + "publicIp": peer_public_ip, + "publicPort": peer_public_port + } -async def update_files(): - ... + task = asyncio.create_task(await resource_manager_dict.get(destination).start_download()) -async def get_file_info(url): - ... +async def stop_download_file(destination): + ''' + Function what stopping downloading of file, and updating peer information + on tracker server + ''' + await resource_manager_dict.get(destination).stop_download() -async def add_torrent(file_info): - ... -async def remove_torrent(file_name): - ... +async def get_state(destination): + ''' + Function what starting downloading of file, and updating peer information + on tracker server + ''' -def get_mock_content(source): - ... + return await resource_manager_dict.get(destination).get_state() \ No newline at end of file From 947dc7604fd3e8a161c371d848f1e46060074a84 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Tue, 29 Apr 2025 00:48:39 +0300 Subject: [PATCH 24/46] edited torrentinno.py --- client/torrentInno.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/torrentInno.py b/client/torrentInno.py index 1e7c03c..715a8e6 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -132,6 +132,7 @@ async def parse_peer_list(json_text): async def stop_share_file(destination): await resource_manager_dict.get(destination).stop_sharing_file() + del resource_manager_dict[destination] async def start_download_file(destination, resource: Resource): ''' @@ -158,6 +159,7 @@ async def stop_download_file(destination): on tracker server ''' await resource_manager_dict.get(destination).stop_download() + del resource_manager_dict[destination] async def get_state(destination): From 2c4d374cbe03ca24fd28172e84033afd9bd279fb Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Tue, 29 Apr 2025 02:35:07 +0300 Subject: [PATCH 25/46] Add the download/upload state --- client/core/p2p/resource_file.py | 4 +- client/core/p2p/resource_manager.py | 166 ++++++++++++++++----- client/core/p2p/resource_manager_api.md | 30 +++- client/core/tests/resource_manager_test.py | 118 ++++++++------- 4 files changed, 216 insertions(+), 102 deletions(-) diff --git a/client/core/p2p/resource_file.py b/client/core/p2p/resource_file.py index da73e5b..8f0dabb 100644 --- a/client/core/p2p/resource_file.py +++ b/client/core/p2p/resource_file.py @@ -55,8 +55,8 @@ def _calculate_offset(self, piece_index: int, piece_inner_offset: int) -> int: async def _create_downloading_destination(self): self.downloading_destination.unlink(missing_ok=True) async with aiofiles.open(self.downloading_destination, mode='wb') as f: - for piece in self.resource.pieces: - await f.write(bytes([0] * piece.size_bytes)) + await f.seek(self.offsets[-1] - 1) + await f.write(b'\0') async def _ensure_downloading_destination(self): async with self.lock: diff --git a/client/core/p2p/resource_manager.py b/client/core/p2p/resource_manager.py index e35b8a1..e598cd5 100644 --- a/client/core/p2p/resource_manager.py +++ b/client/core/p2p/resource_manager.py @@ -1,5 +1,6 @@ import asyncio import hashlib +import time from pathlib import Path from dataclasses import dataclass import random @@ -26,6 +27,17 @@ class PieceStatus(Enum): @dataclass class State: piece_status: list[bool] + upload_speed_bytes_per_sec: int + download_speed_bytes_per_sec: int + + @dataclass + class NetworkStats: + # The time of last stats drop (used to calculate approximate instantaneous upload/download speed) + last_drop_timestamp_seconds: float + bytes_downloaded_since_last_drop: int = 0 + bytes_uploaded_since_last_drop: int = 0 + prev_download_bytes_per_sec: int = 0 + prev_upload_bytes_per_sec: int = 0 def _log_prefix(self, msg: str) -> str: return f"[ResourceManager peer_id={self.host_peer_id[:6]} info_hash={self.info_hash[:6]}] {msg}" @@ -66,18 +78,6 @@ async def _handle_incoming_connection(self, reader: asyncio.StreamReader, writer def _create_connection_listener(self, peer_id: str) -> ConnectionListener: return ConnectionListenerImpl(peer_id, self) - async def _confirm_download_complete(self): - saved_pieces = sum( - piece_status == ResourceManager.PieceStatus.SAVED - for piece_status in self.piece_status - ) - assert saved_pieces == len(self.resource.pieces) - - await self.resource_file.accept_download() - await self.stop_download() - await self.resource_save.remove_save() - logging.info(self._log_prefix("Download is completed")) - async def _add_peer(self, peer_id: str, connection: Connection): self._connections[peer_id] = connection self._bitfields[peer_id] = [False] * len(self.resource.pieces) @@ -101,7 +101,7 @@ async def _remove_peer(self, peer_id: str): # -----MAIN DOWNLOAD LOGIC BEGINS HERE----- async def _download_work(self, peer_id: str, piece_index: int): - logging.info(self._log_prefix(f"Download Work on piece {piece_index} from peer {peer_id[:6]}")) + logging.info(self._log_prefix(f"Download Work to download piece {piece_index} from peer {peer_id[:6]}")) connection = self._connections[peer_id] await connection.send_message( Request( @@ -155,6 +155,18 @@ async def _download_loop(self): break await asyncio.sleep(0.2) + async def _confirm_download_complete(self): + saved_pieces = sum( + piece_status == ResourceManager.PieceStatus.SAVED + for piece_status in self.piece_status + ) + assert saved_pieces == len(self.resource.pieces) + + await self.resource_file.accept_download() + await self.stop_download() + await self.resource_save.remove_save() + logging.info(self._log_prefix("Download is completed")) + # -----END OF DOWNLOAD LOGIC----- def _peer_has_piece(self, peer_id: str, piece_index: int) -> bool: @@ -194,6 +206,26 @@ async def _serve_forever(self, server: asyncio.Server): async with server: await server.serve_forever() + async def _calc_network_stats(self): + while True: + delta = time.time() - self._network_stats.last_drop_timestamp_seconds + self._network_stats.prev_download_bytes_per_sec = \ + int(self._network_stats.bytes_downloaded_since_last_drop / delta) + self._network_stats.prev_upload_bytes_per_sec = \ + int(self._network_stats.bytes_uploaded_since_last_drop / delta) + + self._network_stats.last_drop_timestamp_seconds = time.time() + self._network_stats.bytes_downloaded_since_last_drop = 0 + self._network_stats.bytes_uploaded_since_last_drop = 0 + + logging.info( + self._log_prefix(f"Download speed: {self._network_stats.prev_download_bytes_per_sec // 1000} kb/sec") + ) + logging.info( + self._log_prefix(f"Upload speed: {self._network_stats.prev_upload_bytes_per_sec // 1000} kb/sec") + ) + await asyncio.sleep(2) + def __init__( self, host_peer_id: str, @@ -201,7 +233,9 @@ def __init__( resource: Resource, ): """ - Create a new ResourceManager instance + Create a new ResourceManager instance. + IMPORTANT: For pair (destination, resource) there MUST be only one instance of (running) `ResourceManager`. + Otherwise, the whole behaviour is undefined. :param host_peer_id: the peer_id that will host the resource :param destination: The destination of the file on the filesystem. Important: if the destination exists @@ -219,7 +253,7 @@ def __init__( self.resource_save = ResourceSave(destination, resource) # If the peer can give file pieces - self.share_file = False + self.share_file = True # Peer dictionaries self._connections: dict[str, Connection] = dict() # peer_id <-> Connection @@ -250,11 +284,16 @@ def __init__( # Current peer id that handles the piece (empty string=no peer) self._peer_in_charge: list[str] = [''] * len(self.resource.pieces) + # Download state + self._network_stats = ResourceManager.NetworkStats(time.time()) + # Various asyncio background tasks self._download_task: asyncio.Task | None = None self._server_task: asyncio.Task | None = None self._broadcast_task: asyncio.Task | None = None + self._calc_network_stats_task = asyncio.create_task(self._calc_network_stats()) + # PUBLIC METHODS: async def open_public_port(self) -> int: """ @@ -338,6 +377,9 @@ async def stop_download(self): """ logging.info(self._log_prefix("Stop download")) + # For each piece, mark that nobody is responsible for it + self._peer_in_charge = [''] * len(self.resource.pieces) + if self._download_task is not None: # Stop downloading the resource self._download_task.cancel() @@ -356,22 +398,39 @@ async def stop_sharing_file(self): """ self.share_file = False - async def full_start(self) -> int: + async def full_start( + self, + restore_previous=True, + start_sharing_file=True, + start_download=True, + open_public_port=True + ) -> int | None: """ - A convenience methods that automatically opens the public port of the resource manager, - starts download, attempt to restore the previous download state etc. + A method to start the `ResourceManager`. Clients MUST call this method in order to fully start the `ResourceManager`. + The method has a bunch of flags that allow clients to adjust the parameters of `ResourceManager` + at the beginning. Any disabled flag can be enabled later by calling the appropriate public method. + + The method is not guaranteed to be idempotent (i.e. repeating calls of `full_start()` to the + running ResourceManager may cause exceptions/various errors). - :return: the same as `open_public_port()` + :return: if `open_public_port` is True then the return value is the same as `open_public_port()` otherwise None """ - await self.restore_previous() - await self.start_sharing_file() - await self.start_download() - listen_port = await self.open_public_port() + if restore_previous: await self.restore_previous() + if start_sharing_file: await self.start_sharing_file() + if start_download: await self.start_download() + listen_port = None + if open_public_port: listen_port = await self.open_public_port() + + if self._calc_network_stats_task is None: + self._calc_network_stats_task = asyncio.create_task(self._calc_network_stats()) return listen_port async def shutdown(self): """ - A convenience method that is the opposite of `full_start()` (i.e. close everything that was started/launched) + A method to stop the running `ResourceManager`. The clients MUST call this method in order to fully stop the + running `ResourceManager` and release all associated resources. + + The method is idempotent (repeating calls do not cause any errors/exception) """ await self.close_public_port() await asyncio.gather( @@ -380,6 +439,8 @@ async def shutdown(self): ) await self.stop_download() await self.stop_sharing_file() + if self._calc_network_stats_task is not None: + self._calc_network_stats_task.cancel() async def submit_peers(self, peers: list[PeerInfo]): """ @@ -409,7 +470,12 @@ async def get_state(self) -> 'ResourceManager.State': Get the current state of the resource (i.e. downloaded pieces, upload/download speed etc.) :return: The current state of the resource (file) """ - return ResourceManager.State(self._get_bitfield()) + delta_sec = time.time() - self._network_stats.last_drop_timestamp_seconds + return ResourceManager.State( + self._get_bitfield(), + upload_speed_bytes_per_sec=self._network_stats.prev_upload_bytes_per_sec, + download_speed_bytes_per_sec=self._network_stats.prev_download_bytes_per_sec + ) class ConnectionListenerImpl(ConnectionListener): @@ -417,14 +483,13 @@ def __init__(self, connected_peer_id: str, resource_manager: ResourceManager): self.resource_manager = resource_manager self.connected_peer_id = connected_peer_id - def _info_log(self, msg: str): - logging.info( - self.resource_manager._log_prefix(msg) - ) + def _log(self, level, msg: str): + logging.log(level, self.resource_manager._log_prefix(msg)) async def on_request(self, request: Request): if not self.resource_manager.share_file: - self._info_log(f"Ignore Request message from peer {self.connected_peer_id[:6]} as sharing is disabled") + self._log(logging.DEBUG, + f"Ignore Request message from peer {self.connected_peer_id[:6]} as sharing is disabled") return try: @@ -442,7 +507,12 @@ async def on_request(self, request: Request): data ) ) - self._info_log(f"Send piece {request.piece_index} on Request message to peer {self.connected_peer_id[:6]}") + + # Update the network stats + self.resource_manager._network_stats.bytes_uploaded_since_last_drop += request.block_length + + self._log(logging.DEBUG, + f"Send piece {request.piece_index} on Request message to peer {self.connected_peer_id[:6]}") except Exception: logging.exception( self.resource_manager._log_prefix( @@ -454,7 +524,10 @@ async def on_request(self, request: Request): async def on_piece(self, piece: Piece): # This peer is not in charge on this piece if self.resource_manager._peer_in_charge[piece.piece_index] != self.connected_peer_id: - self._info_log(f"Discard piece {piece.piece_index} from {self.connected_peer_id[:6]} as not in charge") + self._log( + logging.DEBUG, + f"Discard piece {piece.piece_index} from {self.connected_peer_id[:6]} as not in charge" + ) return self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.RECEIVED try: @@ -463,12 +536,11 @@ async def on_piece(self, piece: Piece): received_hash = self.resource_manager.resource.pieces[piece.piece_index].sha256 if expected_hash != received_hash: - logging.warning( - self.resource_manager._log_prefix( - f"Incorrect hash of piece {piece.piece_index} on Piece message from peer {self.connected_peer_id}\n" + - f"Expected: {expected_hash}\n" - f"Received: {received_hash}" - ) + self._log( + logging.WARNING, + f"Incorrect hash of piece {piece.piece_index} on Piece message from peer {self.connected_peer_id}\n" + + f"Expected: {expected_hash}\n" + f"Received: {received_hash}" ) return @@ -478,6 +550,9 @@ async def on_piece(self, piece: Piece): piece.data ) + # Update the network stats + self.resource_manager._network_stats.bytes_downloaded_since_last_drop += len(piece.data) + # If the piece is saved, then broadcast the bitfield to all connections and change the status self.resource_manager.piece_status[piece.piece_index] = ResourceManager.PieceStatus.SAVED @@ -485,7 +560,11 @@ async def on_piece(self, piece: Piece): piece_status == ResourceManager.PieceStatus.SAVED for piece_status in self.resource_manager.piece_status ) - self._info_log(f"Save piece {piece.piece_index} from {self.connected_peer_id[:6]}") + self._log( + logging.INFO, + f"Save piece {piece.piece_index} from {self.connected_peer_id[:6]}. " + f"Now has {saved_pieces}/{len(self.resource_manager.resource.pieces)} pieces" + ) # Also update the information about saved piece in the file: await self.resource_manager._save_loading_state() @@ -509,9 +588,14 @@ async def on_piece(self, piece: Piece): async def on_bitfield(self, bitfield: Bitfield): self.resource_manager._bitfields[self.connected_peer_id] = bitfield.bitfield - self._info_log(f"Bitfield from {self.connected_peer_id[:6]}: {bitfield.bitfield}") + owned_pieces = sum(bitfield.bitfield) + self._log( + logging.DEBUG, + f"Bitfield from {self.connected_peer_id[:6]}." + f" The peer claims to have {owned_pieces}/{len(bitfield.bitfield)} pieces" + ) async def on_close(self, cause): # The connection with peer for some reason is closed - self._info_log(f"The connection with {self.connected_peer_id[:6]} is closed") + self._log(logging.INFO, f"The connection with {self.connected_peer_id[:6]} is closed") await self.resource_manager._remove_peer(self.connected_peer_id) diff --git a/client/core/p2p/resource_manager_api.md b/client/core/p2p/resource_manager_api.md index 1618592..2400890 100644 --- a/client/core/p2p/resource_manager_api.md +++ b/client/core/p2p/resource_manager_api.md @@ -9,7 +9,9 @@ def __init__( resource: Resource, ): """ - Create a new ResourceManager instance + Create a new ResourceManager instance. + IMPORTANT: For pair (destination, resource) there MUST be only one instance of (running) `ResourceManager`. + Otherwise, the whole behaviour is undefined. :param host_peer_id: the peer_id that will host the resource :param destination: The destination of the file on the filesystem. Important: if the destination exists @@ -22,19 +24,31 @@ def __init__( ## Public methods: ### The most useful ones: ```python -async def full_start(self) -> int: - """ - A convenience methods that automatically opens the public port of the resource manager, - starts download, attempt to restore the previous download state etc. +async def full_start( + self, + restore_previous=True, + start_sharing_file=True, + start_download=True, + open_public_port=True +) -> int | None: + """ + A method to start the `ResourceManager`. Clients MUST call this method in order to fully start the `ResourceManager`. + The method has a bunch of flags that allow clients to adjust the parameters of `ResourceManager` + at the beginning. Any disabled flag can be enabled later by calling the appropriate public method. + + The method is not guaranteed to be idempotent (i.e. repeating calls of `full_start()` to the + running ResourceManager may cause exceptions/various errors). - :return: the same as `open_public_port()` + :return: if `open_public_port` is True then the return value is the same as `open_public_port()` otherwise None """ - ... ``` ```python async def shutdown(self): """ - A convenience method that is the opposite of `full_start()` (i.e. close everything that was started/launched) + A method to stop the running `ResourceManager`. The clients MUST call this method in order to fully stop the + running `ResourceManager` and release all associated resources. + + The method is idempotent (repeating calls do not cause any errors/exception) """ ... ``` diff --git a/client/core/tests/resource_manager_test.py b/client/core/tests/resource_manager_test.py index 7a8365b..2efb6df 100644 --- a/client/core/tests/resource_manager_test.py +++ b/client/core/tests/resource_manager_test.py @@ -1,90 +1,108 @@ import asyncio -import datetime import hashlib +import datetime +from itertools import accumulate from pathlib import Path import random import shutil import logging +import time +import aiofiles from core.common.peer_info import PeerInfo from core.common.resource import Resource from core.p2p.resource_manager import ResourceManager -def random_bits(size) -> bytes: +def random_bytes(size) -> bytes: return bytes(random.randint(0, 255) for _ in range(size)) def random_peer_id() -> str: - return random_bits(32).hex() + return random_bytes(32).hex() + + +async def create_peer(peer_id: str, destination: Path, resource) -> tuple[PeerInfo, ResourceManager]: + destination.parent.mkdir(parents=True, exist_ok=True) + resource_manager = ResourceManager(peer_id, destination, resource) + port = await resource_manager.full_start() + peer_info = PeerInfo('127.0.0.1', port, peer_id) + return peer_info, resource_manager async def main(): - logging.basicConfig(level=logging.DEBUG) + logging.basicConfig(level=logging.INFO) - # Temporary directory + # Temporary directory and necessary file tree manipulations tmp = Path(__file__).parent.joinpath('tmp') shutil.rmtree(tmp, ignore_errors=True) tmp.mkdir(parents=True) - # Generate stub data - data: list[bytes] = [random_bits(random.randint(100, 1000)) for _ in range(10)] - pieces: list[Resource.Piece] = [ - Resource.Piece( - sha256=hashlib.sha256(piece_data).hexdigest(), - size_bytes=len(piece_data) - ) - for piece_data in data - ] + # Make the randomizer deterministic + random.seed(0) + + # Generate the initial file + source_peer_id = random_peer_id() + source_peer_destination = tmp.joinpath('source', 'data') + source_peer_destination.parent.mkdir(parents=True) + piece_sizes = [random.randint(5 * 10 ** 5, 10 ** 6) for _ in range(100)] + offset = [0] + list(accumulate(piece_sizes)) + + with open(source_peer_destination, mode='wb') as file: + file.seek(offset[-1] - 1) + file.write(b'\0') + + print("Start generating file...") + pieces = [] + async with aiofiles.open(source_peer_destination, mode='r+b') as file: + for i in range(len(piece_sizes)): + await file.seek(offset[i]) + arr = random_bytes(min(100, piece_sizes[i])) + data = arr * (piece_sizes[i] // len(arr)) + b'\0' * (piece_sizes[i] % len(arr)) + assert len(data) == piece_sizes[i] + piece = Resource.Piece( + sha256=hashlib.sha256(data).hexdigest(), + size_bytes=len(data) + ) + pieces.append(piece) + await file.write(data) + + assert source_peer_destination.stat().st_size == offset[-1] + resource = Resource( tracker_ip='0.0.0.0', tracker_port=8080, comment='Test file', - creation_date=datetime.datetime.now(), + creation_date=datetime.datetime(year=2000, month=1, day=1, hour=1, minute=1, second=1), name='Random testing file', pieces=pieces ) - # Write the stub data to file - source_file = tmp.joinpath(resource.name) - with open(source_file, mode='wb') as f: - for piece_data in data: - f.write(piece_data) - - # Set up source peer_id. This can be used as an example of working with ResourceManager - source_peer_id = random_peer_id() # peer_id is unique PER PEER (not per ResourceManager) - source_destination = source_file - source_resource_manager = ResourceManager(source_peer_id, source_destination, resource) - source_port = await source_resource_manager.open_public_port() - await source_resource_manager.start_sharing_file() - source_peer_info = PeerInfo('127.0.0.1', source_port, source_peer_id) + source_peer_info, source_resource_manager = await create_peer( + source_peer_id, + source_peer_destination, + resource + ) + # Now create consumer peers consumer_peer_ids = [random_peer_id() for _ in range(5)] - consumer_destinations = [tmp.joinpath(peer_id, resource.name) for peer_id in consumer_peer_ids] - for consumer_destination in consumer_destinations: - consumer_destination.parent.mkdir(parents=True) - - consumer_resource_managers = [ - ResourceManager(consumer_peer_id, consumer_destination, resource) - for consumer_peer_id, consumer_destination in zip(consumer_peer_ids, consumer_destinations) + consumer_destinations = [ + tmp.joinpath(consumer_peer_id, resource.name) + for consumer_peer_id in consumer_peer_ids ] - for consumer_resource_manager in consumer_resource_managers: - await consumer_resource_manager.start_sharing_file() - consumer_ports = [ - await resource_manager.open_public_port() - for resource_manager in consumer_resource_managers + consumer_tuples = [ + await create_peer( + consumer_peer_id, + consumer_destination, + resource + ) for consumer_peer_id, consumer_destination in zip(consumer_peer_ids, consumer_destinations) ] - consumer_peer_infos = [ - PeerInfo('127.0.0.1', port, peer_id) - for port, peer_id in zip(consumer_ports, consumer_peer_ids) - ] - - all_peer_infos = consumer_peer_infos + [source_peer_info] + consumer_peer_infos = [t[0] for t in consumer_tuples] + consumer_resource_managers = [t[1] for t in consumer_tuples] - for resource_manager in consumer_resource_managers: - await resource_manager.start_download() + all_peer_infos = [source_peer_info] + consumer_peer_infos - # For all the peers, submit the PeerInfo list + # Submit the info about peers to all peers await asyncio.gather( source_resource_manager.submit_peers(all_peer_infos), *( @@ -93,7 +111,6 @@ async def main(): ) ) - # ...watch the peers talking! if __name__ == "__main__": try: @@ -105,4 +122,3 @@ async def main(): pass tmp = Path(__file__).parent.joinpath('tmp') shutil.rmtree(tmp) - From 1b0ecaedea5f3db7e7f34e9c997bb9aa259b9df8 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Tue, 29 Apr 2025 13:18:06 +0300 Subject: [PATCH 26/46] Fix type hint in torrentinno.py --- client/poetry.lock | 171 +++++++++++++++++++++++++++++++++++++++++- client/pyproject.toml | 3 +- client/torrentInno.py | 3 +- 3 files changed, 174 insertions(+), 3 deletions(-) diff --git a/client/poetry.lock b/client/poetry.lock index d505462..8adcbe6 100644 --- a/client/poetry.lock +++ b/client/poetry.lock @@ -12,6 +12,120 @@ files = [ {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, ] +[[package]] +name = "certifi" +version = "2025.4.26" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"}, + {file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, +] + [[package]] name = "colorama" version = "0.4.6" @@ -25,6 +139,21 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "iniconfig" version = "2.1.0" @@ -105,7 +234,47 @@ pytest = ">=8.2,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "urllib3" +version = "2.4.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"}, + {file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + [metadata] lock-version = "2.1" python-versions = ">=3.12" -content-hash = "b03d58fff95b6ca39996dfcb5c4ff6025e6995f2eb519d8f9640cbc256b44ab5" +content-hash = "345812af8ad798aa93c3b1bb2e01c140564a3636dbaa6de5a69ad36e5ddc1ced" diff --git a/client/pyproject.toml b/client/pyproject.toml index acb88c4..e2675f7 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -5,7 +5,8 @@ description = "" requires-python = ">=3.12" dependencies = [ "pytest-asyncio (>=0.26.0,<0.27.0)", - "aiofiles (>=24.1.0,<25.0.0)" + "aiofiles (>=24.1.0,<25.0.0)", + "requests (>=2.32.3,<3.0.0)" ] diff --git a/client/torrentInno.py b/client/torrentInno.py index 715a8e6..67eda82 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -9,6 +9,7 @@ import hashlib from typing import Dict +from core.p2p.resource_manager import ResourceManager from core.s2p.server_manager import update_peer from core.p2p import resource_manager from core.s2p.server_manager import heart_beat @@ -40,7 +41,7 @@ def get_peer_public_ip(): TRACKER_IP = '80.71.232.39' TRACKER_PORT = '8080' peer_id = generate_peer_id() -resource_manager_dict: Dict[str, Resource] = {} +resource_manager_dict: Dict[str, ResourceManager] = {} def create_resource_json(name: str, comment: str, file_path, piece_size: int = 1024 * 1024): ''' From 491cf3c1aa7ff01cf2d2ece29283674c00b62f2f Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Tue, 29 Apr 2025 14:28:12 +0300 Subject: [PATCH 27/46] edited some files --- .gitignore | 3 +- client/core/tests/torrentInno_test.py | 10 ++ client/torrentInno.py | 217 ++++++++++++++++---------- specs/resource.json | 3 +- 4 files changed, 146 insertions(+), 87 deletions(-) create mode 100644 client/core/tests/torrentInno_test.py diff --git a/.gitignore b/.gitignore index d107def..f8cc8e3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .idea/ .venv/ venv/ -__pycache__/ \ No newline at end of file +__pycache__/ +.ropeproject/ \ No newline at end of file diff --git a/client/core/tests/torrentInno_test.py b/client/core/tests/torrentInno_test.py new file mode 100644 index 0000000..415598b --- /dev/null +++ b/client/core/tests/torrentInno_test.py @@ -0,0 +1,10 @@ +from torrentInno import TorrentInno +from torrentInno import create_resource_json, create_resource_from_json +from core.common.resource import Resource + +client = TorrentInno() + +resource_json = create_resource_json('pptx', 'presentation for 5', '~/home/') +resource = create_resource_from_json(resource_json) + +TorrentInno.start_share_file('~/home/', resource) \ No newline at end of file diff --git a/client/torrentInno.py b/client/torrentInno.py index 67eda82..67eae06 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -1,8 +1,6 @@ import asyncio import random import json -import requests -import time import socket import datetime import os @@ -11,16 +9,20 @@ from core.p2p.resource_manager import ResourceManager from core.s2p.server_manager import update_peer -from core.p2p import resource_manager +from core.p2p.resource_manager import ResourceManager from core.s2p.server_manager import heart_beat from core.common.peer_info import PeerInfo from core.common.resource import Resource +# --- constants --- +TRACKER_IP = '80.71.232.39' +TRACKER_PORT = '8080' + # --- utility functions --- def generate_random_bits(size) -> bytes: ''' - Generate random bits usign randint + Generate random bits using randint ''' return bytes(random.randint(0, 255) for _ in range(size)) @@ -38,11 +40,14 @@ def get_peer_public_ip(): ip = socket.gethostbyname(hostname) return ip +<<<<<<< Updated upstream TRACKER_IP = '80.71.232.39' TRACKER_PORT = '8080' peer_id = generate_peer_id() resource_manager_dict: Dict[str, ResourceManager] = {} +======= +>>>>>>> Stashed changes def create_resource_json(name: str, comment: str, file_path, piece_size: int = 1024 * 1024): ''' Create a resource by splitting the file into multiple pieces. @@ -58,14 +63,14 @@ def create_resource_json(name: str, comment: str, file_path, piece_size: int = 1 sha256 = hashlib.sha256(file_bytes).hexdigest() pieces.append({ 'sha256': sha256, - 'size_bytes': len(file_bytes) + 'size': len(file_bytes) }) resource_json = { - 'tracker_ip': TRACKER_IP, - 'tracker_port': TRACKER_PORT, + 'trackerIp': TRACKER_IP, + 'trackerPort': TRACKER_PORT, 'comment': comment, - 'creation_date': datetime.datetime.now().isoformat(), + 'creationDate': datetime.datetime.now().isoformat(), 'name': name, 'pieces': pieces } @@ -79,94 +84,138 @@ def create_resource_from_json(resource_json): ''' pieces = [Resource.Piece(**piece) for piece in resource_json['pieces']] resource = Resource( - tracker_ip=resource_json['tracker_ip'], - tracker_port=resource_json['tracker_port'], + tracker_ip=resource_json['trackerIp'], + tracker_port=resource_json['trackerPort'], comment=resource_json['comment'], - creation_date=datetime.datetime.fromisoformat(resource_json['creation_date']), + creation_date=datetime.datetime.fromisoformat(resource_json['creationDate']), name=resource_json['name'], pieces=pieces ) return resource # --- Torrent logic --- -async def start_share_file(destination, resource: Resource): - ''' - Function what starting sharing of file, and updating peer information - on tracker server - ''' - peer_public_ip = get_peer_public_ip() - local_resource_manager = resource_manager.ResourceManager(peer_id, destination, resource) - resource_manager_dict[destination] = local_resource_manager - peer_public_port = await resource_manager_dict.get(destination).full_start() - resource_info_hash = resource.get_info_hash() - peer = { - "peerId": peer_id, - "infoHash": resource_info_hash, - "publicIp": peer_public_ip, - "publicPort": peer_public_port - } - tracker_url = f'http://{TRACKER_IP}:{TRACKER_PORT}/peers' - async def parse_peer_list(json_text): +class TorrentInno: + + peer_id = generate_peer_id() + resource_manager_dict: Dict[str, ResourceManager] = {} + + async def start_share_file(destination, resource: Resource, self): ''' - Function what parse json text and return list of peerInfo elements + Function what starting sharing of file, and updating peer information + on tracker server ''' - peer_list = [] - - try: - data = json.loads(json_text) - resource_info_hash = resource.get_info_hash() - for peer in data.get("peers", []): - if peer.get("infoHash") == resource_info_hash: - peer_info = PeerInfo( - public_ip=peer["publicIp"], - public_port=int(peer["publicPort"]), - peer_id=peer["peerId"] - ) - peer_list.append(peer_info) - except (json.JSONDecodeError, KeyError, ValueError) as e: - print(f"Error parsing peer list: {e}") - - await resource_manager_dict.get(destination).submit_peers(peer_list) - - task = asyncio.create_task((heart_beat(tracker_url, peer, parse_peer_list))) - -async def stop_share_file(destination): - await resource_manager_dict.get(destination).stop_sharing_file() - del resource_manager_dict[destination] - -async def start_download_file(destination, resource: Resource): - ''' - Function what starting downloading of file, and updating peer information - on tracker server - ''' - peer_public_ip = get_peer_public_ip() - local_resource_manager = resource_manager.ResourceManager(peer_id, destination, resource) - resource_manager_dict[destination] = local_resource_manager - peer_public_port = await resource_manager_dict.get(destination).full_start() - resource_info_hash = resource.get_info_hash() - peer = { - "peerId": peer_id, - "infoHash": resource_info_hash, - "publicIp": peer_public_ip, - "publicPort": peer_public_port - } + peer_public_ip = get_peer_public_ip() + local_resource_manager = ResourceManager(self.peer_id, destination, resource) + self.resource_manager_dict[destination] = local_resource_manager + peer_public_port = await self.resource_manager_dict.get(destination).full_start() + resource_info_hash = resource.get_info_hash() + peer = { + "peerId": self.peer_id, + "infoHash": resource_info_hash, + "publicIp": peer_public_ip, + "publicPort": peer_public_port + } + tracker_url = f'http://{TRACKER_IP}:{TRACKER_PORT}/peers' + + async def parse_peer_list(json_text): + ''' + Function what parse json text and return list of peerInfo elements + ''' + peer_list = [] + + + try: + data = json.loads(json_text) + resource_info_hash = resource.get_info_hash() + for peer in data.get("peers", []): + if peer.get("infoHash") == resource_info_hash: + peer_info = PeerInfo( + public_ip=peer["publicIp"], + public_port=int(peer["publicPort"]), + peer_id=peer["peerId"] + ) + peer_list.append(peer_info) + except (json.JSONDecodeError, KeyError, ValueError) as e: + print(f"Error parsing peer list: {e}") + + await self.resource_manager_dict.get(destination).submit_peers(peer_list) + + task = asyncio.create_task((heart_beat(tracker_url, peer, parse_peer_list))) + + async def stop_share_file(destination, self): + await self.resource_manager_dict.get(destination).stop_sharing_file() + del self.resource_manager_dict[destination] + + async def start_download_file(destination, resource: Resource, self): + ''' + Function what starting downloading of file, and updating peer information + on tracker server + ''' + peer_public_ip = get_peer_public_ip() + local_resource_manager = ResourceManager(self.peer_id, destination, resource) + self.resource_manager_dict[destination] = local_resource_manager + peer_public_port = await self.resource_manager_dict.get(destination).full_start() + resource_info_hash = resource.get_info_hash() + peer = { + "peerId": self.peer_id, + "infoHash": resource_info_hash, + "publicIp": peer_public_ip, + "publicPort": peer_public_port + } + + tracker_url = f'http://{TRACKER_IP}:{TRACKER_PORT}/peers' + + async def parse_peer_list(json_text): + ''' + Function what parse json text and return list of peerInfo elements + ''' + peer_list = [] + + try: + data = json.loads(json_text) + resource_info_hash = resource.get_info_hash() + for peer in data.get("peers", []): + if peer.get("infoHash") == resource_info_hash: + peer_info = PeerInfo( + public_ip=peer["publicIp"], + public_port=int(peer["publicPort"]), + peer_id=peer["peerId"] + ) + peer_list.append(peer_info) + except (json.JSONDecodeError, KeyError, ValueError) as e: + print(f"Error parsing peer list: {e}") + + await self.resource_manager_dict.get(destination).submit_peers(peer_list) + + task = asyncio.create_task((heart_beat(tracker_url, peer, parse_peer_list))) + await self.resource_manager_dict.get(destination).start_download() + + + async def stop_download_file(destination, self): + ''' + Function what stopping downloading of file, and updating peer information + on tracker server + ''' + await self.resource_manager_dict.get(destination).stop_download() + del self.resource_manager_dict[destination] - task = asyncio.create_task(await resource_manager_dict.get(destination).start_download()) -async def stop_download_file(destination): - ''' - Function what stopping downloading of file, and updating peer information - on tracker server - ''' - await resource_manager_dict.get(destination).stop_download() - del resource_manager_dict[destination] + async def get_state(destination, self): + ''' + Function what starting downloading of file, and updating peer information + on tracker server + ''' + return await self.resource_manager_dict.get(destination).get_state() -async def get_state(destination): - ''' - Function what starting downloading of file, and updating peer information - on tracker server - ''' + async def get_all_files_state(self): + ''' + Function what returning state of all files + ''' + return_list = [] + + for key in self.resource_manager_dict.keys(): + return_list.append(tuple(key, self.resource_manager_dict.get(key).get_state())) - return await resource_manager_dict.get(destination).get_state() \ No newline at end of file + return return_list diff --git a/specs/resource.json b/specs/resource.json index 472333a..2c572a0 100644 --- a/specs/resource.json +++ b/specs/resource.json @@ -17,6 +17,5 @@ "sha256": "d432e67ffb96a5e742ab6d396bf52b41c4b960a5609871d4a477ad879d8a3c0", "size": 26071 } - ], - "fileSha256": "8a517f0f321567c047f1a1e20c35d7f8a2a083f32045a9d98f8ff14f0b5f350" + ] } \ No newline at end of file From efb5487808ff34afa8460a68a03fc9d8ad357a28 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Tue, 29 Apr 2025 14:28:48 +0300 Subject: [PATCH 28/46] edited some files --- client/torrentInno.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/torrentInno.py b/client/torrentInno.py index 67eae06..9930693 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -40,14 +40,12 @@ def get_peer_public_ip(): ip = socket.gethostbyname(hostname) return ip -<<<<<<< Updated upstream + TRACKER_IP = '80.71.232.39' TRACKER_PORT = '8080' peer_id = generate_peer_id() resource_manager_dict: Dict[str, ResourceManager] = {} -======= ->>>>>>> Stashed changes def create_resource_json(name: str, comment: str, file_path, piece_size: int = 1024 * 1024): ''' Create a resource by splitting the file into multiple pieces. From 535759ca80024ce0b72ce30a2c28b74ad0f552c8 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Tue, 29 Apr 2025 19:42:31 +0300 Subject: [PATCH 29/46] Worked version of TorrentInno --- client/core/s2p/server_manager.py | 10 ++- client/core/tests/torrentInno_test.py | 27 ++++-- client/torrentInno.py | 113 +++++++++++++++----------- 3 files changed, 92 insertions(+), 58 deletions(-) diff --git a/client/core/s2p/server_manager.py b/client/core/s2p/server_manager.py index f265ef2..9b49405 100644 --- a/client/core/s2p/server_manager.py +++ b/client/core/s2p/server_manager.py @@ -2,7 +2,8 @@ import time import asyncio -def update_peer(server_url,peer) -> str: +def update_peer(server_url, peer) -> str: + print(server_url) ''' Using post requests create or update peer information on tracker server ''' @@ -12,13 +13,14 @@ def update_peer(server_url,peer) -> str: print("Peer updated successfully.") else: print(f"Failed to update peer. Status code: {response.status_code}") + print(f"Response content: {response.text}") # Log the response content for debugging return response.text except requests.exceptions.RequestException as e: print(f"Error updating peer: {e}") return '' -async def heart_beat(server_url, peer, on_tracker_responce) -> str: +async def heart_beat(server_url, peer, on_tracker_response) -> str: while True: - responce_text = update_peer(server_url, peer) - on_tracker_responce(responce_text) + response_text = update_peer(server_url, peer) + await on_tracker_response(response_text) await asyncio.sleep(30) diff --git a/client/core/tests/torrentInno_test.py b/client/core/tests/torrentInno_test.py index 415598b..a6d127d 100644 --- a/client/core/tests/torrentInno_test.py +++ b/client/core/tests/torrentInno_test.py @@ -1,10 +1,25 @@ -from torrentInno import TorrentInno -from torrentInno import create_resource_json, create_resource_from_json +import asyncio +from torrentInno import TorrentInno, create_resource_json, create_resource_from_json from core.common.resource import Resource +import logging -client = TorrentInno() +logging.basicConfig(level=logging.DEBUG) -resource_json = create_resource_json('pptx', 'presentation for 5', '~/home/') -resource = create_resource_from_json(resource_json) +async def main(): + client1 = TorrentInno() + print(client1.peer_id) + client2 = TorrentInno() + print(client2.peer_id) -TorrentInno.start_share_file('~/home/', resource) \ No newline at end of file + resource_json = create_resource_json('Lab_8_Docker.html', 'presentation for 5', '/home/setterwars/Downloads/Lab_8_Docker.html') + resource: Resource = create_resource_from_json(resource_json) + + await client1.start_share_file('/home/setterwars/Downloads/Lab_8_Docker.html', resource) + await asyncio.sleep(2) + await client2.start_download_file('/home/setterwars/Documents/Lab_8_Docker.html', resource) + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.create_task(main()) + loop.run_forever() \ No newline at end of file diff --git a/client/torrentInno.py b/client/torrentInno.py index 9930693..af606fc 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -5,18 +5,19 @@ import datetime import os import hashlib +import math + from typing import Dict +from pathlib import Path from core.p2p.resource_manager import ResourceManager -from core.s2p.server_manager import update_peer -from core.p2p.resource_manager import ResourceManager -from core.s2p.server_manager import heart_beat +from core.s2p.server_manager import update_peer, heart_beat from core.common.peer_info import PeerInfo from core.common.resource import Resource # --- constants --- TRACKER_IP = '80.71.232.39' -TRACKER_PORT = '8080' +TRACKER_PORT = 8080 # --- utility functions --- @@ -40,21 +41,18 @@ def get_peer_public_ip(): ip = socket.gethostbyname(hostname) return ip - -TRACKER_IP = '80.71.232.39' -TRACKER_PORT = '8080' -peer_id = generate_peer_id() -resource_manager_dict: Dict[str, ResourceManager] = {} - -def create_resource_json(name: str, comment: str, file_path, piece_size: int = 1024 * 1024): +def create_resource_json(name: str, comment: str, file_path, max_pieces: int = 1000, min_piece_size: int = 64 * 1024): ''' - Create a resource by splitting the file into multiple pieces. + Create a resource by splitting the file into an adaptive number of pieces. ''' size_bytes = os.path.getsize(file_path) + # Calculate adaptive piece size + piece_size = max(min_piece_size, math.ceil(size_bytes / max_pieces)) pieces = [] + total_read = 0 with open(file_path, 'rb') as f: - while True: + while total_read < size_bytes: file_bytes = f.read(piece_size) if not file_bytes: break @@ -63,6 +61,10 @@ def create_resource_json(name: str, comment: str, file_path, piece_size: int = 1 'sha256': sha256, 'size': len(file_bytes) }) + total_read += len(file_bytes) + + assert total_read == size_bytes, f"Read {total_read} bytes, expected {size_bytes}" + assert sum(p['size'] for p in pieces) == size_bytes, "Piece sizes do not sum to file size" resource_json = { 'trackerIp': TRACKER_IP, @@ -73,6 +75,7 @@ def create_resource_json(name: str, comment: str, file_path, piece_size: int = 1 'pieces': pieces } + print(f"Adaptive split: {len(pieces)} pieces, piece size: {piece_size} bytes") return resource_json @@ -80,7 +83,7 @@ def create_resource_from_json(resource_json): ''' Create a resource from the given JSON data. ''' - pieces = [Resource.Piece(**piece) for piece in resource_json['pieces']] + pieces = [Resource.Piece(sha256=piece['sha256'], size_bytes=piece['size']) for piece in resource_json['pieces']] resource = Resource( tracker_ip=resource_json['trackerIp'], tracker_port=resource_json['trackerPort'], @@ -93,35 +96,39 @@ def create_resource_from_json(resource_json): # --- Torrent logic --- -class TorrentInno: - peer_id = generate_peer_id() - resource_manager_dict: Dict[str, ResourceManager] = {} +class TorrentInno: + def __init__(self): + self.peer_id = generate_peer_id() + self.resource_manager_dict: Dict[str, ResourceManager] = {} - async def start_share_file(destination, resource: Resource, self): + async def start_share_file(self, destination, resource: Resource): ''' Function what starting sharing of file, and updating peer information on tracker server ''' peer_public_ip = get_peer_public_ip() - local_resource_manager = ResourceManager(self.peer_id, destination, resource) + local_resource_manager = ResourceManager(self.peer_id, Path(destination), resource) self.resource_manager_dict[destination] = local_resource_manager peer_public_port = await self.resource_manager_dict.get(destination).full_start() resource_info_hash = resource.get_info_hash() peer = { - "peerId": self.peer_id, - "infoHash": resource_info_hash, - "publicIp": peer_public_ip, - "publicPort": peer_public_port + "peerId": str(self.peer_id), + "infoHash": str(resource_info_hash), + "publicIp": str(peer_public_ip), + "publicPort": str(peer_public_port) } - tracker_url = f'http://{TRACKER_IP}:{TRACKER_PORT}/peers' + tracker_url = 'http://' + TRACKER_IP + f':{TRACKER_PORT}/peers' async def parse_peer_list(json_text): ''' - Function what parse json text and return list of peerInfo elements + Function to parse JSON text and return a list of PeerInfo elements. ''' peer_list = [] + if not json_text.strip(): + print("Error parsing peer list: Response is empty") + return try: data = json.loads(json_text) @@ -136,39 +143,41 @@ async def parse_peer_list(json_text): peer_list.append(peer_info) except (json.JSONDecodeError, KeyError, ValueError) as e: print(f"Error parsing peer list: {e}") - + print("Share peer list:") + print(peer_list) await self.resource_manager_dict.get(destination).submit_peers(peer_list) - task = asyncio.create_task((heart_beat(tracker_url, peer, parse_peer_list))) + task = asyncio.create_task(heart_beat(tracker_url, peer, parse_peer_list)) - async def stop_share_file(destination, self): + async def stop_share_file(self, destination): await self.resource_manager_dict.get(destination).stop_sharing_file() del self.resource_manager_dict[destination] - async def start_download_file(destination, resource: Resource, self): + + async def start_download_file(self, destination, resource: Resource): ''' Function what starting downloading of file, and updating peer information - on tracker server ''' peer_public_ip = get_peer_public_ip() - local_resource_manager = ResourceManager(self.peer_id, destination, resource) + local_resource_manager = ResourceManager(self.peer_id, Path(destination), resource) self.resource_manager_dict[destination] = local_resource_manager peer_public_port = await self.resource_manager_dict.get(destination).full_start() resource_info_hash = resource.get_info_hash() peer = { - "peerId": self.peer_id, - "infoHash": resource_info_hash, - "publicIp": peer_public_ip, - "publicPort": peer_public_port + "peerId": str(self.peer_id), + "infoHash": str(resource_info_hash), + "publicIp": str(peer_public_ip), + "publicPort": str(peer_public_port) } - tracker_url = f'http://{TRACKER_IP}:{TRACKER_PORT}/peers' + tracker_url = 'http://' + TRACKER_IP + f':{TRACKER_PORT}/peers' + peers_ready = asyncio.Event() async def parse_peer_list(json_text): - ''' - Function what parse json text and return list of peerInfo elements - ''' peer_list = [] + if not json_text.strip(): + print("Error parsing peer list: Response is empty") + return try: data = json.loads(json_text) @@ -184,13 +193,17 @@ async def parse_peer_list(json_text): except (json.JSONDecodeError, KeyError, ValueError) as e: print(f"Error parsing peer list: {e}") - await self.resource_manager_dict.get(destination).submit_peers(peer_list) + filtered_peers = [p for p in peer_list if p.peer_id != self.peer_id] + await self.resource_manager_dict.get(destination).submit_peers(filtered_peers) + if filtered_peers: + peers_ready.set() + print('download peer list:') + print(filtered_peers) - task = asyncio.create_task((heart_beat(tracker_url, peer, parse_peer_list))) + task = asyncio.create_task(heart_beat(tracker_url, peer, parse_peer_list)) + await peers_ready.wait() await self.resource_manager_dict.get(destination).start_download() - - - async def stop_download_file(destination, self): + async def stop_download_file(self, destination): ''' Function what stopping downloading of file, and updating peer information on tracker server @@ -198,13 +211,11 @@ async def stop_download_file(destination, self): await self.resource_manager_dict.get(destination).stop_download() del self.resource_manager_dict[destination] - - async def get_state(destination, self): + async def get_state(self, destination): ''' Function what starting downloading of file, and updating peer information on tracker server ''' - return await self.resource_manager_dict.get(destination).get_state() async def get_all_files_state(self): @@ -214,6 +225,12 @@ async def get_all_files_state(self): return_list = [] for key in self.resource_manager_dict.keys(): - return_list.append(tuple(key, self.resource_manager_dict.get(key).get_state())) + return_list.append((key, await self.resource_manager_dict.get(key).get_state())) return return_list + + async def exit_with_saving_state(self): + ''' + Function what save states of all files when you close torrentInno + ''' + From a0861d6d1dd46ebaa1aefd67469c6eed88b65a4c Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Tue, 29 Apr 2025 20:11:31 +0300 Subject: [PATCH 30/46] Start implementing cli.py --- client/cli/__init__.py | 0 client/cli/cli.py | 94 ++++++++++++++++++++++++++++++++++++++++++ client/pyproject.toml | 5 ++- 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 client/cli/__init__.py create mode 100644 client/cli/cli.py diff --git a/client/cli/__init__.py b/client/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/cli/cli.py b/client/cli/cli.py new file mode 100644 index 0000000..07f6fe8 --- /dev/null +++ b/client/cli/cli.py @@ -0,0 +1,94 @@ +import asyncio +import datetime +import threading +from pathlib import Path +import json + +from core.common.resource import Resource + + +def get_help_message() -> str: + return ( + 'TorrentInno CLI reference:\n\n' + + '"help" - display this message\n' + + '"quit" - quit the CLI and terminate the torrent session\n' + + '"download " - ' + 'start downloading the file associated with into the \n' + + '"share " - ' + 'start sharing the existing with other peers. The ' + 'is the metadata of the filet\n' + + '"show all" - show the status of all files\n' + + '"show " - show the status of file with \n' + + '"generate resource " - generate the resource json of the ' + 'and save the result into ' + ) + + +def create_resource_from_file(file: Path): + with open(file, mode='r') as f: + resource_dict = json.load(f) + resource = Resource( + tracker_ip=resource_dict["trackerIp"], + tracker_port=resource_dict["trackerPort"], + comment=resource_dict["comment"], + creation_date=datetime.datetime.fromisoformat(resource_dict["creationDate"]), + name=resource_dict["name"], + pieces=[ + Resource.Piece( + sha256=piece_dict['sha256'], + size_bytes=int(piece_dict['size']) + ) + for piece_dict in resource_dict["pieces"] + ] + ) + print(resource_dict, resource) + return resource + + +class Client: + async def start(self): + print( + "Welcome to TorrentInno CLI\n" + "Type \"help\" to display the help message\n" + "To exit type \"quit\"" + ) + try: + self.infinite_loop() + except KeyboardInterrupt: + print("Quitting") + + def infinite_loop(self): + while True: + print(">", end=' ') + line = input() + if line == "help": + print(get_help_message()) + continue + if line == "quit": + print("Quitting") + break + tokens = line.split(' ') + if tokens[0] == "download": + try: + destination = Path(tokens[1]) + resource_file = Path(tokens[2]) + if destination.exists(): + print(f"The {destination} already exists. Abort download") + continue + resource = create_resource_from_file(resource_file) + # TODO: start the download somehow + except Exception as e: + print(f"Download failed: {e}") + continue + + +def main(): + client = Client() + asyncio.run(client.start()) diff --git a/client/pyproject.toml b/client/pyproject.toml index e2675f7..1c0c4b4 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -9,10 +9,11 @@ dependencies = [ "requests (>=2.32.3,<3.0.0)" ] +[tool.poetry.scripts] +cli = "cli.cli:main" [tool.poetry] -package-mode = false - +packages = [{ include = "cli" }] [tool.poetry.group.dev.dependencies] pytest = "^8.3.5" From fbc4f464e222c6b328f0aa4235a6c9264d8cb834 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Tue, 29 Apr 2025 20:16:41 +0300 Subject: [PATCH 31/46] edits in torrentinno.py --- client/torrentInno.py | 72 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/client/torrentInno.py b/client/torrentInno.py index af606fc..8b95085 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -9,6 +9,7 @@ from typing import Dict from pathlib import Path +from dataclasses import dataclass from core.p2p.resource_manager import ResourceManager from core.s2p.server_manager import update_peer, heart_beat @@ -95,12 +96,36 @@ def create_resource_from_json(resource_json): return resource # --- Torrent logic --- - - class TorrentInno: + @dataclass + class State: + piece_status: list[bool] + upload_speed_bytes_per_sec: int + download_speed_bytes_per_sec: int + def __init__(self): self.peer_id = generate_peer_id() self.resource_manager_dict: Dict[str, ResourceManager] = {} + state_file = "last_used.json" + if os.path.exists(state_file): + with open(state_file, 'r', encoding='utf-8') as f: + state_dict = json.load(f) + for key, entry in state_dict.items(): + resource_data = entry['resource'] + pieces = [ + Resource.Piece(sha256=piece['sha256'], size_bytes=piece['size_bytes']) + for piece in resource_data['pieces'] + ] + resource = Resource( + tracker_ip=resource_data['tracker_ip'], + tracker_port=resource_data['tracker_port'], + comment=resource_data['comment'], + creation_date=resource_data['creation_date'], + name=resource_data['name'], + pieces=pieces + ) + manager = ResourceManager(self.peer_id, Path(key), resource) + self.resource_manager_dict[key] = manager async def start_share_file(self, destination, resource: Resource): ''' @@ -203,6 +228,7 @@ async def parse_peer_list(json_text): task = asyncio.create_task(heart_beat(tracker_url, peer, parse_peer_list)) await peers_ready.wait() await self.resource_manager_dict.get(destination).start_download() + async def stop_download_file(self, destination): ''' Function what stopping downloading of file, and updating peer information @@ -216,7 +242,11 @@ async def get_state(self, destination): Function what starting downloading of file, and updating peer information on tracker server ''' - return await self.resource_manager_dict.get(destination).get_state() + states: ResourceManager.State = await self.resource_manager_dict.get(destination).get_state() + + return self.State(states.piece_status, + states.upload_speed_bytes_per_sec, + states.download_speed_bytes_per_sec) async def get_all_files_state(self): ''' @@ -230,7 +260,35 @@ async def get_all_files_state(self): return return_list async def exit_with_saving_state(self): - ''' - Function what save states of all files when you close torrentInno - ''' - + ''' + Save the state and resource info of all resource managers to a file. + ''' + state_dict = {} + for key, manager in self.resource_manager_dict.items(): + state = await manager.get_state() + if hasattr(state, '__dict__'): + state = state.__dict__ + resource = manager.resource + if hasattr(resource, '__dict__'): + resource_dict = resource.__dict__.copy() + if 'pieces' in resource_dict: + resource_dict['pieces'] = [ + piece.__dict__ if hasattr(piece, '__dict__') else piece + for piece in resource_dict['pieces'] + ] + else: + resource_dict = str(resource) + state_dict[str(key)] = { + 'state': state, + 'resource': resource_dict + } + + with open("last_used.json", 'w', encoding='utf-8') as f: + json.dump(state_dict, f, ensure_ascii=False, indent=4) + + async def remove_from_torrent(self , destination): + ''' + Function what removing file from torrent + ''' + self.resource_manager_dict.get(destination).shutdown() + del self.resource_manager_dict[destination] \ No newline at end of file From c7e38cd3b1cd911c4b343abf8a46b0d9ab9b69a4 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Tue, 29 Apr 2025 20:19:08 +0300 Subject: [PATCH 32/46] Edited torrentinno.py --- client/torrentInno.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/torrentInno.py b/client/torrentInno.py index 8b95085..0de0325 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -175,7 +175,11 @@ async def parse_peer_list(json_text): task = asyncio.create_task(heart_beat(tracker_url, peer, parse_peer_list)) async def stop_share_file(self, destination): + ''' + Function what stopping sharing of file, and updating peer information + ''' await self.resource_manager_dict.get(destination).stop_sharing_file() + await self.resource_manager_dict.get(destination).shutdown() del self.resource_manager_dict[destination] @@ -235,6 +239,7 @@ async def stop_download_file(self, destination): on tracker server ''' await self.resource_manager_dict.get(destination).stop_download() + await self.resource_manager_dict.get(destination).shutdown() del self.resource_manager_dict[destination] async def get_state(self, destination): @@ -290,5 +295,5 @@ async def remove_from_torrent(self , destination): ''' Function what removing file from torrent ''' - self.resource_manager_dict.get(destination).shutdown() + await self.resource_manager_dict.get(destination).shutdown() del self.resource_manager_dict[destination] \ No newline at end of file From 67686275635f0cff792b49abf9aa99e6683ac8cc Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Tue, 29 Apr 2025 20:24:45 +0300 Subject: [PATCH 33/46] Edited torrentinno.py --- client/torrentInno.py | 47 ------------------------------------------- 1 file changed, 47 deletions(-) diff --git a/client/torrentInno.py b/client/torrentInno.py index 0de0325..d08e678 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -106,26 +106,6 @@ class State: def __init__(self): self.peer_id = generate_peer_id() self.resource_manager_dict: Dict[str, ResourceManager] = {} - state_file = "last_used.json" - if os.path.exists(state_file): - with open(state_file, 'r', encoding='utf-8') as f: - state_dict = json.load(f) - for key, entry in state_dict.items(): - resource_data = entry['resource'] - pieces = [ - Resource.Piece(sha256=piece['sha256'], size_bytes=piece['size_bytes']) - for piece in resource_data['pieces'] - ] - resource = Resource( - tracker_ip=resource_data['tracker_ip'], - tracker_port=resource_data['tracker_port'], - comment=resource_data['comment'], - creation_date=resource_data['creation_date'], - name=resource_data['name'], - pieces=pieces - ) - manager = ResourceManager(self.peer_id, Path(key), resource) - self.resource_manager_dict[key] = manager async def start_share_file(self, destination, resource: Resource): ''' @@ -264,33 +244,6 @@ async def get_all_files_state(self): return return_list - async def exit_with_saving_state(self): - ''' - Save the state and resource info of all resource managers to a file. - ''' - state_dict = {} - for key, manager in self.resource_manager_dict.items(): - state = await manager.get_state() - if hasattr(state, '__dict__'): - state = state.__dict__ - resource = manager.resource - if hasattr(resource, '__dict__'): - resource_dict = resource.__dict__.copy() - if 'pieces' in resource_dict: - resource_dict['pieces'] = [ - piece.__dict__ if hasattr(piece, '__dict__') else piece - for piece in resource_dict['pieces'] - ] - else: - resource_dict = str(resource) - state_dict[str(key)] = { - 'state': state, - 'resource': resource_dict - } - - with open("last_used.json", 'w', encoding='utf-8') as f: - json.dump(state_dict, f, ensure_ascii=False, indent=4) - async def remove_from_torrent(self , destination): ''' Function what removing file from torrent From f038975d947f6048956a62faf7231c12288cd7d4 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Tue, 29 Apr 2025 20:38:04 +0300 Subject: [PATCH 34/46] Edited torrentInno.py --- client/torrentInno.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/client/torrentInno.py b/client/torrentInno.py index d08e678..c6bb5d0 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -102,6 +102,7 @@ class State: piece_status: list[bool] upload_speed_bytes_per_sec: int download_speed_bytes_per_sec: int + destination: str def __init__(self): self.peer_id = generate_peer_id() @@ -230,8 +231,9 @@ async def get_state(self, destination): states: ResourceManager.State = await self.resource_manager_dict.get(destination).get_state() return self.State(states.piece_status, - states.upload_speed_bytes_per_sec, - states.download_speed_bytes_per_sec) + states.upload_speed_bytes_per_sec, + states.download_speed_bytes_per_sec, + destination) async def get_all_files_state(self): ''' @@ -240,7 +242,13 @@ async def get_all_files_state(self): return_list = [] for key in self.resource_manager_dict.keys(): - return_list.append((key, await self.resource_manager_dict.get(key).get_state())) + state = await self.resource_manager_dict.get(key).get_state() + return_list.append((key, self.State( + state.piece_status, + state.upload_speed_bytes_per_sec, + state.download_speed_bytes_per_sec, + key + ))) return return_list From 970ee1ab648f4d3e5b5b5d196c06090a388b1fd8 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Tue, 29 Apr 2025 21:42:23 +0300 Subject: [PATCH 35/46] Start implementing cli --- client/.gitignore | 1 + client/cli/cli.py | 112 +++++++++++++++++++++++++++++++++------------- 2 files changed, 82 insertions(+), 31 deletions(-) create mode 100644 client/.gitignore diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..038d841 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1 @@ +last_used.json \ No newline at end of file diff --git a/client/cli/cli.py b/client/cli/cli.py index 07f6fe8..3878a20 100644 --- a/client/cli/cli.py +++ b/client/cli/cli.py @@ -1,10 +1,10 @@ import asyncio -import datetime -import threading from pathlib import Path import json +import shlex from core.common.resource import Resource +from torrentInno import TorrentInno, create_resource_json, create_resource_from_json def get_help_message() -> str: @@ -19,40 +19,28 @@ def get_help_message() -> str: 'start downloading the file associated with into the \n' '"share " - ' - 'start sharing the existing with other peers. The ' + 'start sharing the existing with other peers. The ' 'is the metadata of the filet\n' '"show all" - show the status of all files\n' - '"show " - show the status of file with \n' + '"show " - show the status of file at the path \n' '"generate resource " - generate the resource json of the ' 'and save the result into ' ) -def create_resource_from_file(file: Path): +def create_resource_from_file(file: Path) -> Resource: with open(file, mode='r') as f: - resource_dict = json.load(f) - resource = Resource( - tracker_ip=resource_dict["trackerIp"], - tracker_port=resource_dict["trackerPort"], - comment=resource_dict["comment"], - creation_date=datetime.datetime.fromisoformat(resource_dict["creationDate"]), - name=resource_dict["name"], - pieces=[ - Resource.Piece( - sha256=piece_dict['sha256'], - size_bytes=int(piece_dict['size']) - ) - for piece_dict in resource_dict["pieces"] - ] - ) - print(resource_dict, resource) - return resource + resource_json = json.load(f) + return create_resource_from_json(resource_json) class Client: + def __init__(self): + self.torrent_inno: TorrentInno = None # TODO: initialize in start() + async def start(self): print( "Welcome to TorrentInno CLI\n" @@ -60,35 +48,97 @@ async def start(self): "To exit type \"quit\"" ) try: - self.infinite_loop() + await self.infinite_loop() except KeyboardInterrupt: print("Quitting") - def infinite_loop(self): + async def infinite_loop(self): while True: print(">", end=' ') line = input() if line == "help": print(get_help_message()) continue + if line == "quit": print("Quitting") break - tokens = line.split(' ') + + tokens = shlex.split(line) + if tokens[0] == "download": try: - destination = Path(tokens[1]) - resource_file = Path(tokens[2]) + destination = Path(tokens[1]).expanduser() + resource_file = Path(tokens[2]).expanduser() if destination.exists(): print(f"The {destination} already exists. Abort download") continue resource = create_resource_from_file(resource_file) - # TODO: start the download somehow + await self.torrent_inno.start_download_file(destination.resolve(), resource) + print(f"Start downloading a file into {destination.resolve()}") except Exception as e: print(f"Download failed: {e}") - continue + + elif len(tokens) == 2 and tokens[0] == "show" and tokens[1] == "all": + try: + states = await self.torrent_inno.get_all_files_state() + for state in states: + print(state) + except Exception as e: + print(f"Something went wrong: {e}") + + elif tokens[0] == "share": + try: + destination = Path(tokens[1]).expanduser() + resource_file = Path(tokens[2]).expanduser() + if not destination.exists(): + print(f"The {destination} does not exist. Abort share") + continue + resource = create_resource_from_file(resource_file) + await self.torrent_inno.start_share_file(destination.resolve(), resource) + print(f"Start sharing file at {destination}") + except Exception as e: + print(f"Share failed: {e}") + + elif tokens[0] == "show": + try: + destination = Path(tokens[1]).expanduser() + state = await self.torrent_inno.get_state(destination.resolve()) + print(state) + except Exception as e: + print(f"Fail when fetching the status of file at {tokens[0]}: {e}") + + elif len(tokens) == 4 and tokens[0] == "generate" and tokens[1] == "resource": + try: + file = Path(tokens[2]).expanduser() + resource_file = Path(tokens[3]).expanduser() + + print(file.resolve()) + + if not file.exists(): + print(f"File {file.resolve()} does not exist. Abort generation") + continue + + # Start the interactive session + print("Enter the comment: ") + comment = input() + + print("Enter the name of the resource file: ") + name = input() + + resource_json = create_resource_json(name=name, comment=comment, file_path=file) + with open(resource_file, mode='w') as f: + json.dump(resource_json, f, indent=4, ensure_ascii=False) + print("Successfully created") + except Exception as e: + print(f"Failed when generating the resource file: {e}") + else: + print("Unknown command") def main(): - client = Client() - asyncio.run(client.start()) + try: + client = Client() + asyncio.run(client.start()) + except Exception as e: + print("Quitting") From 8c16500b43f7ac133612fef9da0dd6172226db0b Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Tue, 29 Apr 2025 23:33:58 +0300 Subject: [PATCH 36/46] Edited torrention.py --- client/core/s2p/server_manager.py | 11 ++++++----- client/torrentInno.py | 21 ++++++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/client/core/s2p/server_manager.py b/client/core/s2p/server_manager.py index 9b49405..1d58872 100644 --- a/client/core/s2p/server_manager.py +++ b/client/core/s2p/server_manager.py @@ -1,22 +1,23 @@ import requests import time import asyncio +import logging def update_peer(server_url, peer) -> str: - print(server_url) + logging.info(server_url) ''' Using post requests create or update peer information on tracker server ''' try: response = requests.post(server_url, json=peer, timeout=5) if response.status_code == 200: - print("Peer updated successfully.") + logging.info("Peer updated successfully.") else: - print(f"Failed to update peer. Status code: {response.status_code}") - print(f"Response content: {response.text}") # Log the response content for debugging + logging.info(f"Failed to update peer. Status code: {response.status_code}") + logging.info(f"Response content: {response.text}") # Log the response content for debugging return response.text except requests.exceptions.RequestException as e: - print(f"Error updating peer: {e}") + logging.info(f"Error updating peer: {e}") return '' async def heart_beat(server_url, peer, on_tracker_response) -> str: diff --git a/client/torrentInno.py b/client/torrentInno.py index c6bb5d0..15ead65 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -6,6 +6,7 @@ import os import hashlib import math +import logging from typing import Dict from pathlib import Path @@ -20,6 +21,8 @@ TRACKER_IP = '80.71.232.39' TRACKER_PORT = 8080 +logging.basicConfig(level=logging.INFO) + # --- utility functions --- def generate_random_bits(size) -> bytes: @@ -76,7 +79,7 @@ def create_resource_json(name: str, comment: str, file_path, max_pieces: int = 1 'pieces': pieces } - print(f"Adaptive split: {len(pieces)} pieces, piece size: {piece_size} bytes") + logging.info(f"Adaptive split: {len(pieces)} pieces, piece size: {piece_size} bytes") return resource_json @@ -133,7 +136,7 @@ async def parse_peer_list(json_text): peer_list = [] if not json_text.strip(): - print("Error parsing peer list: Response is empty") + logging.info("Error parsing peer list: Response is empty") return try: @@ -148,9 +151,9 @@ async def parse_peer_list(json_text): ) peer_list.append(peer_info) except (json.JSONDecodeError, KeyError, ValueError) as e: - print(f"Error parsing peer list: {e}") - print("Share peer list:") - print(peer_list) + logging.info(f"Error parsing peer list: {e}") + logging.info("Share peer list:") + logging.info(peer_list) await self.resource_manager_dict.get(destination).submit_peers(peer_list) task = asyncio.create_task(heart_beat(tracker_url, peer, parse_peer_list)) @@ -186,7 +189,7 @@ async def start_download_file(self, destination, resource: Resource): async def parse_peer_list(json_text): peer_list = [] if not json_text.strip(): - print("Error parsing peer list: Response is empty") + logging.info("Error parsing peer list: Response is empty") return try: @@ -201,14 +204,14 @@ async def parse_peer_list(json_text): ) peer_list.append(peer_info) except (json.JSONDecodeError, KeyError, ValueError) as e: - print(f"Error parsing peer list: {e}") + logging.info(f"Error parsing peer list: {e}") filtered_peers = [p for p in peer_list if p.peer_id != self.peer_id] await self.resource_manager_dict.get(destination).submit_peers(filtered_peers) if filtered_peers: peers_ready.set() - print('download peer list:') - print(filtered_peers) + logging.info('download peer list:') + logging.info(filtered_peers) task = asyncio.create_task(heart_beat(tracker_url, peer, parse_peer_list)) await peers_ready.wait() From efc4d2154274cc5fb7e73466feb923b0e78946cd Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Tue, 29 Apr 2025 23:43:21 +0300 Subject: [PATCH 37/46] Move the torrent inno to background thread in cli --- client/.gitignore | 3 +- client/cli/cli.py | 64 ++++++++++++++++++---- client/core/p2p/resource_manager.py | 8 +-- client/core/tests/resource_manager_test.py | 12 +++- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/client/.gitignore b/client/.gitignore index 038d841..ea58291 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -1 +1,2 @@ -last_used.json \ No newline at end of file +last_used.json +*.log \ No newline at end of file diff --git a/client/cli/cli.py b/client/cli/cli.py index 3878a20..bd60861 100644 --- a/client/cli/cli.py +++ b/client/cli/cli.py @@ -1,7 +1,9 @@ import asyncio +import logging from pathlib import Path import json import shlex +import threading from core.common.resource import Resource from torrentInno import TorrentInno, create_resource_json, create_resource_from_json @@ -20,7 +22,7 @@ def get_help_message() -> str: '"share " - ' 'start sharing the existing with other peers. The ' - 'is the metadata of the filet\n' + 'is the metadata of the file\n' '"show all" - show the status of all files\n' @@ -37,22 +39,40 @@ def create_resource_from_file(file: Path) -> Resource: return create_resource_from_json(resource_json) +def _run_event_loop(loop): + asyncio.set_event_loop(loop) + loop.run_forever() + + +def setup_logging(): + log_file = Path(__file__).parent.joinpath('cli_logs.log') + logging.basicConfig( + level=logging.DEBUG, + filename=log_file + ) + + class Client: def __init__(self): - self.torrent_inno: TorrentInno = None # TODO: initialize in start() + setup_logging() + + self.torrent_inno: TorrentInno = TorrentInno() + self.loop = asyncio.new_event_loop() + self.background_thread = threading.Thread(target=_run_event_loop, args=(self.loop,)) + self.background_thread.start() - async def start(self): + def start(self): print( "Welcome to TorrentInno CLI\n" "Type \"help\" to display the help message\n" "To exit type \"quit\"" ) try: - await self.infinite_loop() + self.infinite_loop() except KeyboardInterrupt: print("Quitting") - async def infinite_loop(self): + def infinite_loop(self): while True: print(">", end=' ') line = input() @@ -74,14 +94,21 @@ async def infinite_loop(self): print(f"The {destination} already exists. Abort download") continue resource = create_resource_from_file(resource_file) - await self.torrent_inno.start_download_file(destination.resolve(), resource) + asyncio.run_coroutine_threadsafe( + self.torrent_inno.start_download_file(destination.resolve(), resource), + self.loop + ) print(f"Start downloading a file into {destination.resolve()}") except Exception as e: print(f"Download failed: {e}") elif len(tokens) == 2 and tokens[0] == "show" and tokens[1] == "all": try: - states = await self.torrent_inno.get_all_files_state() + states = asyncio.run_coroutine_threadsafe( + self.torrent_inno.get_all_files_state(), + self.loop + ).result() + for state in states: print(state) except Exception as e: @@ -94,8 +121,12 @@ async def infinite_loop(self): if not destination.exists(): print(f"The {destination} does not exist. Abort share") continue + resource = create_resource_from_file(resource_file) - await self.torrent_inno.start_share_file(destination.resolve(), resource) + asyncio.run_coroutine_threadsafe( + self.torrent_inno.start_share_file(destination.resolve(), resource), + self.loop + ) print(f"Start sharing file at {destination}") except Exception as e: print(f"Share failed: {e}") @@ -103,10 +134,13 @@ async def infinite_loop(self): elif tokens[0] == "show": try: destination = Path(tokens[1]).expanduser() - state = await self.torrent_inno.get_state(destination.resolve()) + state = asyncio.run_coroutine_threadsafe( + self.torrent_inno.get_state(destination.resolve()), + self.loop + ).result() print(state) except Exception as e: - print(f"Fail when fetching the status of file at {tokens[0]}: {e}") + print(f"Fail when fetching the status of file at {tokens[1]}: {e}") elif len(tokens) == 4 and tokens[0] == "generate" and tokens[1] == "resource": try: @@ -126,7 +160,13 @@ async def infinite_loop(self): print("Enter the name of the resource file: ") name = input() - resource_json = create_resource_json(name=name, comment=comment, file_path=file) + resource_json = create_resource_json( + name=name, + comment=comment, + file_path=file, + min_piece_size=1000 * 1000, + max_pieces=1000 + ) with open(resource_file, mode='w') as f: json.dump(resource_json, f, indent=4, ensure_ascii=False) print("Successfully created") @@ -139,6 +179,6 @@ async def infinite_loop(self): def main(): try: client = Client() - asyncio.run(client.start()) + client.start() except Exception as e: print("Quitting") diff --git a/client/core/p2p/resource_manager.py b/client/core/p2p/resource_manager.py index e598cd5..17f85fd 100644 --- a/client/core/p2p/resource_manager.py +++ b/client/core/p2p/resource_manager.py @@ -217,13 +217,6 @@ async def _calc_network_stats(self): self._network_stats.last_drop_timestamp_seconds = time.time() self._network_stats.bytes_downloaded_since_last_drop = 0 self._network_stats.bytes_uploaded_since_last_drop = 0 - - logging.info( - self._log_prefix(f"Download speed: {self._network_stats.prev_download_bytes_per_sec // 1000} kb/sec") - ) - logging.info( - self._log_prefix(f"Upload speed: {self._network_stats.prev_upload_bytes_per_sec // 1000} kb/sec") - ) await asyncio.sleep(2) def __init__( @@ -317,6 +310,7 @@ async def open_public_port(self) -> int: if self._broadcast_task is None: self._broadcast_task = asyncio.create_task(self._periodic_broadcast()) + logging.info(self._log_prefix(f'Open public port {port}')) # Return port on which connection has been opened return port diff --git a/client/core/tests/resource_manager_test.py b/client/core/tests/resource_manager_test.py index 2efb6df..949744f 100644 --- a/client/core/tests/resource_manager_test.py +++ b/client/core/tests/resource_manager_test.py @@ -30,8 +30,16 @@ async def create_peer(peer_id: str, destination: Path, resource) -> tuple[PeerIn return peer_info, resource_manager +def setup_logging(): + log_file = Path(__file__).parent.joinpath("resource_manager_test.log") + logging.basicConfig( + filename=log_file, + level=logging.DEBUG + ) + + async def main(): - logging.basicConfig(level=logging.INFO) + setup_logging() # Temporary directory and necessary file tree manipulations tmp = Path(__file__).parent.joinpath('tmp') @@ -45,7 +53,7 @@ async def main(): source_peer_id = random_peer_id() source_peer_destination = tmp.joinpath('source', 'data') source_peer_destination.parent.mkdir(parents=True) - piece_sizes = [random.randint(5 * 10 ** 5, 10 ** 6) for _ in range(100)] + piece_sizes = [random.randint(5 * 10 ** 5, 10 ** 6) for _ in range(10)] offset = [0] + list(accumulate(piece_sizes)) with open(source_peer_destination, mode='wb') as file: From 0be1b95e504b6e7ed78961b7c0a77d71b184adb9 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Wed, 30 Apr 2025 00:45:19 +0300 Subject: [PATCH 38/46] Finish CLI --- client/cli/cli.py | 67 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/client/cli/cli.py b/client/cli/cli.py index bd60861..f69a98f 100644 --- a/client/cli/cli.py +++ b/client/cli/cli.py @@ -1,10 +1,14 @@ import asyncio import logging +import sys +import time from pathlib import Path import json import shlex import threading +import os +import torrentInno from core.common.resource import Resource from torrentInno import TorrentInno, create_resource_json, create_resource_from_json @@ -48,14 +52,14 @@ def setup_logging(): log_file = Path(__file__).parent.joinpath('cli_logs.log') logging.basicConfig( level=logging.DEBUG, - filename=log_file + filename=log_file, + force=True ) class Client: def __init__(self): setup_logging() - self.torrent_inno: TorrentInno = TorrentInno() self.loop = asyncio.new_event_loop() self.background_thread = threading.Thread(target=_run_event_loop, args=(self.loop,)) @@ -80,10 +84,6 @@ def infinite_loop(self): print(get_help_message()) continue - if line == "quit": - print("Quitting") - break - tokens = shlex.split(line) if tokens[0] == "download": @@ -104,13 +104,20 @@ def infinite_loop(self): elif len(tokens) == 2 and tokens[0] == "show" and tokens[1] == "all": try: - states = asyncio.run_coroutine_threadsafe( + states: list[TorrentInno.State] = asyncio.run_coroutine_threadsafe( self.torrent_inno.get_all_files_state(), self.loop ).result() - for state in states: - print(state) + for key, state in states: + all_pieces = len(state.piece_status) + saved_pieces = sum(state.piece_status) + print( + f'Destination: {state.destination}\n' + f'Upload speed {state.upload_speed_bytes_per_sec / 10 ** 6:.2f} mb/sec\n' + f'Download speed {state.download_speed_bytes_per_sec / 10 ** 6:.2f} mb/sec\n' + f'Downloaded {saved_pieces}/{all_pieces} pieces\n' + ) except Exception as e: print(f"Something went wrong: {e}") @@ -134,11 +141,41 @@ def infinite_loop(self): elif tokens[0] == "show": try: destination = Path(tokens[1]).expanduser() - state = asyncio.run_coroutine_threadsafe( - self.torrent_inno.get_state(destination.resolve()), - self.loop - ).result() - print(state) + + while True: + # Get the state asynchronously + state: TorrentInno.State = asyncio.run_coroutine_threadsafe( + self.torrent_inno.get_state(destination.resolve()), + self.loop + ).result() + + # Convert speed to MB (1 MB = 10^6 bytes) + upload_speed_mb = state.upload_speed_bytes_per_sec / 10 ** 6 + download_speed_mb = state.download_speed_bytes_per_sec / 10 ** 6 + + # Create the saved_chunks string + saved_pieces = ''.join('#' if piece else '.' for piece in state.piece_status) + + # Clear the terminal + os.system('cls' if os.name == 'nt' else 'clear') + + print( + f"Destination: {state.destination}" + " " * 20 + ) # Add padding to clear longer previous lines + print( + f"Upload speed: {upload_speed_mb:.2f} mb/sec" + " " * 20 + ) + print( + f"Download speed: {download_speed_mb:.2f} mb/sec" + " " * 20 + ) + print( + f"Saved pieces: {saved_pieces}" + " " * 20 + ) + + # Wait before updating again + time.sleep(0.5) + except KeyboardInterrupt as e: + pass # Ignore keyboard interrupt and simply continue except Exception as e: print(f"Fail when fetching the status of file at {tokens[1]}: {e}") @@ -164,7 +201,7 @@ def infinite_loop(self): name=name, comment=comment, file_path=file, - min_piece_size=1000 * 1000, + min_piece_size=64 * 1000, max_pieces=1000 ) with open(resource_file, mode='w') as f: From 8fe4a69c135209a875f899d98aeef2b59f35240d Mon Sep 17 00:00:00 2001 From: Eugene Bortsov Date: Wed, 30 Apr 2025 14:42:04 +0300 Subject: [PATCH 39/46] Improve CLI --- client/cli/cli.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client/cli/cli.py b/client/cli/cli.py index f69a98f..d236f45 100644 --- a/client/cli/cli.py +++ b/client/cli/cli.py @@ -78,7 +78,7 @@ def start(self): def infinite_loop(self): while True: - print(">", end=' ') + print("\033[92mTorrentInno>\033[0m", end=' ') line = input() if line == "help": print(get_help_message()) @@ -86,13 +86,21 @@ def infinite_loop(self): tokens = shlex.split(line) + if len(tokens) == 0: + continue + if tokens[0] == "download": try: destination = Path(tokens[1]).expanduser() resource_file = Path(tokens[2]).expanduser() + if destination.exists(): print(f"The {destination} already exists. Abort download") continue + if not destination.parent.exists(): + print(f"The parent folder {destination.parent} does not exist. Abort download") + continue + resource = create_resource_from_file(resource_file) asyncio.run_coroutine_threadsafe( self.torrent_inno.start_download_file(destination.resolve(), resource), @@ -176,6 +184,7 @@ def infinite_loop(self): time.sleep(0.5) except KeyboardInterrupt as e: pass # Ignore keyboard interrupt and simply continue + print() # Print the new line except Exception as e: print(f"Fail when fetching the status of file at {tokens[1]}: {e}") @@ -201,8 +210,8 @@ def infinite_loop(self): name=name, comment=comment, file_path=file, - min_piece_size=64 * 1000, - max_pieces=1000 + min_piece_size=1000 * 1000, + max_pieces=10000 ) with open(resource_file, mode='w') as f: json.dump(resource_json, f, indent=4, ensure_ascii=False) From 692aece710749d77e0f037620e9df36c2df5479b Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Wed, 30 Apr 2025 14:43:35 +0300 Subject: [PATCH 40/46] Fix bug with exception handling of file --- client/cli/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/cli/cli.py b/client/cli/cli.py index f69a98f..8f19c4a 100644 --- a/client/cli/cli.py +++ b/client/cli/cli.py @@ -177,7 +177,7 @@ def infinite_loop(self): except KeyboardInterrupt as e: pass # Ignore keyboard interrupt and simply continue except Exception as e: - print(f"Fail when fetching the status of file at {tokens[1]}: {e}") + print(f"Fail when fetching the status of file: {e}") elif len(tokens) == 4 and tokens[0] == "generate" and tokens[1] == "resource": try: From fafe20fd033c2d4d14b026fdfe622155da3df67b Mon Sep 17 00:00:00 2001 From: Ge-os Date: Wed, 30 Apr 2025 19:41:21 +0300 Subject: [PATCH 41/46] Connecting with core v1.0 --- client/gui/app.py | 532 ++++++++++++++++++++++------- client/gui/data/file_icons/png.png | 11 + client/gui/torrent_manager.py | 427 ++++++++++++++--------- 3 files changed, 697 insertions(+), 273 deletions(-) create mode 100644 client/gui/data/file_icons/png.png diff --git a/client/gui/app.py b/client/gui/app.py index a6a2c95..1168c9d 100644 --- a/client/gui/app.py +++ b/client/gui/app.py @@ -1,11 +1,11 @@ from kivymd.app import MDApp -from kivy.lang import Builder from kivy.clock import Clock from kivy.uix.screenmanager import ScreenManager, Screen from kivy.properties import ListProperty, StringProperty, NumericProperty from kivy.metrics import dp from kivymd.uix.boxlayout import MDBoxLayout import torrent_manager +from os.path import expanduser class TorrentFileItem(MDBoxLayout): """Class representing a single torrent file in the list""" @@ -19,7 +19,7 @@ class TorrentFileItem(MDBoxLayout): def __init__(self, **kwargs): # Initialize blocks properly to avoid shared list issue - blocks_data = kwargs.pop('blocks', [0] * 20) + blocks_data = kwargs.pop('blocks', [0] * 1) self.index = kwargs.pop('index', -1) # Get the index from kwargs super(TorrentFileItem, self).__init__(**kwargs) self.blocks = blocks_data @@ -84,12 +84,42 @@ def show_options_dialog(self): self.dialog.open() def delete_item(self): - """Delete this item""" + """Delete this item and remove the physical file if it exists""" # Close the dialog self.dialog.dismiss() - # Call the remove_torrent method of MainScreen + + # Get the app instance and main screen app = MDApp.get_running_app() main_screen = app.root.get_screen('main') + + # Get the file name from the current item + file_name = self.file_name + + # Try to delete the physical file if it exists + try: + # Assume the file is in the Downloads folder + from os.path import expanduser, join + import os + + # Get the default download path + download_path = expanduser("~/Downloads") + file_path = join(download_path, file_name) + + # Check if file exists and delete it + if os.path.exists(file_path): + os.remove(file_path) + from kivymd.toast import toast + toast(f"Файл {file_name} успешно удален") + except PermissionError: + # Handle permission error + from kivymd.toast import toast + toast(f"Недостаточно прав для удаления файла {file_name}") + except Exception as e: + # Handle other errors + from kivymd.toast import toast + toast(f"Ошибка при удалении файла: {str(e)}") + + # Call the remove_torrent method of MainScreen to update the UI and torrent_state.json main_screen.remove_torrent(self.index) class MainScreen(Screen): @@ -147,12 +177,15 @@ def update_file_list(self): file_list.add_widget(item) def on_back_pressed(self): - """Handle back button press""" - pass # this would navigate back + """Handle back button press - exit the application""" + # Получаем экземпляр приложения и завершаем его работу + app = MDApp.get_running_app() + app.stop() def show_info(self): - """Show app information""" - pass # this would show an info dialog + """Show app information - open GitHub repository""" + import webbrowser + webbrowser.open('https://github.com/Timofeq1/TorrentInno') def show_menu(self): """Show app menu""" @@ -162,12 +195,9 @@ def add_torrent(self): """Add a new torrent""" from kivymd.uix.dialog import MDDialog from kivymd.uix.button import MDFlatButton - from kivymd.uix.textfield import MDTextField from kivymd.uix.boxlayout import MDBoxLayout - from kivymd.uix.filemanager import MDFileManager - from kivy.core.window import Window - from os.path import expanduser - import os + from kivymd.uix.label import MDLabel + from kivy.metrics import dp # Create a custom content for the dialog self.dialog_content = MDBoxLayout( @@ -177,15 +207,12 @@ def add_torrent(self): adaptive_height=True ) - # Add a text field for torrent URL - self.url_field = MDTextField( - hint_text="Введите торрент-ссылку", - helper_text="Например: magnet:?xt=urn:btih:...", - helper_text_mode="on_focus", + # Add a label with instructions + self.dialog_content.add_widget(MDLabel( + text="Выберите действие:", size_hint_y=None, height=dp(48) - ) - self.dialog_content.add_widget(self.url_field) + )) # Create the dialog self.add_dialog = MDDialog( @@ -194,71 +221,127 @@ def add_torrent(self): content_cls=self.dialog_content, buttons=[ MDFlatButton( - text="ВЫБРАТЬ ФАЙЛ", - on_release=self.open_file_manager + text="ВЫГРУЗИТЬ", + on_release=self.open_upload_file_manager ), MDFlatButton( - text="ОТМЕНА", - on_release=lambda x: self.add_dialog.dismiss() + text="ЗАГРУЗИТЬ", + on_release=self.open_download_dialog ), MDFlatButton( - text="ДОБАВИТЬ", - on_release=self.process_torrent_url + text="ОТМЕНА", + on_release=lambda x: self.add_dialog.dismiss() ), ], ) + # Show the dialog + self.add_dialog.open() + + def open_upload_file_manager(self, *args): + """Open file manager to select a file to share""" + from kivymd.uix.filemanager import MDFileManager + # Initialize file manager self.file_manager = MDFileManager( exit_manager=self.exit_file_manager, - select_path=self.select_torrent_file, + select_path=self.select_file_to_share, preview=True, ) - # Show the dialog - self.add_dialog.open() - - def open_file_manager(self, *args): - """Open file manager to select a torrent file""" # Set the starting path to user's home directory home_dir = expanduser("~") + self.add_dialog.dismiss() self.file_manager.show(home_dir) + + def open_download_dialog(self, *args): + """Open dialog to download a torrent""" + from kivymd.uix.dialog import MDDialog + from kivymd.uix.button import MDFlatButton + from kivymd.uix.textfield import MDTextField + from kivymd.uix.boxlayout import MDBoxLayout + from kivymd.uix.filemanager import MDFileManager + + # Dismiss the previous dialog + self.add_dialog.dismiss() + + # Create a custom content for the dialog + self.download_content = MDBoxLayout( + orientation='vertical', + spacing=dp(10), + padding=dp(10), + adaptive_height=True + ) + + # Add a text field for JSON input + self.json_field = MDTextField( + hint_text="Вставьте JSON метаданные", + multiline=True, + size_hint_y=None, + height=dp(100) + ) + self.download_content.add_widget(self.json_field) + + # Create the dialog + self.download_dialog = MDDialog( + title="Загрузить торрент", + type="custom", + content_cls=self.download_content, + buttons=[ + MDFlatButton( + text="ВЫБРАТЬ JSON ФАЙЛ", + on_release=self.open_json_file_manager + ), + MDFlatButton( + text="ОТМЕНА", + on_release=lambda x: self.download_dialog.dismiss() + ), + MDFlatButton( + text="ЗАГРУЗИТЬ", + on_release=self.process_json_input + ), + ], + ) + + # Initialize file manager for JSON selection + self.json_file_manager = MDFileManager( + exit_manager=self.exit_json_file_manager, + select_path=self.select_json_file, + preview=True, + ) + # Show the dialog + self.download_dialog.open() + def exit_file_manager(self, *args): """Close the file manager""" self.file_manager.close() - - def select_torrent_file(self, path): - """Handle torrent file selection""" - self.file_manager.close() - # Check if the file is a torrent file - if path.endswith('.torrent'): - # Сохраняем путь к файлу для использования в start_download - self.current_torrent_url = path - self.show_torrent_content_dialog(path) - else: - from kivymd.toast import toast - toast("Выбранный файл не является торрент-файлом") - def process_torrent_url(self, *args): - """Process the torrent URL entered by the user""" - url = self.url_field.text.strip() - if url.startswith('magnet:') or url.endswith('.torrent'): - self.add_dialog.dismiss() - # Сохраняем URL для использования в start_download - self.current_torrent_url = url - self.show_torrent_content_dialog(url) - else: - from kivymd.toast import toast - toast("Введите корректную торрент-ссылку") + def exit_json_file_manager(self, *args): + """Close the JSON file manager""" + self.json_file_manager.close() + + def select_file_to_share(self, path): + """Handle file selection for sharing""" + self.file_manager.close() + + # Save the selected file path + self.selected_file_path = path + + # Show dialog to enter comment and name + self.show_resource_creation_dialog(path) - def show_torrent_content_dialog(self, source): - """Show dialog with torrent content for selection""" - # Получаем данные о содержимом торрента из менеджера - mock_content = torrent_manager.get_mock_content(source) + def show_resource_creation_dialog(self, file_path): + """Show dialog to enter comment and name for resource creation""" + import os + from kivymd.uix.dialog import MDDialog + from kivymd.uix.button import MDFlatButton + from kivymd.uix.textfield import MDTextField + from kivymd.uix.boxlayout import MDBoxLayout + from kivymd.uix.label import MDLabel - from kivymd.uix.list import MDList, OneLineAvatarIconListItem, IconLeftWidget, IconRightWidget - from kivymd.uix.selectioncontrol import MDCheckbox + # Get the file name + file_name = os.path.basename(file_path) # Create content for the dialog content = MDBoxLayout( @@ -266,105 +349,310 @@ def show_torrent_content_dialog(self, source): spacing=dp(10), padding=dp(10), size_hint_y=None, - height=dp(300) + height=dp(200) ) - # Add a label for save path - save_path_box = MDBoxLayout( - orientation='horizontal', + # Add fields for comment and name + content.add_widget(MDLabel( + text="Введите комментарий к файлу:", + size_hint_y=None, + height=dp(30) + )) + + self.comment_field = MDTextField( + hint_text="Комментарий", + text="", size_hint_y=None, height=dp(48) ) + content.add_widget(self.comment_field) + content.add_widget(MDLabel( + text="Введите имя файла (оставьте пустым для использования исходного имени):", + size_hint_y=None, + height=dp(30) + )) + + self.name_field = MDTextField( + hint_text="Имя файла", + text=file_name, + size_hint_y=None, + height=dp(48) + ) + content.add_widget(self.name_field) + + # Create the dialog + self.resource_dialog = MDDialog( + title="Создание ресурса", + type="custom", + content_cls=content, + buttons=[ + MDFlatButton( + text="ОТМЕНА", + on_release=lambda x: self.resource_dialog.dismiss() + ), + MDFlatButton( + text="СОЗДАТЬ", + on_release=self.create_and_share_resource + ), + ], + ) + + self.resource_dialog.open() + + def open_json_file_manager(self, *args): + """Open file manager to select a JSON file""" + home_dir = expanduser("~") + # Установка фильтров для отображения JSON файлов + self.json_file_manager.ext = [".json"] + self.json_file_manager.show(home_dir) + + def select_json_file(self, path): + """Handle JSON file selection""" + self.json_file_manager.close() + + # Check if the file is a JSON file + if path.endswith('.json'): + try: + import json + with open(path, 'r') as f: + json_data = json.load(f) + self.json_field.text = json.dumps(json_data, indent=2) + except Exception as e: + from kivymd.toast import toast + toast(f"Ошибка при чтении JSON файла: {str(e)}") + else: + from kivymd.toast import toast + toast("Выбранный файл не является JSON файлом") + + def process_json_input(self, *args): + """Process the JSON input and start download""" + import json + from kivymd.toast import toast + + json_text = self.json_field.text.strip() + if not json_text: + toast("Введите JSON метаданные или выберите файл") + return + + try: + # Parse the JSON + resource_json = json.loads(json_text) + + # Show dialog to select save path + self.show_save_path_dialog(resource_json) + except json.JSONDecodeError: + toast("Некорректный формат JSON") + + def show_save_path_dialog(self, resource_json): + """Show dialog to select save path""" + from kivymd.uix.dialog import MDDialog + from kivymd.uix.button import MDFlatButton + from kivymd.uix.textfield import MDTextField + from kivymd.uix.boxlayout import MDBoxLayout from kivymd.uix.label import MDLabel - save_path_label = MDLabel( - text="Путь сохранения:", - size_hint_x=0.3 + + # Dismiss the previous dialog + self.download_dialog.dismiss() + + # Create content for the dialog + content = MDBoxLayout( + orientation='vertical', + spacing=dp(10), + padding=dp(10), + size_hint_y=None, + height=dp(100) ) + # Add a label for save path + content.add_widget(MDLabel( + text="Путь для сохранения файла:", + size_hint_y=None, + height=dp(30) + )) + self.save_path_field = MDTextField( - text=expanduser("~/Downloads"), - size_hint_x=0.7 + text=expanduser("~/Downloads/") + resource_json.get("name", "downloaded_file"), + size_hint_y=None, + height=dp(48) ) + content.add_widget(self.save_path_field) - save_path_box.add_widget(save_path_label) - save_path_box.add_widget(self.save_path_field) - content.add_widget(save_path_box) + # Create the dialog + self.save_path_dialog = MDDialog( + title="Выберите путь сохранения", + type="custom", + content_cls=content, + buttons=[ + MDFlatButton( + text="ОТМЕНА", + on_release=lambda x: self.save_path_dialog.dismiss() + ), + MDFlatButton( + text="ЗАГРУЗИТЬ", + on_release=lambda x: self.start_download_with_resource(resource_json) + ), + ], + ) - # Create a list for files - file_list = MDList() + self.save_path_dialog.open() + + def create_and_share_resource(self, *args): + """Create resource from file and start sharing""" + import os + import json + from kivymd.toast import toast - # Add files to the list - for file in mock_content: - item = OneLineAvatarIconListItem(text=f"{file['name']} ({file['size']})") + # Get the values from fields + comment = self.comment_field.text + name = self.name_field.text if self.name_field.text else None + file_path = self.selected_file_path + + try: + # Create resource JSON + resource_json = torrent_manager.create_resource_from_file(file_path, comment, name) - # Add file icon based on extension - file_ext = file['name'].split('.')[-1] if '.' in file['name'] else 'unknown' - icon_name = "file" - if file_ext in ['mp3', 'wav', 'ogg']: - icon_name = "music-note" - elif file_ext in ['mp4', 'avi', 'mkv']: - icon_name = "video" - elif file_ext in ['txt', 'pdf', 'doc', 'docx']: - icon_name = "file-document" + # Start sharing the file + file_info = torrent_manager.start_sharing_file(file_path, resource_json) - item.add_widget(IconLeftWidget(icon=icon_name)) + # Close the dialog + self.resource_dialog.dismiss() - # Add checkbox for selection - check = MDCheckbox(active=file['selected']) - check.bind(active=lambda checkbox, value, file=file: self.toggle_file_selection(file, value)) - right_icon = IconRightWidget(widget=check) - item.add_widget(right_icon) + # Show dialog to save or copy the resource JSON + self.show_resource_save_dialog(resource_json, file_path) - file_list.add_widget(item) - - # Add the list to a scroll view + # Update the file list + self.files = torrent_manager.get_files() + self.update_file_list() + except Exception as e: + toast(f"Ошибка при создании ресурса: {str(e)}") + self.resource_dialog.dismiss() + + def show_resource_save_dialog(self, resource_json, file_path): + """Show dialog to save or copy the resource JSON""" + import os + import json + from kivymd.uix.dialog import MDDialog + from kivymd.uix.button import MDFlatButton + from kivymd.uix.textfield import MDTextField + from kivymd.uix.boxlayout import MDBoxLayout + from kivymd.uix.label import MDLabel from kivy.uix.scrollview import ScrollView + + # Create content for the dialog + content = MDBoxLayout( + orientation='vertical', + spacing=dp(10), + padding=dp(10), + size_hint_y=None, + height=dp(300) + ) + + # Add a label with instructions + content.add_widget(MDLabel( + text="Ресурс успешно создан. Вы можете скопировать JSON или сохранить его в файл:", + size_hint_y=None, + height=dp(40) + )) + + # Add a text field with the JSON + json_text = json.dumps(resource_json, indent=2) + json_field = MDTextField( + text=json_text, + multiline=True, + readonly=True, + size_hint_y=None, + height=dp(200) + ) + + # Add the text field to a scroll view scroll = ScrollView() - scroll.add_widget(file_list) + scroll.add_widget(json_field) content.add_widget(scroll) - # Create the dialog - from kivymd.uix.dialog import MDDialog - from kivymd.uix.button import MDFlatButton + # Generate default save path + file_name = os.path.basename(file_path) + default_save_path = os.path.splitext(file_path)[0] + "_meta.json" - self.content_dialog = MDDialog( - title="Выберите файлы для загрузки", + # Add a text field for save path + self.json_save_path = MDTextField( + hint_text="Путь для сохранения JSON", + text=default_save_path, + size_hint_y=None, + height=dp(48) + ) + content.add_widget(self.json_save_path) + + # Create the dialog + self.resource_save_dialog = MDDialog( + title="Сохранение ресурса", type="custom", content_cls=content, buttons=[ MDFlatButton( - text="ОТМЕНА", - on_release=lambda x: self.content_dialog.dismiss() + text="КОПИРОВАТЬ", + on_release=lambda x: self.copy_to_clipboard(json_text) ), MDFlatButton( - text="ЗАГРУЗИТЬ", - on_release=self.start_download + text="СОХРАНИТЬ", + on_release=lambda x: self.save_resource_json(resource_json) + ), + MDFlatButton( + text="ЗАКРЫТЬ", + on_release=lambda x: self.resource_save_dialog.dismiss() ), ], ) - self.content_dialog.open() + self.resource_save_dialog.open() - def toggle_file_selection(self, file, value): - """Toggle file selection""" - file['selected'] = value + def copy_to_clipboard(self, text): + """Copy text to clipboard""" + from kivy.core.clipboard import Clipboard + from kivymd.toast import toast + + Clipboard.copy(text) + toast("JSON скопирован в буфер обмена") + + def save_resource_json(self, resource_json): + """Save resource JSON to file""" + import json + from kivymd.toast import toast + + save_path = self.json_save_path.text + try: + with open(save_path, 'w') as f: + json.dump(resource_json, f, indent=4) + toast(f"JSON сохранен в {save_path}") + self.resource_save_dialog.dismiss() + except Exception as e: + toast(f"Ошибка при сохранении JSON: {str(e)}") - def start_download(self, *args): - """Start downloading selected files""" - # Закрываем диалог - self.content_dialog.dismiss() - - # Получаем информацию о торренте из менеджера - url = getattr(self, 'current_torrent_url', '') - if url: - file_info = torrent_manager.get_file_info(url) - # Обновляем список файлов + def start_download_with_resource(self, resource_json): + """Start downloading file with resource JSON""" + from kivymd.toast import toast + + # Get the save path + save_path = self.save_path_field.text + + try: + # Start downloading the file + file_info = torrent_manager.start_download_file(save_path, resource_json) + + # Close the dialog + self.save_path_dialog.dismiss() + + # Update the file list self.files = torrent_manager.get_files() self.update_file_list() + + toast(f"Начата загрузка файла {resource_json.get('name', 'unknown')}") + except Exception as e: + toast(f"Ошибка при загрузке файла: {str(e)}") + self.save_path_dialog.dismiss() def remove_torrent(self, index=None): - """Remove a torrent""" + """Remove a torrent and save changes to torrent_state.json""" if index is not None and 0 <= index < len(self.files): # Получаем имя файла для удаления file_name = self.files[index]['name'] @@ -374,6 +662,8 @@ def remove_torrent(self, index=None): self.files = torrent_manager.get_files() # Обновляем отображение self.update_file_list() + # Сохраняем изменения в torrent_state.json + torrent_manager.shutdown() class TorrentInnoApp(MDApp): def build(self): diff --git a/client/gui/data/file_icons/png.png b/client/gui/data/file_icons/png.png new file mode 100644 index 0000000..ca7000e --- /dev/null +++ b/client/gui/data/file_icons/png.png @@ -0,0 +1,11 @@ + + + + + + png + + + + UNKNOWN + \ No newline at end of file diff --git a/client/gui/torrent_manager.py b/client/gui/torrent_manager.py index 5c4c802..4507568 100644 --- a/client/gui/torrent_manager.py +++ b/client/gui/torrent_manager.py @@ -1,48 +1,36 @@ -import random +import asyncio import json import os +import threading +import time +import logging +from pathlib import Path + +# Импортируем реальный функционал из torrentInno +import sys +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from torrentInno import TorrentInno, create_resource_json, create_resource_from_json +from core.common.resource import Resource # Путь к файлу для сохранения состояния торрентов TORRENT_STATE_FILE = 'torrent_state.json' -# Тестовые данные для торрент-файлов -_MOCK_FILES = [ - { - 'name': 'example.txt', - 'size': '1.23 kb', - 'type': 'txt', - 'download_speed': '1kb/s', - 'upload_speed': '0kb/s', - 'blocks': [0] * 20 # 0 означает не загружено, 1 означает загружено - }, - { - 'name': 'music.mp3', - 'size': '2.03 mb', - 'type': 'mp3', - 'download_speed': '2mb/s', - 'upload_speed': '1mb/s', - 'blocks': [0] * 20 - }, - { - 'name': 'video.mp4', - 'size': '12.7 mb', - 'type': 'mp4', - 'download_speed': '1mb/s', - 'upload_speed': '1mb/s', - 'blocks': [0] * 20 - }, - { - 'name': 'unknown', - 'size': '1.097 Gb', - 'type': 'unknown', - 'download_speed': '3mb/s', - 'upload_speed': '2 mb/s', - 'blocks': [0] * 20 - } -] +# Настройка логирования +logging.basicConfig(level=logging.INFO) -# Список активных торрентов +# Глобальные переменные _active_torrents = [] +_torrent_inno = None +_loop = None +_background_thread = None + +# Словарь для хранения путей к файлам +_file_paths = {} + +def _run_event_loop(loop): + """Запускает цикл событий asyncio в отдельном потоке""" + asyncio.set_event_loop(loop) + loop.run_forever() def _load_torrent_state(): """Загружает состояние торрентов из файла""" @@ -64,19 +52,35 @@ def _save_torrent_state(): def initialize(): """Инициализирует менеджер торрентов, загружая сохраненное состояние""" - global _active_torrents + global _active_torrents, _torrent_inno, _loop, _background_thread + + # Создаем экземпляр TorrentInno + _torrent_inno = TorrentInno() + + # Создаем и запускаем цикл событий в отдельном потоке + _loop = asyncio.new_event_loop() + _background_thread = threading.Thread(target=_run_event_loop, args=(_loop,)) + _background_thread.daemon = True # Поток завершится при завершении основного потока + _background_thread.start() + + # Загружаем сохраненное состояние saved_state = _load_torrent_state() if saved_state: _active_torrents = saved_state else: - # Если нет сохраненного состояния, используем тестовые данные _active_torrents = [] - for file in _MOCK_FILES: - _active_torrents.append(file.copy()) def shutdown(): """Завершает работу менеджера торрентов, сохраняя текущее состояние""" _save_torrent_state() + + # Останавливаем цикл событий + if _loop and _loop.is_running(): + _loop.call_soon_threadsafe(_loop.stop) + + # Ждем завершения потока + if _background_thread and _background_thread.is_alive(): + _background_thread.join(timeout=1.0) def get_files(): """Возвращает список всех торрент-файлов @@ -86,77 +90,95 @@ def get_files(): """ return [file.copy() for file in _active_torrents] -def update_file(file_name): - """Обновляет информацию о конкретном торрент-файле +def _convert_state_to_file_info(state, file_path): + """Конвертирует состояние TorrentInno.State в формат файла для GUI Args: - file_name (str): Имя файла для обновления + state (TorrentInno.State): Состояние файла + file_path (str): Путь к файлу Returns: - dict: Словарь с обновленной информацией о скорости и блоках, - или None если файл не найден + dict: Словарь с информацией о файле для GUI """ - for file in _active_torrents: - if file['name'] == file_name: - # Обновляем скорость загрузки и выгрузки - _update_file_speeds(file) - # Обновляем блоки загрузки - _update_file_blocks(file) - - # Возвращаем только нужные поля - return { - 'download_speed': file['download_speed'], - 'upload_speed': file['upload_speed'], - 'blocks': file['blocks'].copy() - } - return None - -def _update_file_speeds(file): - """Обновляет скорость загрузки и выгрузки для файла + # Получаем имя файла из пути + file_name = os.path.basename(file_path) - Args: - file (dict): Словарь с информацией о файле - """ - # Извлекаем текущие значения скорости - download_value = float(file['download_speed'].split('mb/s')[0].split('kb/s')[0].strip()) - upload_value = float(file['upload_speed'].split('mb/s')[0].split('kb/s')[0].strip()) + # Определяем тип файла по расширению + file_ext = file_name.split('.')[-1] if '.' in file_name else 'unknown' - # Случайно изменяем скорость - download_value += random.uniform(-0.5, 0.5) - upload_value += random.uniform(-0.3, 0.3) + # Вычисляем общий размер файла + total_size = sum(piece.size for piece in state.piece_status) - # Убеждаемся, что скорость не опускается ниже 0.1 - download_value = max(0.1, download_value) - upload_value = max(0.1, upload_value) + # Форматируем размер файла + if total_size < 1024: + size_str = f"{total_size} B" + elif total_size < 1024 * 1024: + size_str = f"{total_size / 1024:.2f} KB" + elif total_size < 1024 * 1024 * 1024: + size_str = f"{total_size / (1024 * 1024):.2f} MB" + else: + size_str = f"{total_size / (1024 * 1024 * 1024):.2f} GB" + + # Конвертируем скорость в удобный формат + download_speed = state.download_speed_bytes_per_sec + upload_speed = state.upload_speed_bytes_per_sec - # Обновляем значения скорости - if 'kb/s' in file['download_speed']: - file['download_speed'] = f"{download_value:.1f}kb/s" + if download_speed < 1024: + download_speed_str = f"{download_speed}B/s" + elif download_speed < 1024 * 1024: + download_speed_str = f"{download_speed / 1024:.1f}KB/s" else: - file['download_speed'] = f"{download_value:.1f}mb/s" - - if 'kb/s' in file['upload_speed']: - file['upload_speed'] = f"{upload_value:.1f}kb/s" + download_speed_str = f"{download_speed / (1024 * 1024):.1f}MB/s" + + if upload_speed < 1024: + upload_speed_str = f"{upload_speed}B/s" + elif upload_speed < 1024 * 1024: + upload_speed_str = f"{upload_speed / 1024:.1f}KB/s" else: - file['upload_speed'] = f"{upload_value:.1f}mb/s" + upload_speed_str = f"{upload_speed / (1024 * 1024):.1f}MB/s" + + # Создаем блоки для отображения прогресса + blocks = [1 if piece else 0 for piece in state.piece_status] + + # Если блоков слишком много, уменьшаем их количество до 20 + if len(blocks) > 20: + # Группируем блоки + group_size = len(blocks) // 20 + grouped_blocks = [] + for i in range(0, len(blocks), group_size): + group = blocks[i:i+group_size] + # Если хотя бы половина блоков в группе загружена, считаем группу загруженной + grouped_blocks.append(1 if sum(group) >= len(group) / 2 else 0) + blocks = grouped_blocks[:20] # Берем только первые 20 групп + + # Если блоков меньше 20, дополняем до 20 + while len(blocks) < 20: + blocks.append(0) + + return { + 'name': file_name, + 'size': size_str, + 'type': file_ext, + 'download_speed': download_speed_str, + 'upload_speed': upload_speed_str, + 'blocks': blocks + } -def _update_file_blocks(file): - """Обновляет блоки загрузки для файла +async def _get_all_states(): + """Получает состояние всех файлов - Args: - file (dict): Словарь с информацией о файле + Returns: + list: Список состояний файлов """ - # Случайно выбираем блок для отметки как загруженный - if 0 in file['blocks']: # Если есть еще блоки для загрузки - zero_indices = [i for i, x in enumerate(file['blocks']) if x == 0] - if zero_indices: # Если есть блоки, которые еще не загружены - # Случайно выбираем 1-3 блока для отметки как загруженные - num_blocks = min(random.randint(1, 3), len(zero_indices)) - for _ in range(num_blocks): - if zero_indices: # Проверяем снова, на случай если мы использовали все индексы - idx = random.choice(zero_indices) - file['blocks'][idx] = 1 - zero_indices.remove(idx) + if not _torrent_inno: + return [] + + try: + states = await _torrent_inno.get_all_files_state() + return states + except Exception as e: + logging.error(f"Ошибка при получении состояния файлов: {e}") + return [] def update_files(): """Обновляет информацию о всех торрент-файлах @@ -164,91 +186,192 @@ def update_files(): Returns: list: Список обновленных торрент-файлов """ - for file in _active_torrents: - _update_file_speeds(file) - _update_file_blocks(file) + global _active_torrents + + try: + # Получаем состояние всех файлов + states_future = asyncio.run_coroutine_threadsafe(_get_all_states(), _loop) + states = states_future.result(timeout=5.0) # Ждем результат не более 5 секунд + + # Обновляем информацию о файлах + updated_files = [] + for file_path, state in states: + file_info = _convert_state_to_file_info(state, file_path) + updated_files.append(file_info) + # Сохраняем путь к файлу + _file_paths[file_info['name']] = file_path + + _active_torrents = updated_files + _save_torrent_state() + + except Exception as e: + logging.error(f"Ошибка при обновлении файлов: {e}") return get_files() -def get_file_info(url): - """Получает информацию о торрент-файле по URL +def create_resource_from_file(file_path, comment="", name=None): + """Создает ресурс JSON из файла Args: - url (str): URL торрент-файла или magnet-ссылка + file_path (str): Путь к файлу + comment (str): Комментарий к файлу + name (str): Имя файла (если не указано, используется имя исходного файла) Returns: - dict: Словарь с информацией о торрент-файле + dict: JSON с метаданными торрента """ - # В реальном приложении здесь был бы код для получения информации о торренте - # Сейчас просто возвращаем тестовые данные + # Если имя не указано, используем имя исходного файла + if name is None: + name = os.path.basename(file_path) - # Генерируем случайное имя файла на основе URL - import hashlib - name_hash = hashlib.md5(url.encode()).hexdigest()[:8] + # Используем реальную функцию из torrentInno + return create_resource_json( + name=name, + comment=comment, + file_path=Path(file_path), + min_piece_size=1000 * 1000, # 1MB + max_pieces=10000 + ) + +def start_sharing_file(file_path, resource_json): + """Начинает раздачу файла - # Создаем новый файл с тестовыми данными + Args: + file_path (str): Путь к файлу + resource_json (dict): JSON с метаданными торрента + + Returns: + dict: Информация о добавленном торренте + """ + # Создаем ресурс из JSON + resource = create_resource_from_json(resource_json) + + # Запускаем раздачу файла + asyncio.run_coroutine_threadsafe( + _torrent_inno.start_share_file(Path(file_path).resolve(), resource), + _loop + ) + + # Создаем информацию о торренте для GUI + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + file_ext = file_name.split('.')[-1] if '.' in file_name else 'unknown' + + # Форматируем размер файла + if file_size < 1024: + size_str = f"{file_size} B" + elif file_size < 1024 * 1024: + size_str = f"{file_size / 1024:.2f} KB" + elif file_size < 1024 * 1024 * 1024: + size_str = f"{file_size / (1024 * 1024):.2f} MB" + else: + size_str = f"{file_size / (1024 * 1024 * 1024):.2f} GB" + + # Создаем новый торрент с полностью загруженными блоками new_file = { - 'name': f'torrent_{name_hash}', - 'size': '1.097 Gb', - 'type': 'unknown', - 'download_speed': '0mb/s', - 'upload_speed': '0mb/s', - 'blocks': [0] * 20 + 'name': file_name, + 'size': size_str, + 'type': file_ext, + 'download_speed': '0MB/s', + 'upload_speed': '0.5MB/s', # Начальная скорость раздачи + 'blocks': [1] * 20 # Все блоки загружены } # Добавляем файл в список активных торрентов _active_torrents.append(new_file) + _file_paths[file_name] = file_path + _save_torrent_state() return new_file.copy() -def add_torrent(file_info): - """Добавляет новый торрент в список активных +def start_download_file(destination_path, resource_json): + """Начинает загрузку файла Args: - file_info (dict): Информация о торрент-файле + destination_path (str): Путь для сохранения файла + resource_json (dict): JSON с метаданными торрента Returns: - bool: True если торрент успешно добавлен, иначе False + dict: Информация о добавленном торренте """ - if not file_info or 'name' not in file_info: - return False + # Создаем ресурс из JSON + resource = create_resource_from_json(resource_json) + + # Запускаем загрузку файла + asyncio.run_coroutine_threadsafe( + _torrent_inno.start_download_file(Path(destination_path).resolve(), resource), + _loop + ) + + # Получаем имя файла из пути назначения + file_name = os.path.basename(destination_path) + + # Определяем тип файла по расширению + file_ext = file_name.split('.')[-1] if '.' in file_name else 'unknown' + + # Вычисляем общий размер файла из ресурса + total_size = sum(piece['size'] for piece in resource_json['pieces']) + + # Форматируем размер файла + if total_size < 1024: + size_str = f"{total_size} B" + elif total_size < 1024 * 1024: + size_str = f"{total_size / 1024:.2f} KB" + elif total_size < 1024 * 1024 * 1024: + size_str = f"{total_size / (1024 * 1024):.2f} MB" + else: + size_str = f"{total_size / (1024 * 1024 * 1024):.2f} GB" - # Проверяем, не существует ли уже торрент с таким именем - for file in _active_torrents: - if file['name'] == file_info['name']: - return False + # Создаем новый торрент с незагруженными блоками + new_file = { + 'name': file_name, + 'size': size_str, + 'type': file_ext, + 'download_speed': '1MB/s', # Начальная скорость загрузки + 'upload_speed': '0MB/s', + 'blocks': [0] * 20 # Все блоки не загружены + } - # Добавляем новый торрент - _active_torrents.append(file_info.copy()) - return True + # Добавляем файл в список активных торрентов + _active_torrents.append(new_file) + _file_paths[file_name] = destination_path + _save_torrent_state() + + return new_file.copy() -def remove_torrent(file_name): - """Удаляет торрент из списка активных +def remove_torrent(index): + """Удаляет торрент из списка активных по индексу Args: - file_name (str): Имя файла для удаления + index (int): Индекс файла в списке Returns: bool: True если торрент успешно удален, иначе False """ - for i, file in enumerate(_active_torrents): - if file['name'] == file_name: - del _active_torrents[i] + try: + if 0 <= index < len(_active_torrents): + file_name = _active_torrents[index]['name'] + file_path = _file_paths.get(file_name) + + # Если есть путь к файлу, останавливаем раздачу/загрузку + if file_path and _torrent_inno: + try: + # Пытаемся остановить раздачу файла + asyncio.run_coroutine_threadsafe( + _torrent_inno.stop_share_file(Path(file_path).resolve()), + _loop + ).result(timeout=5.0) + except Exception as e: + logging.error(f"Ошибка при остановке раздачи файла: {e}") + + # Удаляем файл из списка активных торрентов + del _active_torrents[index] + if file_name in _file_paths: + del _file_paths[file_name] + + _save_torrent_state() return True - return False - -def get_mock_content(source): - """Возвращает тестовый список файлов в торренте + except Exception as e: + logging.error(f"Ошибка при удалении торрента: {e}") - Args: - source (str): URL или путь к торрент-файлу - - Returns: - list: Список словарей с информацией о файлах в торренте - """ - # Тестовые данные для содержимого торрента - return [ - {"name": "file1.mp4", "size": "1.2 GB", "selected": True}, - {"name": "file2.txt", "size": "15 KB", "selected": True}, - {"name": "file3.mp3", "size": "5.7 MB", "selected": True} - ] \ No newline at end of file + return False \ No newline at end of file From d8b824af3078a8931214a331f2a25a528e159d72 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Wed, 30 Apr 2025 20:53:13 +0300 Subject: [PATCH 42/46] Fix converting function --- client/gui/torrent_manager.py | 31 +++++++++++++++++-------------- client/torrentInno.py | 8 ++++---- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/client/gui/torrent_manager.py b/client/gui/torrent_manager.py index 4507568..8c7629f 100644 --- a/client/gui/torrent_manager.py +++ b/client/gui/torrent_manager.py @@ -16,11 +16,11 @@ TORRENT_STATE_FILE = 'torrent_state.json' # Настройка логирования -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.DEBUG) # Глобальные переменные _active_torrents = [] -_torrent_inno = None +_torrent_inno: TorrentInno | None = None _loop = None _background_thread = None @@ -105,10 +105,11 @@ def _convert_state_to_file_info(state, file_path): # Определяем тип файла по расширению file_ext = file_name.split('.')[-1] if '.' in file_name else 'unknown' - + # Вычисляем общий размер файла - total_size = sum(piece.size for piece in state.piece_status) - + resource: Resource = _torrent_inno.resource_manager_dict[str(Path(file_path).resolve())].resource + total_size: int = sum(piece.size_bytes for piece in resource.pieces) + # Форматируем размер файла if total_size < 1024: size_str = f"{total_size} B" @@ -118,7 +119,7 @@ def _convert_state_to_file_info(state, file_path): size_str = f"{total_size / (1024 * 1024):.2f} MB" else: size_str = f"{total_size / (1024 * 1024 * 1024):.2f} GB" - + # Конвертируем скорость в удобный формат download_speed = state.download_speed_bytes_per_sec upload_speed = state.upload_speed_bytes_per_sec @@ -136,7 +137,7 @@ def _convert_state_to_file_info(state, file_path): upload_speed_str = f"{upload_speed / 1024:.1f}KB/s" else: upload_speed_str = f"{upload_speed / (1024 * 1024):.1f}MB/s" - + # Создаем блоки для отображения прогресса blocks = [1 if piece else 0 for piece in state.piece_status] @@ -154,7 +155,7 @@ def _convert_state_to_file_info(state, file_path): # Если блоков меньше 20, дополняем до 20 while len(blocks) < 20: blocks.append(0) - + return { 'name': file_name, 'size': size_str, @@ -189,10 +190,11 @@ def update_files(): global _active_torrents try: + # Получаем состояние всех файлов states_future = asyncio.run_coroutine_threadsafe(_get_all_states(), _loop) states = states_future.result(timeout=5.0) # Ждем результат не более 5 секунд - + # Обновляем информацию о файлах updated_files = [] for file_path, state in states: @@ -200,7 +202,7 @@ def update_files(): updated_files.append(file_info) # Сохраняем путь к файлу _file_paths[file_info['name']] = file_path - + _active_torrents = updated_files _save_torrent_state() @@ -233,7 +235,7 @@ def create_resource_from_file(file_path, comment="", name=None): max_pieces=10000 ) -def start_sharing_file(file_path, resource_json): +def start_sharing_file(file_path: str, resource_json): """Начинает раздачу файла Args: @@ -248,7 +250,7 @@ def start_sharing_file(file_path, resource_json): # Запускаем раздачу файла asyncio.run_coroutine_threadsafe( - _torrent_inno.start_share_file(Path(file_path).resolve(), resource), + _torrent_inno.start_share_file(str(Path(file_path).resolve()), resource), _loop ) @@ -256,6 +258,7 @@ def start_sharing_file(file_path, resource_json): file_name = os.path.basename(file_path) file_size = os.path.getsize(file_path) file_ext = file_name.split('.')[-1] if '.' in file_name else 'unknown' + file_ext = file_ext if file_ext in {'mp3', 'mp4', 'png', 'txt'} else 'unknown' # Форматируем размер файла if file_size < 1024: @@ -299,7 +302,7 @@ def start_download_file(destination_path, resource_json): # Запускаем загрузку файла asyncio.run_coroutine_threadsafe( - _torrent_inno.start_download_file(Path(destination_path).resolve(), resource), + _torrent_inno.start_download_file(str(Path(destination_path).resolve()), resource), _loop ) @@ -358,7 +361,7 @@ def remove_torrent(index): try: # Пытаемся остановить раздачу файла asyncio.run_coroutine_threadsafe( - _torrent_inno.stop_share_file(Path(file_path).resolve()), + _torrent_inno.stop_share_file(str(Path(file_path).resolve())), _loop ).result(timeout=5.0) except Exception as e: diff --git a/client/torrentInno.py b/client/torrentInno.py index 15ead65..984b1b1 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -111,7 +111,7 @@ def __init__(self): self.peer_id = generate_peer_id() self.resource_manager_dict: Dict[str, ResourceManager] = {} - async def start_share_file(self, destination, resource: Resource): + async def start_share_file(self, destination: str, resource: Resource): ''' Function what starting sharing of file, and updating peer information on tracker server @@ -158,7 +158,7 @@ async def parse_peer_list(json_text): task = asyncio.create_task(heart_beat(tracker_url, peer, parse_peer_list)) - async def stop_share_file(self, destination): + async def stop_share_file(self, destination: str): ''' Function what stopping sharing of file, and updating peer information ''' @@ -167,7 +167,7 @@ async def stop_share_file(self, destination): del self.resource_manager_dict[destination] - async def start_download_file(self, destination, resource: Resource): + async def start_download_file(self, destination: str, resource: Resource): ''' Function what starting downloading of file, and updating peer information ''' @@ -217,7 +217,7 @@ async def parse_peer_list(json_text): await peers_ready.wait() await self.resource_manager_dict.get(destination).start_download() - async def stop_download_file(self, destination): + async def stop_download_file(self, destination: str): ''' Function what stopping downloading of file, and updating peer information on tracker server From d1e7d3debbd2368c425ec4c47fd6ee63a962ce61 Mon Sep 17 00:00:00 2001 From: "s.zaynulin" Date: Wed, 30 Apr 2025 21:07:31 +0300 Subject: [PATCH 43/46] poetry edit --- client/core/p2p/connection_listener.py | 2 +- client/poetry.lock | 340 ++++++++++++++++++++++++- client/pyproject.toml | 4 +- 3 files changed, 343 insertions(+), 3 deletions(-) diff --git a/client/core/p2p/connection_listener.py b/client/core/p2p/connection_listener.py index 65b8d55..584b192 100644 --- a/client/core/p2p/connection_listener.py +++ b/client/core/p2p/connection_listener.py @@ -12,4 +12,4 @@ async def on_bitfield(self, bitfield: Bitfield): pass def on_close(self, cause): - pass + pass \ No newline at end of file diff --git a/client/poetry.lock b/client/poetry.lock index 8adcbe6..53b63ba 100644 --- a/client/poetry.lock +++ b/client/poetry.lock @@ -139,6 +139,30 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "docutils" +version = "0.21.2" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"}, + {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, +] + +[[package]] +name = "filetype" +version = "1.2.0" +description = "Infer file type and MIME type of any file/buffer. No external dependencies." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25"}, + {file = "filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb"}, +] + [[package]] name = "idna" version = "3.10" @@ -166,6 +190,162 @@ files = [ {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "kivy" +version = "2.3.1" +description = "An open-source Python framework for developing GUI apps that work cross-platform, including desktop, mobile and embedded platforms." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "Kivy-2.3.1-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:ace93c166c9400f9435cfd3bd179b5ef9fdd40d69ee8171a6b8beba08c402d09"}, + {file = "Kivy-2.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d6215762510b463b0461d173f8a0b22e449beb12ba79cf151e18aa1d3d12a40"}, + {file = "Kivy-2.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba83dd8266fc2b1247de18c5e8114fa47ea20eb33eb7c3a9e2eb6202b9778088"}, + {file = "Kivy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:d28ad14162554abd0324ae8f66ce2f374c05456d2656d60cfa80814f715d62c0"}, + {file = "Kivy-2.3.1-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:acb58843763075818de919989a73657307f4d833a7cc5547c1b16c226e260e5d"}, + {file = "Kivy-2.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7a1799b19f6ab3bcfcef1e729a0229cee646167a1633e067c2add6978f928bb"}, + {file = "Kivy-2.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f180280df46a8c2f9988159938aa1a3e5a0094060d9586ea79df4b4ead9cad98"}, + {file = "Kivy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:002de19fef53955c48108758beea3092cf281326642d2e71eca1c443f4227cce"}, + {file = "Kivy-2.3.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:3f74679ef305f0ed0d8bb3599a2dddc80ffc81157bdc07947498dd689fc9a5d9"}, + {file = "Kivy-2.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:663e9b2fe5002f53371b3ad3712dccdaaa96905bbeaa83d7c7e64f3c44fec94e"}, + {file = "Kivy-2.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2be79fe1494b6e60cb5aa5f124c37961530417cf27a53171b5a72c9e4c7d41cf"}, + {file = "Kivy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:2046f6608d17b6c1a0530ac9aa127307fa25f6f75764f1d60428a1c0f6c0af88"}, + {file = "Kivy-2.3.1-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:d8d9e57501961c5d45e5a2c5af0caef24e48f43a0cd88f607eb3b517198cfec4"}, + {file = "Kivy-2.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfe25296e9612cbfa2b68cfb0ccd3c80db1441c11261a9e131d5f8fed7618c2c"}, + {file = "Kivy-2.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:950d17e275f817ca34cc7c9d55f9d229067e2f7fbd0fad985a74c94893f7e739"}, + {file = "Kivy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:b5127af11c2fc1299f2331402fe4f6edb0985711c2841fbfdf509830c058c78e"}, + {file = "Kivy-2.3.1-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:d9e92c4894f99685d822ab7d059a3912bbff17d812e64a12ed3cf0acd37924cb"}, + {file = "Kivy-2.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:445b6054afcd08fd271b75e5552a72a5ffb122b05e8511e46bf69e3b5e344d31"}, + {file = "Kivy-2.3.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e473b10e9b9a49a6475760fd1f7d674873852f9561505ff6b4d8e5f1691d4f9"}, + {file = "Kivy-2.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:ee628e5dbe5e397ceeeda7b49cf4c800b79a695c6345fee1a8f1b71d3fc530bb"}, + {file = "Kivy-2.3.1-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:38a265ff95120694ab7dfc29ed2ccdec40a8a47344387b886f498449b0c3c66c"}, + {file = "Kivy-2.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae8168549c822a7122044965715d9f953a1862fdef132ea7725df8c1d2f19e5c"}, + {file = "Kivy-2.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:748163206ce95aab5aaad1ada772a79e422a80b6308623510e74a1b7baf80f0a"}, + {file = "Kivy-2.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:91c836b7c2b4958fb4b3839f63b1724435bd617548baace0602c122d39756746"}, + {file = "Kivy-2.3.1.tar.gz", hash = "sha256:0833949e3502cdb4abcf9c1da4384674045ad7d85644313aa1ee7573f3b4f9d9"}, +] + +[package.dependencies] +docutils = "*" +filetype = "*" +"kivy-deps.angle" = {version = ">=0.4.0,<0.5.0", markers = "sys_platform == \"win32\""} +"kivy-deps.glew" = {version = ">=0.3.1,<0.4.0", markers = "sys_platform == \"win32\""} +"kivy-deps.sdl2" = {version = ">=0.8.0,<0.9.0", markers = "sys_platform == \"win32\""} +Kivy-Garden = ">=0.1.4" +pygments = "*" +pypiwin32 = {version = "*", markers = "sys_platform == \"win32\""} +requests = "*" + +[package.extras] +angle = ["kivy-deps.angle (>=0.4.0,<0.5.0) ; sys_platform == \"win32\""] +base = ["pillow (>=9.5.0,<11)"] +dev = ["flake8", "kivy-deps.glew-dev (>=0.3.1,<0.4.0) ; sys_platform == \"win32\"", "kivy-deps.gstreamer-dev (>=0.3.3,<0.4.0) ; sys_platform == \"win32\"", "kivy-deps.sdl2-dev (>=0.8.0,<0.9.0) ; sys_platform == \"win32\"", "pre-commit", "pyinstaller", "pytest (>=3.6)", "pytest-asyncio (!=0.11.0)", "pytest-benchmark", "pytest-cov", "pytest-timeout", "responses", "sphinx (>=6.2.1,<6.3.0)", "sphinxcontrib-jquery (>=4.1,<5.0)"] +full = ["ffpyplayer ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "kivy-deps.gstreamer (>=0.3.3,<0.4.0) ; sys_platform == \"win32\"", "pillow (>=9.5.0,<11)"] +glew = ["kivy-deps.glew (>=0.3.1,<0.4.0) ; sys_platform == \"win32\""] +gstreamer = ["kivy-deps.gstreamer (>=0.3.3,<0.4.0) ; sys_platform == \"win32\""] +media = ["ffpyplayer ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "kivy-deps.gstreamer (>=0.3.3,<0.4.0) ; sys_platform == \"win32\""] +sdl2 = ["kivy-deps.sdl2 (>=0.8.0,<0.9.0) ; sys_platform == \"win32\""] +tuio = ["oscpy"] + +[[package]] +name = "kivy-deps-angle" +version = "0.4.0" +description = "Repackaged binary dependency of Kivy." +optional = false +python-versions = "*" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "kivy_deps.angle-0.4.0-cp310-cp310-win32.whl", hash = "sha256:7873a551e488afa5044c4949a4aa42c4a4c4290469f0a6dd861e6b95283c9638"}, + {file = "kivy_deps.angle-0.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:71f2f01a3a7bbe1d4790e2a64e64a0ea8ae154418462ea407799ed66898b2c1f"}, + {file = "kivy_deps.angle-0.4.0-cp311-cp311-win32.whl", hash = "sha256:c3899ff1f3886b80b155955bad07bfa33bbebd97718cdf46dfd788dc467124bc"}, + {file = "kivy_deps.angle-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:574381d4e66f3198bc48aa10f238e7a3816ad56b80ec939f5d56fb33a378d0b1"}, + {file = "kivy_deps.angle-0.4.0-cp312-cp312-win32.whl", hash = "sha256:4fa7a6366899fba13f7624baf4645787165f45731db08d14557da29c12ee48f0"}, + {file = "kivy_deps.angle-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:668e670d4afd2551af0af2c627ceb0feac884bd799fb6a3dff78fdbfa2ea0451"}, + {file = "kivy_deps.angle-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:9afbf702f8bb9a993c48f39c018ca3b4d2ec381a5d3f82fe65bdaa6af0bba29b"}, + {file = "kivy_deps.angle-0.4.0-cp37-cp37m-win32.whl", hash = "sha256:24cfc0076d558080a00c443c7117311b4a977c1916fe297232eff1fd6f62651e"}, + {file = "kivy_deps.angle-0.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:48592ac6f7c183c5cd10d9ebe43d4148d0b2b9e400a2b0bcb5d21014cc929ce2"}, + {file = "kivy_deps.angle-0.4.0-cp38-cp38-win32.whl", hash = "sha256:1bbacf20bf6bd6ee965388f95d937c8fba2c54916fb44faa166c2ba58276753c"}, + {file = "kivy_deps.angle-0.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:e2ba4e390b02ad5bcb57b43a9227fa27ff55e69cd715a87217b324195eb267c3"}, + {file = "kivy_deps.angle-0.4.0-cp39-cp39-win32.whl", hash = "sha256:6546a62aba2b7e18a800b3df79daa757af3a980c297646c986896522395794e2"}, + {file = "kivy_deps.angle-0.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:bfaf9b37f2ecc3e4e7736657eed507716477af35cdd3118903e999d9d567ae8c"}, +] + +[[package]] +name = "kivy-deps-glew" +version = "0.3.1" +description = "Repackaged binary dependency of Kivy." +optional = false +python-versions = "*" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "kivy_deps.glew-0.3.1-cp310-cp310-win32.whl", hash = "sha256:8f4b3ed15acb62474909b6d41661ffb4da9eb502bb5684301fb2da668f288a58"}, + {file = "kivy_deps.glew-0.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef2d2a93f129d8425c75234e7f6cc0a34b59a4aee67f6d2cd7a5fdfa9915b53"}, + {file = "kivy_deps.glew-0.3.1-cp311-cp311-win32.whl", hash = "sha256:ee2f80ef7ac70f4b61c50da8101b024308a8c59a57f7f25a6e09762b6c48f942"}, + {file = "kivy_deps.glew-0.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:22e155ec59ce717387f5d8804811206d200a023ba3d0bc9bbf1393ee28d0053e"}, + {file = "kivy_deps.glew-0.3.1-cp312-cp312-win32.whl", hash = "sha256:b64ee4e445a04bc7c848c0261a6045fc2f0944cc05d7f953e3860b49f2703424"}, + {file = "kivy_deps.glew-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:3acbbd30da05fc10c185b5d4bb75fbbc882a6ef2192963050c1c94d60a6e795a"}, + {file = "kivy_deps.glew-0.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:f4aa8322078359862ccd9e16e5cea61976d75fb43125d87922e20c916fa31a11"}, + {file = "kivy_deps.glew-0.3.1-cp37-cp37m-win32.whl", hash = "sha256:5bf6a63fe9cc4fe7bbf280ec267ec8c47914020a1175fb22152525ff1837b436"}, + {file = "kivy_deps.glew-0.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:d64a8625799fab7a7efeb3661ef8779a7f9c6d80da53eed87a956320f55530fa"}, + {file = "kivy_deps.glew-0.3.1-cp38-cp38-win32.whl", hash = "sha256:00f4ae0a4682d951266458ddb639451edb24baa54a35215dce889209daf19a06"}, + {file = "kivy_deps.glew-0.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f8b89dcf1846032d7a9c5ef88b0ee9cbd13366e9b4c85ada61e01549a910677"}, + {file = "kivy_deps.glew-0.3.1-cp39-cp39-win32.whl", hash = "sha256:4e377ed97670dfda619a1b63a82345a8589be90e7c616a458fba2810708810b1"}, + {file = "kivy_deps.glew-0.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:081a09b92f7e7817f489f8b6b31c9c9623661378de1dce1d6b097af5e7d42b45"}, +] + +[[package]] +name = "kivy-deps-sdl2" +version = "0.8.0" +description = "Repackaged binary dependency of Kivy." +optional = false +python-versions = "*" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "kivy_deps.sdl2-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5af0a3b318a6ec9e0f0c1d476a4af4b2d0cbcce4dbfd89bc4681c33bcd6b3bcd"}, + {file = "kivy_deps.sdl2-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ae3735480841ec9a57c0fb26e8647adee474a3d746147e3d75a1fc177c0fbc01"}, + {file = "kivy_deps.sdl2-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:bfe0cfca77883dde7e297b3b6039fa9cd7ee8df6b0d12516b38addb0551a574c"}, + {file = "kivy_deps.sdl2-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:56b1c44565b5e8cfc510585db13396edfc605965254f49ed8931189c546d481f"}, + {file = "kivy_deps.sdl2-0.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:5e9f8c0c1e76eb43f0bad8f36c5b92a46fb5696f733ec441db45e6864b1d4065"}, + {file = "kivy_deps.sdl2-0.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:dbaa6718e66e8cd4967c2d4021e05114c558342e2468a86c0bce917bea10003f"}, +] + +[[package]] +name = "kivy-garden" +version = "0.1.5" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "Kivy Garden-0.1.5.tar.gz", hash = "sha256:2b8377378e87501d5d271f33d94f0e44c089884572c64f89c9d609b1f86a2748"}, + {file = "Kivy_Garden-0.1.5-py3-none-any.whl", hash = "sha256:ef50f44b96358cf10ac5665f27a4751bb34ef54051c54b93af891f80afe42929"}, +] + +[package.dependencies] +requests = "*" + +[[package]] +name = "kivymd" +version = "1.2.0" +description = "Set of widgets for Kivy inspired by Google's Material Design" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "kivymd-1.2.0.tar.gz", hash = "sha256:2d33e2c59259998e93aee55acde647a4a20e5a0f962469db24ee4c9ec586962e"}, +] + +[package.dependencies] +kivy = ">=2.2.0" +pillow = "*" + +[package.extras] +dev = ["black", "coveralls", "flake8", "isort[pyproject]", "pre-commit", "pyinstaller[hook-testing]", "pytest", "pytest-cov", "pytest-timeout", "pytest_asyncio"] +docs = ["furo", "sphinx", "sphinx-autoapi (==1.4.0)", "sphinx-copybutton", "sphinx-notfound-page", "sphinx-tabs"] + [[package]] name = "packaging" version = "25.0" @@ -178,6 +358,106 @@ files = [ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] +[[package]] +name = "pillow" +version = "11.2.1" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"}, + {file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"}, + {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"}, + {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"}, + {file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"}, + {file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"}, + {file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"}, + {file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"}, + {file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"}, + {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"}, + {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"}, + {file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"}, + {file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"}, + {file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"}, + {file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"}, + {file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"}, + {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"}, + {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"}, + {file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"}, + {file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"}, + {file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"}, + {file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"}, + {file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"}, + {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"}, + {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"}, + {file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"}, + {file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"}, + {file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"}, + {file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"}, + {file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"}, + {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"}, + {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"}, + {file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"}, + {file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"}, + {file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"}, + {file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"}, + {file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"}, + {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"}, + {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"}, + {file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"}, + {file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"}, + {file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"}, + {file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + [[package]] name = "pluggy" version = "1.5.0" @@ -194,6 +474,37 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "pygments" +version = "2.19.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pypiwin32" +version = "223" +description = "" +optional = false +python-versions = "*" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pypiwin32-223-py3-none-any.whl", hash = "sha256:67adf399debc1d5d14dffc1ab5acacb800da569754fafdc576b2a039485aa775"}, + {file = "pypiwin32-223.tar.gz", hash = "sha256:71be40c1fbd28594214ecaecb58e7aa8b708eabfa0125c8a109ebd51edbd776a"}, +] + +[package.dependencies] +pywin32 = ">=223" + [[package]] name = "pytest" version = "8.3.5" @@ -234,6 +545,33 @@ pytest = ">=8.2,<9" docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] +[[package]] +name = "pywin32" +version = "310" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1"}, + {file = "pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d"}, + {file = "pywin32-310-cp310-cp310-win_arm64.whl", hash = "sha256:33babed0cf0c92a6f94cc6cc13546ab24ee13e3e800e61ed87609ab91e4c8213"}, + {file = "pywin32-310-cp311-cp311-win32.whl", hash = "sha256:1e765f9564e83011a63321bb9d27ec456a0ed90d3732c4b2e312b855365ed8bd"}, + {file = "pywin32-310-cp311-cp311-win_amd64.whl", hash = "sha256:126298077a9d7c95c53823934f000599f66ec9296b09167810eb24875f32689c"}, + {file = "pywin32-310-cp311-cp311-win_arm64.whl", hash = "sha256:19ec5fc9b1d51c4350be7bb00760ffce46e6c95eaf2f0b2f1150657b1a43c582"}, + {file = "pywin32-310-cp312-cp312-win32.whl", hash = "sha256:8a75a5cc3893e83a108c05d82198880704c44bbaee4d06e442e471d3c9ea4f3d"}, + {file = "pywin32-310-cp312-cp312-win_amd64.whl", hash = "sha256:bf5c397c9a9a19a6f62f3fb821fbf36cac08f03770056711f765ec1503972060"}, + {file = "pywin32-310-cp312-cp312-win_arm64.whl", hash = "sha256:2349cc906eae872d0663d4d6290d13b90621eaf78964bb1578632ff20e152966"}, + {file = "pywin32-310-cp313-cp313-win32.whl", hash = "sha256:5d241a659c496ada3253cd01cfaa779b048e90ce4b2b38cd44168ad555ce74ab"}, + {file = "pywin32-310-cp313-cp313-win_amd64.whl", hash = "sha256:667827eb3a90208ddbdcc9e860c81bde63a135710e21e4cb3348968e4bd5249e"}, + {file = "pywin32-310-cp313-cp313-win_arm64.whl", hash = "sha256:e308f831de771482b7cf692a1f308f8fca701b2d8f9dde6cc440c7da17e47b33"}, + {file = "pywin32-310-cp38-cp38-win32.whl", hash = "sha256:0867beb8addefa2e3979d4084352e4ac6e991ca45373390775f7084cc0209b9c"}, + {file = "pywin32-310-cp38-cp38-win_amd64.whl", hash = "sha256:30f0a9b3138fb5e07eb4973b7077e1883f558e40c578c6925acc7a94c34eaa36"}, + {file = "pywin32-310-cp39-cp39-win32.whl", hash = "sha256:851c8d927af0d879221e616ae1f66145253537bbdd321a77e8ef701b443a9a1a"}, + {file = "pywin32-310-cp39-cp39-win_amd64.whl", hash = "sha256:96867217335559ac619f00ad70e513c0fcf84b8a3af9fc2bba3b59b97da70475"}, +] + [[package]] name = "requests" version = "2.32.3" @@ -277,4 +615,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" python-versions = ">=3.12" -content-hash = "345812af8ad798aa93c3b1bb2e01c140564a3636dbaa6de5a69ad36e5ddc1ced" +content-hash = "2cd18e94b7002ea62ce6d0dcc4f5ced633f544dd216f76a6ff23f4de6dfb5927" diff --git a/client/pyproject.toml b/client/pyproject.toml index 1c0c4b4..5e724cf 100644 --- a/client/pyproject.toml +++ b/client/pyproject.toml @@ -6,7 +6,9 @@ requires-python = ">=3.12" dependencies = [ "pytest-asyncio (>=0.26.0,<0.27.0)", "aiofiles (>=24.1.0,<25.0.0)", - "requests (>=2.32.3,<3.0.0)" + "requests (>=2.32.3,<3.0.0)", + "kivy (>=2.3.1,<3.0.0)", + "kivymd (>=1.2.0,<2.0.0)" ] [tool.poetry.scripts] From 8b1e51f1ef4a4a044fed719999a6141a21ca2ca8 Mon Sep 17 00:00:00 2001 From: Ge-os Date: Wed, 30 Apr 2025 21:41:56 +0300 Subject: [PATCH 44/46] Fix file manager view --- client/gui/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/gui/app.py b/client/gui/app.py index 1168c9d..d944998 100644 --- a/client/gui/app.py +++ b/client/gui/app.py @@ -246,7 +246,6 @@ def open_upload_file_manager(self, *args): self.file_manager = MDFileManager( exit_manager=self.exit_file_manager, select_path=self.select_file_to_share, - preview=True, ) # Set the starting path to user's home directory @@ -307,7 +306,6 @@ def open_download_dialog(self, *args): self.json_file_manager = MDFileManager( exit_manager=self.exit_json_file_manager, select_path=self.select_json_file, - preview=True, ) # Show the dialog From 3b4bd6d6c9b1bf9b53760a71ce34332d17acc8b6 Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Wed, 30 Apr 2025 22:18:34 +0300 Subject: [PATCH 45/46] Fix download --- client/torrentInno.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/torrentInno.py b/client/torrentInno.py index 984b1b1..37b16a2 100644 --- a/client/torrentInno.py +++ b/client/torrentInno.py @@ -184,7 +184,6 @@ async def start_download_file(self, destination: str, resource: Resource): } tracker_url = 'http://' + TRACKER_IP + f':{TRACKER_PORT}/peers' - peers_ready = asyncio.Event() async def parse_peer_list(json_text): peer_list = [] @@ -208,13 +207,10 @@ async def parse_peer_list(json_text): filtered_peers = [p for p in peer_list if p.peer_id != self.peer_id] await self.resource_manager_dict.get(destination).submit_peers(filtered_peers) - if filtered_peers: - peers_ready.set() logging.info('download peer list:') logging.info(filtered_peers) task = asyncio.create_task(heart_beat(tracker_url, peer, parse_peer_list)) - await peers_ready.wait() await self.resource_manager_dict.get(destination).start_download() async def stop_download_file(self, destination: str): From e3f8b80bee1821dcf456173feb33d90c46f827ea Mon Sep 17 00:00:00 2001 From: Evgenii Bortsov Date: Wed, 30 Apr 2025 22:23:00 +0300 Subject: [PATCH 46/46] Write installation guide --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3869bd --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Torrent Inno +--- +## Installation guide +1) Clone the repository +2) Ensure `poetry` version 2.1.2 is installed on your system +3) Change the directory to `client` +4) In the `client` directory, run `poetry install` + +Now you have two options to use the project +- Enter `poetry run cli` to run the CLI +- Enter `poetry run python3 gui/app.py` to run the GUI application