Skip to content

Commit

Permalink
add bittorrent client
Browse files Browse the repository at this point in the history
  • Loading branch information
st3llaris committed May 31, 2024
1 parent 9e465e7 commit 8d906dc
Show file tree
Hide file tree
Showing 22 changed files with 693 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
AllCops:
NewCops: enable
TargetRubyVersion: 3.0

Style/StringLiterals:
EnforcedStyle: double_quotes

Style/StringLiteralsInInterpolation:
EnforcedStyle: double_quotes
Documentation:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
24 changes: 24 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,32 @@ PATH
specs:
rutorrent (0.1.0)
bencode (~> 0.8.2)
bytesize (~> 0.1.0)
concurrent-ruby (~> 1.2)
pry (~> 0.14.1)
tty-prompt (~> 0.23.1)

GEM
remote: https://rubygems.org/
specs:
ast (2.4.2)
bencode (0.8.2)
bytesize (0.1.2)
coderay (1.1.3)
concurrent-ruby (1.2.3)
diff-lcs (1.5.1)
json (2.7.2)
language_server-protocol (3.17.0.3)
method_source (1.1.0)
parallel (1.24.0)
parser (3.3.1.0)
ast (~> 2.4.1)
racc
pastel (0.8.0)
tty-color (~> 0.5)
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
racc (1.8.0)
rainbow (3.1.1)
rake (13.2.1)
Expand Down Expand Up @@ -50,7 +63,18 @@ GEM
parser (>= 3.3.1.0)
ruby-progressbar (1.13.0)
strscan (3.1.0)
tty-color (0.6.0)
tty-cursor (0.7.1)
tty-prompt (0.23.1)
pastel (~> 0.8)
tty-reader (~> 0.8)
tty-reader (0.9.0)
tty-cursor (~> 0.7)
tty-screen (~> 0.8)
wisper (~> 2.0)
tty-screen (0.8.2)
unicode-display_width (2.5.0)
wisper (2.0.1)

PLATFORMS
ruby
Expand Down
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# Rutorrent

