photein 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6e24eda13e60ea0e81a7ac8005f43f9e9a6bf3f04a48893cab7c10f89fd2021a
4
+ data.tar.gz: 7bca13e3736b841bc6ef20f246f3fc6db4c812a31b4204cd0af5399ade995a61
5
+ SHA512:
6
+ metadata.gz: ed7adb5d47bb15496c5c9a30fe9ac54c616b43cc2dace96d611431005496efb5460d374544fa62a95a68abf249150f2983af5338a76080dfebe938bd6448e4e2
7
+ data.tar.gz: c17406ac9266527a2d0d85dc61bbc5eeadc90bae17f8ab94885e0616d7d8e06aa400f71b795d2b5c5504853f11c75e76f0a54b5d8d893fdde3f3469860193d83
data/README.md ADDED
@@ -0,0 +1,117 @@
1
+ Ph📸tein
2
+ ========
3
+
4
+ Import/rename photos & videos from one directory to another.
5
+
6
+ Why?
7
+ ----
8
+
9
+ * The major cloud photo services (iCloud, Google Photos) are great but not FOSS.
10
+
11
+ (My digital photo/video library belongs to me,
12
+ but if I don’t control the pipeline for viewing/managing it,
13
+ then does it really?)
14
+
15
+ * Importing photos from many sources
16
+ (📱 cell phone / 📷 digital camera / 💬 chat app)
17
+ into one library with as few manual steps as possible
18
+ is not simple, especially on Linux.
19
+
20
+ What does it do?
21
+ ----------------
22
+
23
+ Suppose your digital camera creates files with names like `R0017839.JPG`.
24
+ Photein will...
25
+
26
+ * look for a timestamp in...
27
+ * EXIF metadata
28
+ * filename
29
+ * file birthtime
30
+ * rename files by that timestamp (`YYYY-mm-dd_HHMMSS.jpg`)
31
+ * handle conflicts for identical timestamps (`YYYY-mm-dd_HHMMSSa.jpg`, `YYYY-mm-dd_HHMMSSb.jpg`...)
32
+ * sort files into subdirectories by year (`YYYY/YYYY-mm-dd_HHMMSS.jpg`)
33
+ * optionally, optimize media for reduced filesize
34
+
35
+ #### Supported media formats
36
+
37
+ * .jpg
38
+ * .dng
39
+ * .heic
40
+ * .png
41
+ * .mp4
42
+ * .mov
43
+
44
+ ### So how do I use it?
45
+
46
+ #### 📷 Auto-import from a digital camera (Linux)
47
+
48
+ Customize the provided [sample systemd service][]
49
+ to mount, import from, and unmount your camera
50
+ whenever you plug it in via USB.
51
+
52
+ ```sh
53
+ $ mkdir -p ~/.local/share/systemd/user
54
+ $ curl https://raw.githubusercontent.com/rlue/photein/master/examples/share/systemd/user/photein-dcim.service -o ~/.local/share/systemd/user/photein-dcim.service
55
+ $ systemctl --user daemon-reload
56
+ $ systemctl --user enable photein-dcim.service
57
+ ```
58
+
59
+ > Note: The provided systemd service makes the following
60
+ > assumptions:
61
+ >
62
+ > * Your device’s label is `RICOH_GR`.
63
+ > (Use `systemctl --all --full -t device`
64
+ > to determine the label of your USB device.)
65
+ > * You use [rbenv][] to manage your system’s Ruby environment.
66
+ >
67
+ > Adjust accordingly.
68
+
69
+ [sample systemd service]: blob/master/examples/share/systemd/user/photein-dcim.service
70
+ [rbenv]: https://github.com/rbenv/rbenv
71
+
72
+ #### 📱 Auto-import from an Android phone
73
+
74
+ Use [Syncthing][] to sync photos from your phone to a staging directory on
75
+ your computer. Then, run photein in a cron job to import those photos into
76
+ your library on a daily basis.
77
+
78
+ [Syncthing]: https://syncthing.net/
79
+
80
+ Installation
81
+ ------------
82
+
83
+ ```sh
84
+ $ git clone https://github.com/rlue/photein
85
+ $ cd photein
86
+ $ gem build photein.gemspec
87
+ $ gem install photein-0.0.1.gem
88
+ ```
89
+
90
+ Usage
91
+ -----
92
+
93
+ ```sh
94
+ $ photein \
95
+ --source=/media/ricoh_gr/DCIM \ # pull photos & videos from here
96
+ --dest=/home/rlue/Pictures # and deposit them here (in per-year subdirectories)
97
+ ```
98
+
99
+ Use `photein --help` for a summary of all options.
100
+
101
+ Dependencies
102
+ ------------
103
+
104
+ * [ExifTool][]
105
+ * [MediaInfo][]
106
+ * ImageMagick (for `--optimize-for=web` option)
107
+ * OptiPNG (for `--optimize-for=web` option)
108
+ * ffmpeg (for `--optimize-for={web,desktop}` options)
109
+ * lsof (for `--safe` option)
110
+
111
+ [ExifTool]: https://exiftool.org/
112
+ [MediaInfo]: https://mediaarea.net/MediaInfo
113
+
114
+ License
115
+ -------
116
+
117
+ © 2021 Ryan Lue. This project is licensed under the terms of the MIT License.
data/bin/photein ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'photein'
5
+ require 'pathname'
6
+
7
+ Photein::Config.parse_opts!
8
+ Photein::Logger.open
9
+
10
+ # Setup ------------------------------------------------------------------------
11
+
12
+ SRC_DIR = Pathname(Photein::Config.source)
13
+ DEST_DIR = Pathname(Photein::Config.dest)
14
+
15
+ begin
16
+ raise "#{Photein::Config.source}: no such directory" unless SRC_DIR.exist?
17
+ raise "#{Photein::Config.dest}: no such directory" unless DEST_DIR.exist?
18
+ raise "#{Photein::Config.source}: no photos or videos found" if Dir.empty?(SRC_DIR)
19
+ rescue => e
20
+ Photein::Logger.fatal(e.message)
21
+ exit 1
22
+ end
23
+
24
+ # Cleanup ----------------------------------------------------------------------
25
+ at_exit do
26
+ unless Photein::Config.keep
27
+ Dir[SRC_DIR.join('**/')].sort
28
+ .drop(1)
29
+ .reverse_each { |d| Dir.rmdir(d) if Dir.empty?(d) }
30
+ end
31
+
32
+ FileUtils.rm_rf(File.join(Dir.tmpdir, 'photein'))
33
+ end
34
+
35
+ # Core Logic -------------------------------------------------------------------
36
+ image_formats = Photein::Image::SUPPORTED_FORMATS
37
+ .zip(Photein::Image::SUPPORTED_FORMATS.map(&:upcase))
38
+ .flatten
39
+
40
+ SRC_DIR
41
+ .join(Photein::Config.recursive ? '**' : '')
42
+ .join("*{#{image_formats.join(',')}}")
43
+ .then { |glob| Dir[glob].sort }
44
+ .map(&Photein::Image.method(:new))
45
+ .each(&:import)
46
+
47
+ # Video compression is time-consuming, so save it for last
48
+ video_formats = Photein::Video::SUPPORTED_FORMATS
49
+ .zip(Photein::Video::SUPPORTED_FORMATS.map(&:upcase))
50
+ .flatten
51
+
52
+ SRC_DIR
53
+ .join(Photein::Config.recursive ? '**' : '')
54
+ .join("*{#{video_formats.join(',')}}")
55
+ .then { |glob| Dir[glob].sort }
56
+ .map(&Photein::Video.method(:new))
57
+ .each(&:import)
data/lib/photein.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'photein/version'
4
+ require 'photein/config'
5
+ require 'photein/logger'
6
+ require 'photein/media_file'
7
+ require 'photein/image'
8
+ require 'photein/video'
9
+
10
+ module Photein
11
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'optparse'
5
+
6
+ module Photein
7
+ class Config
8
+ include Singleton
9
+
10
+ OPTIONS = [
11
+ ['-v', '--verbose', 'print verbose output'],
12
+ ['-s SOURCE', '--source=SOURCE', 'specify the source directory'],
13
+ ['-d DESTINATION', '--dest=DESTINATION', 'specify the destination directory'],
14
+ ['-r', '--recursive', 'ingest source files recursively'],
15
+ ['-k', '--keep', 'do not delete source files'],
16
+ ['-i', '--interactive', 'ask whether to import each file found'],
17
+ ['-n', '--dry-run', 'perform a "no-op" trial run'],
18
+ [ '--safe', 'skip files in use by other processes'],
19
+ [ '--optimize-for=TARGET', %i[desktop web], 'compress images/video before importing']
20
+ ].freeze
21
+
22
+ OPTION_NAMES = OPTIONS
23
+ .flatten
24
+ .grep(/^--/)
25
+ .map { |option| option[/\w[a-z\-]+/] }
26
+ .map(&:to_sym)
27
+
28
+ class << self
29
+ def parse_opts!
30
+ @params = {}
31
+
32
+ parser = OptionParser.new do |opts|
33
+ opts.version = Photein::VERSION
34
+ opts.banner = <<~BANNER
35
+ Usage: photein [--version] [-h | --help] [<args>]
36
+ BANNER
37
+
38
+ OPTIONS.each { |opt| opts.on(*opt) }
39
+ end.tap { |p| p.parse!(into: @params) }
40
+
41
+ @params[:verbose] ||= @params[:'dry-run']
42
+ @params.freeze
43
+
44
+ raise "no source directory given" if !@params.key?(:source)
45
+ raise "no destination directory given" if !@params.key?(:dest)
46
+ rescue => e
47
+ warn("#{parser.program_name}: #{e.message}")
48
+ warn(parser.help) if e.is_a?(OptionParser::ParseError)
49
+ exit 1
50
+ end
51
+
52
+ def [](key)
53
+ @params[key]
54
+ end
55
+
56
+ def method_missing(m, *args, &blk)
57
+ m.to_s.tr('_', '-').to_sym
58
+ .then { |key| OPTION_NAMES.include?(key) ? self[key] : super }
59
+ end
60
+
61
+ def respond_to_missing?(m, *args)
62
+ @params.key?(m.to_s.tr('_', '-').to_sym) || super
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'time'
5
+
6
+ require 'photein/media_file'
7
+ require 'mini_exiftool'
8
+ require 'mini_magick'
9
+ require 'optipng'
10
+
11
+ module Photein
12
+ class Image < MediaFile
13
+ SUPPORTED_FORMATS = %w(
14
+ .jpg
15
+ .jpeg
16
+ .dng
17
+ .heic
18
+ .png
19
+ ).freeze
20
+ OPTIMIZATION_FORMAT_MAP = {
21
+ web: { '.heic' => '.jpg' }
22
+ }.freeze
23
+ MAX_RES_WEB = 2097152 # 2MP
24
+
25
+ def optimize
26
+ return if Photein::Config.optimize_for == :desktop
27
+
28
+ case extname
29
+ when '.jpg', '.heic'
30
+ return false if image.dimensions.reduce(&:*) < MAX_RES_WEB
31
+
32
+ Photein::Logger.info "optimizing #{path}"
33
+ MiniMagick::Tool::Convert.new do |convert|
34
+ convert << path
35
+ convert.colorspace('sRGB')
36
+ convert.define('jpeg:dct-method=float')
37
+ convert.interlace('JPEG')
38
+ convert.quality('85%')
39
+ convert.resize("#{MAX_RES_WEB}@>")
40
+ convert.sampling_factor('4:2:0')
41
+ convert << tempfile
42
+ end unless Photein::Config.dry_run
43
+ when '.png'
44
+ return if !Optipng.available?
45
+
46
+ FileUtils.cp(path, tempfile, noop: Photein::Config.dry_run)
47
+ Optipng.optimize(tempfile, level: 4) unless Photein::Config.dry_run
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def image
54
+ @image ||= MiniMagick::Image.open(path)
55
+ end
56
+
57
+ def metadata_stamp
58
+ MiniExiftool.new(path.to_s).date_time_original
59
+ end
60
+
61
+ # NOTE: This may be largely unnecessary:
62
+ # metadata timestamps are generally present in all cases except WhatsApp
63
+ def filename_stamp
64
+ path.basename(path.extname).to_s.then do |filename|
65
+ case filename
66
+ when /^IMG_\d{8}_\d{6}(_\d{3})?$/ # Android DCIM: datetime + optional counter
67
+ Time.strptime(filename[0, 19], 'IMG_%Y%m%d_%H%M%S')
68
+ when /^\d{13}$/ # LINE: UNIX time in milliseconds (at download)
69
+ Time.strptime(filename[0..-4], '%s')
70
+ when /^IMG-\d{8}-WA\d{4}$/ # WhatsApp: date + counter (at receipt)
71
+ Time.strptime(filename, 'IMG-%Y%m%d-WA%M%S')
72
+ when /^IMG_\d{8}_\d{6}_\d{3}$/ # Telegram: datetime in milliseconds (at download)
73
+ Time.strptime(filename, 'IMG_%Y%m%d_%H%M%S_%L')
74
+ when /^signal-\d{4}-\d{2}-\d{2}-\d{6}( \(\d+\))?$/ # Signal: datetime + optional counter (at receipt)
75
+ Time.strptime(filename[0, 24], 'signal-%F-%H%M%S')
76
+ when /^\d{13}$/ # LINE: UNIX time in milliseconds (at download)
77
+ Time.strptime(filename[0..-4], '%s')
78
+ else
79
+ super
80
+ end
81
+ end
82
+ end
83
+
84
+ def non_optimizable_format?
85
+ return false if !Photein::Config.optimize_for
86
+ return false if Photein::Config.optimize_for == :desktop
87
+ return true if extname == '.dng'
88
+
89
+ return false
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'singleton'
5
+
6
+ module Photein
7
+ class Logger
8
+ include Singleton
9
+
10
+ class << self
11
+ attr_reader :stdout, :stderr
12
+
13
+ def open
14
+ @stdout = ::Logger.new($stdout)
15
+ @stderr = ::Logger.new($stderr)
16
+
17
+ Photein::Config.verbose ? stdout.debug! : stdout.info!
18
+ Photein::Config.verbose ? stderr.warn! : stderr.fatal!
19
+ end
20
+
21
+ %i[unknown fatal error warn].each do |m|
22
+ define_method(m) { |*args| stderr.send(m, *args) }
23
+ end
24
+
25
+ %i[info debug].each do |m|
26
+ define_method(m) { |*args| stdout.send(m, *args) }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'io/console'
5
+ require 'open3'
6
+ require 'time'
7
+
8
+ module Photein
9
+ class MediaFile
10
+ DATE_FORMAT = '%F_%H%M%S'.freeze
11
+
12
+ NORMAL_EXTNAME_MAP = {
13
+ '.jpeg' => '.jpg'
14
+ }.freeze
15
+
16
+ attr_reader :path
17
+
18
+ def initialize(path)
19
+ @path = Pathname(path)
20
+ end
21
+
22
+ def import
23
+ return if Photein::Config.interactive && denied_by_user?
24
+ return if Photein::Config.safe && in_use?
25
+ return if Photein::Config.optimize_for && non_optimizable_format?
26
+
27
+ FileUtils.mkdir_p(parent_dir, noop: Photein::Config.dry_run)
28
+
29
+ optimize if Photein::Config.optimize_for
30
+
31
+ Photein::Logger.info(<<~MSG.chomp)
32
+ #{Photein::Config.keep ? 'copying' : 'moving'} #{path.basename} to #{dest_path}
33
+ MSG
34
+
35
+ if File.exist?(tempfile)
36
+ FileUtils.mv(tempfile, dest_path, noop: Photein::Config.dry_run)
37
+ else
38
+ FileUtils.cp(path, dest_path, noop: Photein::Config.dry_run)
39
+ FileUtils.chmod('-x', dest_path, noop: Photein::Config.dry_run)
40
+ end
41
+
42
+ FileUtils.rm(path, noop: Photein::Config.dry_run || Photein::Config.keep)
43
+ end
44
+
45
+ private
46
+
47
+ def denied_by_user?
48
+ $stdout.printf "Import #{path}? [y/N]"
49
+ (STDIN.getch.downcase != 'y').tap { $stdout.puts }
50
+ end
51
+
52
+ def in_use?
53
+ out, _err, status = Open3.capture3("lsof '#{path}'")
54
+
55
+ if status.success? # Do open files ALWAYS return exit status 0? (I think so.)
56
+ cmd, pid = out.lines[1]&.split&.first(2)
57
+ Photein::Logger.fatal("skipping #{path}: file in use by #{cmd} (PID #{pid})")
58
+ return true
59
+ else
60
+ return false
61
+ end
62
+ end
63
+
64
+ def non_optimizable_format? # may be overridden by subclasses
65
+ return false
66
+ end
67
+
68
+ def parent_dir
69
+ Pathname(Photein::Config.dest).join(timestamp.strftime('%Y'))
70
+ end
71
+
72
+ def tempfile
73
+ Pathname(Dir.tmpdir).join('photein')
74
+ .tap(&FileUtils.method(:mkdir_p))
75
+ .join(dest_path.basename)
76
+ end
77
+
78
+ def dest_path
79
+ @dest_path ||= begin
80
+ base_path = parent_dir.join("#{timestamp.strftime(DATE_FORMAT)}#{dest_extname}")
81
+ counter = resolve_name_collision(base_path.sub_ext("*#{dest_extname}"))
82
+
83
+ base_path.sub_ext("#{counter}#{dest_extname}")
84
+ end
85
+ end
86
+
87
+ def timestamp
88
+ @timestamp ||= (metadata_stamp || filename_stamp)
89
+ end
90
+
91
+ def filename_stamp
92
+ Time.parse(path.basename(path.extname).to_s)
93
+ rescue ArgumentError
94
+ begin
95
+ File.birthtime(path)
96
+ rescue NotImplementedError
97
+ File.mtime(path)
98
+ end
99
+ end
100
+
101
+ def dest_extname
102
+ self.class::OPTIMIZATION_FORMAT_MAP
103
+ .dig(Photein::Config.optimize_for, extname) || extname
104
+ end
105
+
106
+ def extname
107
+ @extname ||= NORMAL_EXTNAME_MAP[path.extname.downcase] || path.extname.downcase
108
+ end
109
+
110
+ def resolve_name_collision(collision_glob)
111
+ case Dir[collision_glob].length
112
+ when 0 # if no files found, no biggie
113
+ when 1 # if one file found, WITH OR WITHOUT COUNTER, reset counter to a
114
+ if Dir[collision_glob].first != collision_glob.sub('*', 'a') # don't try if it's already a lone, correctly-countered file
115
+ Photein::Logger.info('conflicting timestamp found; adding counter to existing file')
116
+ FileUtils.mv(Dir[collision_glob].first, collision_glob.sub('*', 'a'))
117
+ end
118
+ else # TODO: if multiple files found, rectify them?
119
+ end
120
+
121
+ # return the next usable counter
122
+ Dir[collision_glob].max&.slice(/.(?=#{Regexp.escape(collision_glob.extname)})/)&.next
123
+ .tap { |counter| raise 'Unresolved timestamp conflict' unless [*Array('a'..'z'), nil].include?(counter) }
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Photein
4
+ VERSION = '0.0.1'
5
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'time'
5
+
6
+ require 'photein/media_file'
7
+ require 'mediainfo'
8
+ require 'streamio-ffmpeg'
9
+ require_relative '../../vendor/terminal-size/lib/terminal-size'
10
+
11
+ module Photein
12
+ class Video < MediaFile
13
+ FFMPEG.logger.warn!
14
+
15
+ SUPPORTED_FORMATS = %w(
16
+ .mov
17
+ .mp4
18
+ .webm
19
+ ).freeze
20
+
21
+ OPTIMIZATION_FORMAT_MAP = {
22
+ desktop: { '.mov' => '.mp4' },
23
+ web: { '.mov' => '.mp4' }
24
+ }.freeze
25
+
26
+ BITRATE_THRESHOLD = {
27
+ desktop: 8388608, # 1MB/s
28
+ web: 2097152, # 0.25MB/s
29
+ }.freeze
30
+
31
+ TARGET_CRF = {
32
+ desktop: '28',
33
+ web: '35',
34
+ }.freeze
35
+
36
+ def optimize
37
+ return if video.bitrate < BITRATE_THRESHOLD[Photein::Config.optimize_for]
38
+
39
+ Photein::Logger.info("transcoding #{tempfile}")
40
+ return if Photein::Config.dry_run
41
+
42
+ video.transcode(
43
+ tempfile.to_s,
44
+ [
45
+ '-map_metadata', '0', # https://video.stackexchange.com/a/26076
46
+ '-movflags', 'use_metadata_tags',
47
+ '-c:v', 'libx264',
48
+ '-crf', TARGET_CRF[Photein::Config.optimize_for],
49
+ ],
50
+ &method(:display_progress_bar)
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def video
57
+ @video ||= FFMPEG::Movie.new(path.to_s)
58
+ end
59
+
60
+ def metadata_stamp
61
+ # video timestamps are typically UTC
62
+ MediaInfo.from(path.to_s).general.encoded_date&.getlocal
63
+ end
64
+
65
+ # NOTE: This may be largely unnecessary:
66
+ # metadata timestamps are generally present in all cases except WhatsApp
67
+ def filename_stamp
68
+ path.basename(path.extname).to_s.then do |filename|
69
+ case filename
70
+ when /^LINE_MOVIE_\d{13}$/ # LINE: UNIX time in milliseconds (at download)
71
+ Time.strptime(filename[0..-4], 'LINE_MOVIE_%s')
72
+ when /^VID-\d{8}-WA\d{4}$/ # WhatsApp: date + counter (at receipt)
73
+ Time.strptime(filename, 'VID-%Y%m%d-WA%M%S')
74
+ when /^VID_\d{8}_\d{6}_\d{3}$/ # Telegram: datetime in milliseconds (at download)
75
+ Time.strptime(filename, 'VID_%Y%m%d_%H%M%S_%L')
76
+ when /^signal-\d{4}-\d{2}-\d{2}-\d{6}( \(\d+\))?$/ # Signal: datetime + optional counter (at receipt)
77
+ Time.strptime(filename[0, 24], 'signal-%F-%H%M%S')
78
+ else
79
+ super
80
+ end
81
+ end
82
+ end
83
+
84
+ def display_progress_bar(progress)
85
+ return unless $stdout.tty?
86
+
87
+ percentage = "#{(progress * 100).to_i.to_s}%".rjust(5)
88
+ window_width = Terminal.size[:width]
89
+ bar_len = window_width - 7
90
+ progress_len = (bar_len * progress).to_i
91
+ bg_len = bar_len - progress_len
92
+ progress_bar = "[#{'#' * progress_len}#{'-' * bg_len}]#{percentage}"
93
+ print "#{progress_bar}\r"
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,57 @@
1
+ # `terminal-size` gem, Copyleft (ↄ) 2012 ☈king — Absolutely CC0 / Public Domain / Whateverness.
2
+
3
+ class Terminal
4
+ class Size; VERSION = '0.0.6' end
5
+ class << self
6
+ def size
7
+ size_via_low_level_ioctl or size_via_stty or nil
8
+ end
9
+ def size!; size or _height_width_hash_from 25, 80 end
10
+
11
+ # These are experimental
12
+ def resize direction, magnitude
13
+ tmux 'resize-pane', "-#{direction}", magnitude
14
+ end
15
+
16
+ def tmux *cmd
17
+ system 'tmux', *(cmd.map &:to_s)
18
+ end
19
+
20
+ IOCTL_INPUT_BUF = "\x00"*8
21
+ def size_via_low_level_ioctl
22
+ # Thanks to runpaint for the general approach to this
23
+ return unless $stdin.respond_to? :ioctl
24
+ code = tiocgwinsz_value_for RUBY_PLATFORM
25
+ return unless code
26
+ buf = IOCTL_INPUT_BUF.dup
27
+ return unless $stdout.ioctl(code, buf).zero?
28
+ return if IOCTL_INPUT_BUF == buf
29
+ got = buf.unpack('S4')[0..1]
30
+ _height_width_hash_from *got
31
+ rescue
32
+ nil
33
+ end
34
+
35
+ def tiocgwinsz_value_for platform
36
+ # This is as reported by <sys/ioctl.h>
37
+ # Hard-coding because it seems like overkll to acutally involve C for this.
38
+ {
39
+ /linux/ => 0x5413,
40
+ /darwin/ => 0x40087468, # thanks to brandon@brandon.io for the lookup!
41
+ }.find{|k,v| platform[k]}
42
+ end
43
+
44
+ def size_via_stty
45
+ ints = `stty size`.scan(/\d+/).map &:to_i
46
+ _height_width_hash_from *ints
47
+ rescue
48
+ nil
49
+ end
50
+
51
+ private
52
+ def _height_width_hash_from *dimensions
53
+ { :height => dimensions[0], :width => dimensions[1] }
54
+ end
55
+
56
+ end
57
+ end
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: photein
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Ryan Lue
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-05-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: mediainfo
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.5'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: mini_exiftool
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.10'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.10'
41
+ - !ruby/object:Gem::Dependency
42
+ name: mini_magick
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '4.11'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '4.11'
55
+ - !ruby/object:Gem::Dependency
56
+ name: optipng
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: nokogiri
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.11'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.11'
83
+ - !ruby/object:Gem::Dependency
84
+ name: streamio-ffmpeg
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '3.0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: pry
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '0.14'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '0.14'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.10'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.10'
125
+ description: ''
126
+ email: hello@ryanlue.com
127
+ executables:
128
+ - photein
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - README.md
133
+ - bin/photein
134
+ - lib/photein.rb
135
+ - lib/photein/config.rb
136
+ - lib/photein/image.rb
137
+ - lib/photein/logger.rb
138
+ - lib/photein/media_file.rb
139
+ - lib/photein/version.rb
140
+ - lib/photein/video.rb
141
+ - vendor/terminal-size/lib/terminal-size.rb
142
+ homepage: https://github.com/rlue/photein
143
+ licenses:
144
+ - MIT
145
+ metadata:
146
+ source_code_uri: https://github.com/rlue/photein
147
+ post_install_message:
148
+ rdoc_options: []
149
+ require_paths:
150
+ - lib
151
+ required_ruby_version: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: 2.6.0
156
+ required_rubygems_version: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '0'
161
+ requirements: []
162
+ rubygems_version: 3.2.3
163
+ signing_key:
164
+ specification_version: 4
165
+ summary: Import/rename photos & videos from one directory to another.
166
+ test_files: []