139 lines
3.9 KiB
Ruby
139 lines
3.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "moviehash/version"
|
|
require "net/http"
|
|
require "uri"
|
|
|
|
module Moviehash
|
|
class Error < StandardError; end
|
|
|
|
class FileNotFoundError < Error; end
|
|
|
|
class NetworkError < Error; end
|
|
|
|
class InvalidInputError < Error; end
|
|
|
|
DEFAULT_CHUNK_SIZE = 64 * 1024 # in bytes
|
|
DEFAULT_TIMEOUT = 30 # seconds
|
|
HASH_MASK = 0xffffffffffffffff
|
|
|
|
class << self
|
|
attr_writer :chunk_size, :timeout
|
|
|
|
def chunk_size
|
|
@chunk_size || DEFAULT_CHUNK_SIZE
|
|
end
|
|
|
|
def timeout
|
|
@timeout || DEFAULT_TIMEOUT
|
|
end
|
|
|
|
def configure
|
|
yield self if block_given?
|
|
end
|
|
end
|
|
|
|
def self.compute_hash(url)
|
|
validate_input(url)
|
|
data = url.start_with?("http") ? data_from_url(url) : data_from_file(url)
|
|
|
|
hash = data[:filesize]
|
|
|
|
hash = process_chunk(data.dig(:chunks, 0), hash)
|
|
hash = process_chunk(data.dig(:chunks, 1), hash)
|
|
|
|
format("%016x", hash)
|
|
end
|
|
|
|
def self.data_from_file(path)
|
|
raise FileNotFoundError, "File not found: #{path}" unless File.exist?(path)
|
|
raise FileNotFoundError, "Path is a directory: #{path}" if File.directory?(path)
|
|
|
|
begin
|
|
filesize = File.size(path)
|
|
data = {filesize: filesize, chunks: []}
|
|
|
|
File.open(path, "rb") do |f|
|
|
data[:chunks] << f.read(chunk_size)
|
|
f.seek([0, filesize - chunk_size].max, IO::SEEK_SET)
|
|
data[:chunks] << f.read(chunk_size)
|
|
end
|
|
|
|
data
|
|
rescue Errno::EACCES
|
|
raise FileNotFoundError, "Permission denied: #{path}"
|
|
rescue Errno::ENOENT
|
|
raise FileNotFoundError, "File not found: #{path}"
|
|
rescue => e
|
|
raise Error, "Failed to read file #{path}: #{e.message}"
|
|
end
|
|
end
|
|
|
|
def self.data_from_url(url)
|
|
uri = URI(url)
|
|
raise NetworkError, "Invalid URL scheme: #{uri.scheme}" unless %w[http https].include?(uri.scheme)
|
|
|
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
http.use_ssl = (uri.scheme == "https")
|
|
http.read_timeout = timeout
|
|
http.open_timeout = timeout
|
|
|
|
# Get the file size
|
|
response = http.request_head(uri.path)
|
|
raise NetworkError, "HTTP #{response.code}: #{response.message}" unless response.code == "200"
|
|
|
|
filesize = response["content-length"]&.to_i
|
|
raise NetworkError, "Server did not provide content-length header" unless filesize && filesize > 0
|
|
|
|
data = {filesize: filesize, chunks: []}
|
|
|
|
# Process the beginning of the file
|
|
response = http.get(uri.path, {"Range" => "bytes=0-#{chunk_size - 1}"})
|
|
raise NetworkError, "Failed to fetch beginning chunk: HTTP #{response.code}" unless response.code.start_with?("2")
|
|
data[:chunks] << response.body
|
|
|
|
# Process the end of the file
|
|
start_byte = [0, filesize - chunk_size].max
|
|
response = http.get(uri.path, {"Range" => "bytes=#{start_byte}-#{filesize - 1}"})
|
|
raise NetworkError, "Failed to fetch ending chunk: HTTP #{response.code}" unless response.code.start_with?("2")
|
|
data[:chunks] << response.body
|
|
|
|
data
|
|
rescue URI::InvalidURIError => e
|
|
raise NetworkError, "Invalid URL: #{e.message}"
|
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
raise NetworkError, "Request timeout: #{e.message}"
|
|
rescue SocketError => e
|
|
raise NetworkError, "Network error: #{e.message}"
|
|
rescue => e
|
|
raise NetworkError, "Failed to fetch from URL: #{e.message}"
|
|
end
|
|
|
|
def self.process_chunk(chunk, hash)
|
|
return hash unless chunk
|
|
|
|
chunk.unpack("Q*").each do |n|
|
|
hash = hash + n & HASH_MASK
|
|
end
|
|
|
|
hash
|
|
end
|
|
|
|
private
|
|
|
|
def self.validate_input(input)
|
|
raise InvalidInputError, "Input cannot be nil" if input.nil?
|
|
raise InvalidInputError, "Input cannot be empty" if input.empty?
|
|
raise InvalidInputError, "Input must be a string" unless input.is_a?(String)
|
|
|
|
if input.start_with?("http")
|
|
begin
|
|
uri = URI(input)
|
|
raise InvalidInputError, "Invalid URL: missing host" unless uri.host
|
|
rescue URI::InvalidURIError => e
|
|
raise InvalidInputError, "Invalid URL: #{e.message}"
|
|
end
|
|
end
|
|
end
|
|
end
|