TODO: Delete this and the text below, and describe your gem

Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/rutorrent`. To experiment with that code, run `bin/console` for an interactive prompt.
A Ruby torrent client made using concurrent-ruby for your CLI. Still in development.

## Installation

Expand All @@ -18,7 +16,7 @@ If bundler is not being used to manage dependencies, install the gem by executin

## Usage

TODO: Write usage instructions here
You can simply run `bundle exec rutorrent` and follow the instructions in the console.

## Development

Expand Down
6 changes: 4 additions & 2 deletions bin/rutorrent
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#!/usr/bin/env ruby
require 'rutorrent'
puts Rutorrent::VERSION

require "rutorrent"

Rutorrent::CLI.start
3 changes: 1 addition & 2 deletions lib/rutorrent.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# frozen_string_literal: true

require_relative "rutorrent/version"
Dir["#{__dir__}/rutorrent/**/*.rb"].each { |f| require f }

module Rutorrent
class Error < StandardError; end
# Your code goes here...
end
13 changes: 13 additions & 0 deletions lib/rutorrent/available_piece.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module Rutorrent
class AvailablePiece
attr_accessor :payload

def initialize(payload)
@payload = payload.chars.map!(&:to_i)
end

def available_pieces
payload.each_index.select { |i| payload[i] == 1 }
end
end
end
43 changes: 43 additions & 0 deletions lib/rutorrent/cli.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
require "tty-prompt"
require "bencode"
require "bytesize"

module Rutorrent
class CLI
def self.start
puts Rutorrent::MESSAGES[:no_torrents_available] if Dir["#{Dir.home}/**/*.torrent"].empty? && exit

selected_torrent_path = prompt.select(Rutorrent::MESSAGES[:select_torrent], Dir["#{Dir.home}/**/*.torrent"])
decoded_torrent = BEncode.load_file(selected_torrent_path)
torrent_files = prompt.multi_select(Rutorrent::MESSAGES[:instructions],
format_torrent_files_with_size(decoded_torrent))

torrent_files = format_torrent_files_with_size(decoded_torrent, show_byte_size: false) if torrent_files.empty?

Rutorrent::Downloader.new(decoded_torrent, torrent_files).start
end

def self.format_torrent_files_with_size(torrent, show_byte_size: true)
formatted_files = if torrent["info"]["files"].nil?
[torrent["info"]["name"]]
else
torrent["info"]["files"].map { |f| f["path"].join("/") }
end

if show_byte_size
formatted_files = formatted_files.map do |file|
return "#{file} (#{ByteSize.new(torrent["info"]["length"])})" if torrent["info"]["files"].nil?

index = torrent["info"]["files"].find_index { |f| f["path"].join("/") == file }
"#{file} (#{ByteSize.new(torrent["info"]["files"][index]["length"])})"
end
end

formatted_files
end

def self.prompt
@prompt ||= TTY::Prompt.new
end
end
end
7 changes: 7 additions & 0 deletions lib/rutorrent/cli_messages.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module Rutorrent
MESSAGES = {
select_torrent: "Please select a .torrent file to download",
instructions: "Use ↑/↓ arrow keys to choose a file, press Space to select and Enter to finish (by default, all files will be downloaded):",
no_torrents_available: "No .torrent files found in your home directory. Please download at least one and try again."
}.freeze
end
15 changes: 15 additions & 0 deletions lib/rutorrent/constants.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module Rutorrent
module Constants
PEER_MESSAGES_MAPPING = {
0x0 => "choke",
0x1 => "unchoke",
0x2 => "interested",
0x3 => "not interested",
0x4 => "have",
0x5 => "bitfield",
0x6 => "request",
0x7 => "piece",
0x8 => "cancel"
}.freeze
end
end
20 changes: 20 additions & 0 deletions lib/rutorrent/downloader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
module Rutorrent
class Downloader
def initialize(torrent, torrent_files)
@torrent = torrent
@torrent_files = torrent_files
end

def start
tracker.connect
end

private

def tracker
@tracker ||= Rutorrent::Tracker.new(torrent, torrent_files)
end

attr_reader :torrent, :torrent_files
end
end
46 changes: 46 additions & 0 deletions lib/rutorrent/file_saver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
require "digest/sha1"

class FileSaver
attr_reader :expected_hashes, :pieces, :total_pieces, :piece_length, :file_path, :file

def initialize(file_path, piece_length, total_pieces, expected_hashes)
@file_path = file_path
@file = File.open(file_path, "wb")
@piece_length = piece_length
@total_pieces = total_pieces
@expected_hashes = expected_hashes
@pieces = Array.new(total_pieces) { "" }
end

def save_block(piece_index, begin_offset, block_data)
@pieces[piece_index] ||= ""
@pieces[piece_index][begin_offset, block_data.length] = block_data

@file.seek(piece_index * @piece_length)
@file.write(@pieces[piece_index])
verify_piece(piece_index)
@pieces[piece_index] = nil
end

def save_piece_to_file(piece_index)
@file.seek(piece_index * piece_length)
@file.write(pieces[piece_index])
puts "Verified and saved piece #{piece_index}"
end

def expected_final_piece_size
@total_length - (@piece_length * (@total_pieces - 1))
end

def verify_piece(piece_index)
piece_data = @pieces[piece_index]
piece_hash = Digest::SHA1.digest(piece_data)
expected_hash = @expected_hashes[piece_index]

return save_piece_to_file(piece_index)
end

def close
file.close
end
end
74 changes: 74 additions & 0 deletions lib/rutorrent/http_connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
require "net/http"

module Rutorrent
class HTTPConnection
attr_reader :torrent, :options, :torrent_files

def initialize(torrent, torrent_files, options = {})
@torrent = torrent
@options = options
@torrent_files = torrent_files

create_methods_by_options
end

def connect
uri = build_uri
response = fetch_response(uri)
handle_response(response)
end

private

def request_params
{
info_hash: info_hash,
peer_id: peer_id,
port: port,
uploaded: uploaded,
downloaded: downloaded,
left: left,
compact: compact,
event: event
}
end

def build_uri
URI("#{torrent["announce"]}?#{URI.encode_www_form(request_params)}")
end

def create_methods_by_options
options.each do |method_name, method_value|
define_singleton_method(method_name) { method_value }
end
end

def fetch_response(uri)
Net::HTTP.get_response(uri)
rescue StandardError => e
puts "Something went wrong: #{e}"
exit
end

def handle_response(response)
return if response.nil?

response = BEncode.load(response.body)

peers = response["peers"].scan(/.{6}/)
puts "There are #{peers.size} peers available"

unpacked_peers = unpack_peers(peers)
peer_classes = []

unpacked_peers.map do |ip, port|
handshake = "\x13BitTorrent protocol\x00\x00\x00\x00\x00\x00\x00\x00#{info_hash}#{peer_id}"
peer_classes << Peer.new(ip, port, handshake, info_hash)
end
end

def unpack_peers(peers)
peers.map { |peer| peer.unpack("a4n") }
end
end
end
Loading

0 comments on commit 8d906dc

Please sign in to comment.