Home Custom client for LocalSend - part 2
Post
Cancel

Custom client for LocalSend - part 2

Bash

In a previous post I implemented a very simple LocalSend client for my Linux desktop.

The Bash script was simple and it did exactly what I needed it to do. Here it is:

The script

1
2
3
4
5
6
7
8
9
10
11
12
BASE_URL=http://192.168.1.149:53317/api/localsend/v2
SESSION_ID=5a9606ab-f683-4045-8f72-5a6a2ace9c39

curl -X POST --silent $BASE_URL/prepare-download?sessionId=$SESSION_ID |
  jq -c ".files[] | {id, fileName}" |
  while read -r FILE_JSON
  do
    FILE_ID=$(echo $FILE_JSON | jq -r .id)
    FILE_NAME=$(echo $FILE_JSON | jq -r .fileName)

    wget -O $FILE_NAME "${BASE_URL}/download?sessionId=${SESSION_ID}&fileId=${FILE_ID}"
  done

It still required some manual steps though.

  1. BASE_URL to be inserted manually
  2. SESSION_ID needs to be extracted via browser and inserted manually
  3. Files are downloaded in the same directory where the was run script from. You have to navigate to where you want the files to be downloaded.

Ruby

As stated in my previous post, LocalSend web page assumes JavaScript support. cURL cannot really help me with that.

In a recent issue of Ruby Weekly, this is what I came across:

Tanakai: A Web Scraping Framework for RubyKimurai was a scraping framework that seemingly hibernated a few years ago, but Tanakai is an up-to-date fork that supports Ruby 3+, Cuprite, and lets you work with headless browsers to scrape and interact with modern sites, even those rendered with JavaScript.

Perfect! 👌😍

MVP

Here’s the solution:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
require 'tanakai'

