# 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