# Crawls LocalSend's "share via link"
class LocalSendSpider < Tanakai::Base
  PHONE_APP_ACCEPT_TIMEOUT = 10

  @name = 'local_send_spider'
  @engine = :selenium_firefox

  def parse(_response = nil, url:, **)
    logger.info('Count down for approval on Smartphone side')

    PHONE_APP_ACCEPT_TIMEOUT.times do |n|
      sleep 1
      logger.info(PHONE_APP_ACCEPT_TIMEOUT - n)
      break unless browser.current_response.xpath('/html/body/div/a').empty?
    end

    logger.info('Fetching file links...')
    browser.current_response.xpath('/html/body/div/a').map do |nokogiri_node|
      file_path = nokogiri_node[:href]
      file_name = nokogiri_node.xpath('div[@class = "file-name-cell"]').text.strip

      logger.warn("Downloading file #{file_name}")
      system(%(wget -O #{file_name} "#{url + file_path}"))
    end
    logger.info('Done!')
  end
end

LocalSendSpider.parse!(:parse, url: 'http://192.168.1.247:53317')

It should be pretty easy to follow:

  1. Navigate to some URL with Selenium Firefox
  2. Wait for “approval” from the smartphone app
  3. Find file links and names with some help from Nokogiri
  4. Download each file with wget

Disregard the #parse and #parse! methods. That’s part of Tanakai’s admittedly clunky API.

Here’s what the output looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ bundle exec ruby local_send.rb
... DEBUG -- local_send_spider: BrowserBuilder (selenium_firefox): created browser instance
... DEBUG -- local_send_spider: BrowserBuilder (selenium_firefox): enabled native headless_mode
...  INFO -- local_send_spider: Browser: started get request to: http://192.168.1.247:53317
...  INFO -- local_send_spider: Browser: finished get request to: http://192.168.1.247:53317
... DEBUG -- local_send_spider: Browser: driver.current_memory: 725158
...  INFO -- local_send_spider: Count down for approval on Smartphone side
...  INFO -- local_send_spider: 10
...  INFO -- local_send_spider: 9
...  INFO -- local_send_spider: 8
...  INFO -- local_send_spider: 7
...  INFO -- local_send_spider: 6
...  INFO -- local_send_spider: 5
...  INFO -- local_send_spider: Fetching file links...
...  WARN -- local_send_spider: Downloading file IMG_20231029_120332_010.jpg

--2023-11-01 22:16:45--  http://192.168.1.247:53317/api/localsend/v2/download?sessionId=cec94145-8f75-4dd1-bbce-2e627957b88a&fileId=890b04dc-9537-499a-925b-9671b86b1309
Connecting to 192.168.1.247:53317... connected.
HTTP request sent, awaiting response... 200 OK
Length: 9445328 (9.0M) [application/octet-stream]
Saving to: ‘IMG_20231029_120332_010.jpg’

IMG_20231029_120332_010.jpg     100%[======================================================>]   9.01M  4.38MB/s    in 2.1s

2023-11-01 22:16:47 (4.38 MB/s) - ‘IMG_20231029_120332_010.jpg’ saved [9445328/9445328]

... INFO -- local_send_spider: Done!
... INFO -- local_send_spider: Browser: driver selenium_firefox has been destroyed

And that’s it! Simple, right?

In terms of functionality, this is better than the Bash script from previous post. There’s no need to extract the SESSION_ID from the browser; that’s the benefit of supporting JavaScript. The only manual step here is to paste in the URL where the files can be downloaded.

Everything else stays the same; files are downloaded to the folder from where this script was run, and file names are preserved with the download.

PRs

I always use the latest version of Ruby. At the time of writing, that’s Ruby 3.2.2. There were two issues I needed to resolve before I could use Tanakai, so I opened respective pull requests that got merged:

  1. PR #1: Replace deprecated File::exists?.
  2. PR #2: Bugfix: URI:HTTP NameError

That’s good. Now I can continue using Tanakai and improve my script.

Improvements

I want to create a proper CLI tool for downloading files from LocalSend phone app.

Specifically:

  1. URL should be supplied via command line argument
  2. SESSION_ID should be hidden from the user’s perspective
  3. Download folder should be specified via command line argument
  4. Both URL and the download folder should be subject to validation
  5. When something goes wrong, there should be a clear error message describing what went wrong
  6. Other

CLI

I want to pass arguments via the command line. There are a couple of options to do that:

  1. I could use ARGV directly and parse it manually. But I won’t do that. No need to reinvent the wheel.
  2. Use Ruby standard library’s OptionParser
  3. Use a gem

In the “CLI option parsers” category on The Ruby Toolbox I can find a couple of good candidates:

  1. HighLine
  2. Slop
  3. Gli
  4. Optimist

I won’t go with any of them though. OptionParser will do the job of simple command line option parsing perfectly well. I’ve also used it in the past and am familiar with it.

OptionParser it is then!

OptionParser

OptionParser is a class for command-line option analysis.

Features

  1. The argument specification and the code to handle it are written in the same place.
  2. It can output an option summary; you don’t need to maintain this string separately.
  3. Optional and mandatory arguments are specified very gracefully.
  4. Arguments can be automatically converted to a specified class.
  5. Arguments can be restricted to a certain set.

All of these features are demonstrated in the examples below. See make_switch for full documentation.

The implementation is pretty straight forward. Note that both the --url and --download-path are optional - denoted by the square brackets [value]. I’m using Ruby’s immutable Data class in order to store the actual values. They’re great for defining simple value-like objects and are convenient to use for pattern matching. I’ll use pattern matching for the validation purposes below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
require 'optparse'
require 'optparse/uri'

module CmdLineArgs
  Missing = Data.define
  CmdArgs = Data.define(:uri, :download_path)
end

## Parse CLI flags
class ArgParser
  include CmdLineArgs

  LOCAL_SEND_DEFAULT_PORT = 53_317

  def self.call(options)
    new.call(options)
  end

  def call(options)
    parse(options)
  end

  private

  def parse(_options)
    args = { uri: Missing.new, download_path: Missing.new }

    opt_parser = OptionParser.new do |parser|
      parser.banner = <<~BANNER
        Download all files from LocalSend\'s "share via link" <URL> to <DOWNLOAD_PATH>
        Usage: ruby local_send.rb --url http://192.168.1.230 --download-path /some/path/to/download/dir
      BANNER

      parser.separator('')
      parser.set_summary_width(40)

      parser.on('-u', '--url [URL]', URI) do |uri|
        if uri
          uri.port = LOCAL_SEND_DEFAULT_PORT
          args[:uri] = uri
        end
      end

      parser.on('-d', '--download-path [DOWNLOAD_PATH]', String) do |download_path|
        args[:download_path] = File.expand_path(download_path) if download_path
      end

      parser.on('-h', '--help', 'Prints this help') do
        puts parser
        exit
      end
    end

    opt_parser.parse!
    CmdArgs.new(**args)
  end
end

ArgParser.call(ARGV)
1
2
3
4
5
6
7
8
$ bundle exec ruby local_send.rb --help
Download all files from LocalSend's "share via link" <URL> to <DOWNLOAD_PATH>
Usage: ruby local_send.rb --url http://192.168.1.230 --download-path /some/path/to/download/dir

    -u, --url [URL]
    -d, --download-path [DOWNLOAD_PATH]
    -h, --help                               Prints this help

Validation

Ruby’s pattern matching is perfect for the task at hand. It’s very easy to describe different error modes and handle them accordingly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Validate ARGV
class ArgValidator
  include CmdLineArgs

  attr_reader :parsed_options

  def initialize(parsed_options)
    @parsed_options = parsed_options
  end

  def when_valid
    case parsed_options
    in CmdArgs(uri: Missing, download_path: Missing)
      raise StandardError, 'Missing URL and DOWNLOAD_PATH'
    in CmdArgs(uri: Missing, download_path: _)
      raise StandardError, 'Missing URL'
    in CmdArgs(uri: _, download_path: Missing)
      raise StandardError, 'Missing DOWNLOAD_PATH'
    in CmdArgs(_, download_path) unless File.directory?(download_path)
      raise StandardError, 'DOWNLOAD_PATH is not a directory or DOWNLOAD_PATH does not exist'
    in CmdArgs(URI::HTTP | URI::HTTPS => uri, String => download_path)
      yield uri.to_s, download_path
    in CmdArgs(URI::Generic => uri, _)
      raise StandardError, "Invalid URL: #{uri}"
    end
  end
end


ArgValidator.new(ArgParser.call(ARGV)).when_valid do |url, download_path|
  # do something with url and download_path
end

File Download

I’ve provided two different downloaders here. One is written in pure Ruby, the other uses wget.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
require 'open-uri'
require 'fileutils'

# Should be included in all download hander classes
module DownloaderInterface
  def call
    raise NotImplementedError
  end

  def full_file_download_path
    raise NotImplementedError
  end
end

# Downloads file to a specified path from URL
class RubyDownloader
  include DownloaderInterface

  attr_reader :uri, :file_name, :download_path

  def initialize(uri, file_name, download_path)
    @uri = uri
    @file_name = file_name
    @download_path = download_path
  end

  def call
    File.open(full_file_download_path, 'w') { _1.write(OpenURI.open_uri(uri).read) }
  end

  def full_file_download_path
    File.join(download_path, file_name)
  end
end

# Downloads file to a specified path from URL via wget
class WgetDownloader
  include DownloaderInterface

  attr_reader :uri, :file_name, :download_path

  def initialize(uri, file_name, download_path)
    @uri = uri
    @file_name = file_name
    @download_path = download_path
  end

  def call
    system(%(wget -O #{full_file_download_path} "#{uri}"))
  end

  def full_file_download_path
    File.join(download_path, file_name)
  end
end

Usage

And here’s how to put everything together:

1
2
3
4
5
6
7
8
9
10
ArgValidator.new(ArgParser.call(ARGV)).when_valid do |url, download_path|
  LocalSendSpider.parse!(
    :parse,
    url: url,
    data: {
      download_path: download_path,
      download_handler: WgetDownloader # or use RubyDownloader
    }
  )
end

And here’s how to use it from the command line:

1
$ bundle exec ruby local_send.rb --url http://192.168.1.230 --download-path /some/path/to/download/dir
This post is licensed under CC BY 4.0 by the author.