xferase 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: eb45d4b708f6d607c40971f8081a4778d45887fdd3f5b6a16e4d3b7ada1b809b
4
+ data.tar.gz: ff1906f90f5b01b0b47c0f197444d546ac00fef6203d6225bf116fb2ff9e6187
5
+ SHA512:
6
+ metadata.gz: e7c192878a8a3f13634d2b203edff06e2818cccb0dd79e1df9518ddf257e0397769a27c3f76ec6190da759cdde9b65da73c04c1c21c774056835f75eef3e4b88
7
+ data.tar.gz: 7c279579641f9e91866971167eb0aa75a1231dde5f983c3b474bd91d0c402fb164cd5c8f7f8053403b91764444cbdbfa45f3f617ccd00e0f3f8314aa17749767
data/README.md ADDED
@@ -0,0 +1,275 @@
1
+ πŸ”€ferase
2
+ ========
3
+
4
+ All your photos under one roof.
5
+ -------------------------------
6
+
7
+ Xferase is an always-on background service
8
+ that automatically imports pictures & videos
9
+ into your personal photo library as they come in.
10
+
11
+ When combined with other software,
12
+ it can be used as a kind of self-hosted / DIY alternative
13
+ to cloud photo services like Google Photos or iCloud.
14
+
15
+ Why?
16
+ ----
17
+
18
+ My photo library has always been a hot mess.
19
+ I have photos from my cell phone, photos from my digital camera,
20
+ and photos I’ve gotten from other people.
21
+ Some live in the cloud, some live on my cell phone, some live on my laptop,
22
+ some live on a backup drive in my shoe closet,
23
+ and others will sit on my digital camera’s SD card for a couple weeks
24
+ before I remember that I wanted to share them with someone.
25
+
26
+ I got tired of taking lots of photos
27
+ and then having to look in three places to find the ones I wanted.
28
+ I just wanted my old photos to live in the same place as my new photos,
29
+ plus a few other modest requirements:
30
+
31
+ 1. **automatic photo import** from any source[\*](#caveat)
32
+
33
+ > β€œAutomatic” means no mouse/keyboard interaction:
34
+ >
35
+ > * πŸ“± **cell phone** pictures get imported as soon as they’re taken;
36
+ > * πŸ’¬ **chat app** downloads, as soon as they’re saved;
37
+ > * πŸ“· **digital camera** pictures, as soon as it’s plugged into USB.
38
+
39
+ 2. master copy of library **stored locally, on disk**
40
+
41
+ > My digital photo/video library belongs to _me._
42
+ > But if I don’t own the pipeline for storing and managing it,
43
+ > then does it really? πŸ€”
44
+ >
45
+ > Cloud services can raise their prices, go offline,
46
+ > or fall victim to ransomware attacks.
47
+ > My own Internet can fail, too.
48
+ > I’m happy to use the cloud for Netflix & Spotify,
49
+ > but for something as personal and unreplaceable as my photos,
50
+ > I want the master copy in my own hands.
51
+
52
+ 3. automatic syncing of library back to phone[\*](#caveat)
53
+
54
+ > Obviously, I don’t just want to _collect_ my personal photos;
55
+ > I want to _use_ them, and to have them with me wherever I go.
56
+ > But your complete photo collection is probably
57
+ > too large to fit on your cell phone,
58
+ > which is why Google and Apple host it in the cloud.
59
+ >
60
+ > Xferase gets around this
61
+ > by maintaining two parallel copies of your library:
62
+ > one master, and one optimized for web.
63
+ > Keep both on your computer and sync the latter to your phone;
64
+ > Xferase will make sure that when a photo is deleted from one,
65
+ > it’s automatically removed from the other, too.
66
+
67
+ 4. clean, consistent, **user-visible directory & filename scheme**
68
+
69
+ > Call me obsessive-compulsive, but which would you rather haveβ€”
70
+ >
71
+ > ```sh
72
+ > # this? # ...or this?
73
+ >
74
+ > ~/Pictures ~/Pictures
75
+ > β”œβ”€β”€ 1619593208911.jpeg β”œβ”€β”€ 2020
76
+ > β”œβ”€β”€ DCIM β”‚ β”œβ”€β”€ 2020-08-01_113129.heic
77
+ > β”‚ └── 2021_03_26 β”‚ └── 2020-05-20_160209.png
78
+ > β”‚ β”œβ”€β”€ R0014285.MOV └── 2021
79
+ > β”‚ β”œβ”€β”€ R0014286.DNG β”œβ”€β”€ 2021-02-12_081933a.jpg
80
+ > β”‚ β”œβ”€β”€ R0014286.JPG β”œβ”€β”€ 2021-02-12_081933b.jpg
81
+ > β”‚ β”œβ”€β”€ R0014287.DNG β”œβ”€β”€ 2021-02-12_081939.mp4
82
+ > β”‚ └── R0014287.JPG β”œβ”€β”€ 2021-03-26_161245.mp4
83
+ > β”œβ”€β”€ IMG_20210212_081933_001.jpg β”œβ”€β”€ 2021-03-26_161518.dng
84
+ > β”œβ”€β”€ IMG_20210212_081933_002.jpg β”œβ”€β”€ 2021-03-26_161518.jpg
85
+ > β”œβ”€β”€ IMG_8953.HEIC β”œβ”€β”€ 2021-03-26_170304.dng
86
+ > β”œβ”€β”€ Screenshot_20200520_160209.png β”œβ”€β”€ 2021-03-26_170304.jpg
87
+ > └── VID_20210212_081939.mp4 └── 2021-04-28_000008.jpg
88
+ > ```
89
+ >
90
+ > I also want to know where my files are
91
+ > so I can find them in a β€œBrowse...” dialog
92
+ > or mirror them to other devices with Dropbox, Syncthing, or even rsync.
93
+
94
+ 5. available on Linux
95
+
96
+ > Xferase has not been tested on macOS,
97
+ > but it should work when run in a Docker container.
98
+
99
+ #### \*Caveat
100
+
101
+ For points 1 and 3, Xferase needs the help of additional software
102
+ ([Syncthing][] & systemd).
103
+ If you have no experience (or interest in) tinkering with Linux,
104
+ Xferase is **not** for you.
105
+
106
+ See the next section for more details.
107
+
108
+ [Syncthing]: https://syncthing.net
109
+
110
+ What _Exactly_ Does It Do?
111
+ --------------------------
112
+
113
+ Xferase watches a directory of your choosing (its β€œinbox”),
114
+ and whenever any files are placed there,
115
+ it automatically optimizes and imports them into your photo library,
116
+ like so:
117
+
118
+ ```sh
119
+ # Before # After
120
+
121
+ ~/Pictures ~/Pictures
122
+ β”œβ”€β”€ .inbox β”œβ”€β”€ .inbox
123
+ β”‚ β”œβ”€β”€ 1619593208911.jpeg └── library
124
+ β”‚ β”œβ”€β”€ DCIM β”œβ”€β”€ 2020
125
+ β”‚ β”‚ └── 2021_03_26 β”‚ β”œβ”€β”€ 2020-08-01_113129.heic
126
+ β”‚ β”‚ β”œβ”€β”€ R0014285.MOV β”‚ └── 2020-05-20_160209.png
127
+ β”‚ β”‚ β”œβ”€β”€ R0014286.DNG └── 2021
128
+ β”‚ β”‚ β”œβ”€β”€ R0014286.JPG β”œβ”€β”€ 2021-02-12_081933a.jpg
129
+ β”‚ β”‚ β”œβ”€β”€ R0014287.DNG β”œβ”€β”€ 2021-02-12_081933b.jpg
130
+ β”‚ β”‚ └── R0014287.JPG β”œβ”€β”€ 2021-02-12_081939.mp4
131
+ β”‚ β”œβ”€β”€ IMG_20210212_081933_001.jpg β”œβ”€β”€ 2021-03-26_161245.mp4
132
+ β”‚ β”œβ”€β”€ IMG_20210212_081933_002.jpg β”œβ”€β”€ 2021-03-26_161518.dng
133
+ β”‚ β”œβ”€β”€ IMG_8953.HEIC β”œβ”€β”€ 2021-03-26_161518.jpg
134
+ β”‚ β”œβ”€β”€ Screenshot_20200520_160209.png β”œβ”€β”€ 2021-03-26_170304.dng
135
+ β”‚ └── VID_20210212_081939.mp4 β”œβ”€β”€ 2021-03-26_170304.jpg
136
+ └── library └── 2021-04-28_000008.jpg
137
+ ```
138
+
139
+ (You may have noticed that this is identical to the snippet from above,
140
+ except for the `.inbox` and `library` parent directories.)
141
+
142
+ How you get those files from your phone or camera into the inbox is up to you.
143
+
144
+ > ### 🀷 Up to me?? I thought Xferase was supposed to β€œimport from many sources”.
145
+ >
146
+ > Yes, but only with the help of other software.
147
+ > Automatically getting files from other devices onto your computer
148
+ > is not a trivial problem,
149
+ > and there are existing utilities that already do it better than Xferase could.
150
+ >
151
+ > Consider that different users may have a different technical requirements:
152
+ >
153
+ > * Should digital camera photos be transferred over USB,
154
+ > or using a Wi-Fi SD card?
155
+ > * Should cell phone photos be transferred over cellular data,
156
+ > or only over Wi-Fi?
157
+ > * How many other devices should the library be mirrored/synced to?
158
+ >
159
+ > Limiting the scope of what Xferase does
160
+ > gives you the flexibility to use the best available tools
161
+ > for your needs, every step of the way.
162
+ >
163
+ > Here is a diagram outlining the recommended approach:
164
+ >
165
+ > <img src="/i/system-diagram.gif" width="480">
166
+ >
167
+ > For more on each step, see the [guides](#guides) below.
168
+
169
+ Quick Start
170
+ -----------
171
+
172
+ > πŸ’‘ **The examples below assume you have [Docker][] installed.**
173
+ >
174
+ > Why use Docker? Xferase can be [installed natively as a Ruby gem][],
175
+ > but there are lots of external dependencies.
176
+ >
177
+ > [Docker]: https://docs.docker.com/get-docker/
178
+ > [installed natively as a Ruby gem]: guides/ingest.md#option-2-rubygems--systemd
179
+
180
+ ### Basic Usage
181
+
182
+ ```sh
183
+ $ docker run -d \
184
+ --name xferase \
185
+ --user $(id -u) \
186
+ --env TZ=$(timedatectl show --property=Timezone --value) \
187
+ --volume $HOME/Pictures:/data \
188
+ --env INBOX=/data/.inbox \
189
+ --env STAGING=/data/.staging \
190
+ --env LIBRARY=/data/master \
191
+ rlue/xferase
192
+ ```
193
+
194
+ Any photos or videos placed in the **inbox**
195
+ will be automatically moved to the **staging folder** for processing.
196
+ From there, files are moved to the **library**,
197
+ with videos being compressed to save space on disk.
198
+
199
+ > πŸ€” **What’s with the staging folder?**
200
+ >
201
+ > Using a temp folder makes it easier to resume from a crash,
202
+ > especially when the `LIBRARY_WEB` env var is set.
203
+
204
+ #### Option: `LIBRARY_WEB`
205
+
206
+ ```sh
207
+ $ docker run -d \
208
+ --name xferase \
209
+ --user $(id -u) \
210
+ --env TZ=$(timedatectl show --property=Timezone --value) \
211
+ --volume $HOME/Pictures:/data \
212
+ --env INBOX=/data/.inbox \
213
+ --env STAGING=/data/.staging \
214
+ --env LIBRARY=/data/master \
215
+ --env LIBRARY_WEB=/data/web \
216
+ rlue/xferase
217
+ ```
218
+
219
+ Xferase will create a separate, lo-res copy of each imported file
220
+ and save it to the **web-optimized library**.
221
+
222
+ **Xferase keeps both libraries in sync**,
223
+ meaning that when a photo is deleted from one,
224
+ it will be automatically deleted from the other.
225
+
226
+ > ⚠️ **Warning**
227
+ >
228
+ > This also applies to copies of the same image in different formats:
229
+ > if you shoot RAW+JPEG, deleting a .jpg will cause Xferase to delete the
230
+ > corresponding raw image file (and vice versa).
231
+
232
+ #### Option: `GRACE_PERIOD`
233
+
234
+ ```sh
235
+ $ docker run -d \
236
+ --name xferase \
237
+ --user $(id -u) \
238
+ --env TZ=$(timedatectl show --property=Timezone --value) \
239
+ --volume $HOME/Pictures:/data \
240
+ --env INBOX=/data/.inbox \
241
+ --env STAGING=/data/.staging \
242
+ --env LIBRARY=/data/master \
243
+ --env LIBRARY_WEB=/data/web \
244
+ --env GRACE_PERIOD=60 \
245
+ rlue/xferase
246
+ ```
247
+
248
+ Xferase will wait 60 seconds before initiating the import process.
249
+
250
+ Why would you want this?
251
+ Because not every picture you take belongs in your collectionβ€”maybe
252
+ you just wanted to show a friend a weird bug you found on the sidewalk.
253
+ With the grace period set, you can film it, send it off, and delete it
254
+ before Xferase wastes CPU time transcoding it (twice).
255
+
256
+ Guides
257
+ ------
258
+
259
+ ### 1. Upload (with systemd/Syncthing)
260
+
261
+ * [πŸ“·βž‘οΈπŸ–₯️ Get photos from your camera into Xferase’s inbox](guides/upload-camera.md)
262
+ * [πŸ“±βž‘οΈπŸ–₯️ Get photos from your phone into Xferase’s inbox](guides/upload-phone.md)
263
+
264
+ ### 2. Ingest (with Xferase)
265
+
266
+ * [πŸ–ΌοΈβž‘οΈπŸ“‚ Rename, optimize, and move new photos into your library](guides/ingest.md)
267
+
268
+ ### 3. Propagate (with Syncthing)
269
+
270
+ * [πŸ–₯οΈπŸ”„πŸ“± Sync your library (back) to other devices](guides/propagate.md)
271
+
272
+ License
273
+ -------
274
+
275
+ Β© 2021 Ryan Lue. This project is licensed under the terms of the MIT License.
data/bin/xferase ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Config -----------------------------------------------------------------------
5
+ require 'xferase'
6
+ require 'photein'
7
+
8
+ Xferase::Config.parse_opts!
9
+ Xferase.logger.open
10
+ Photein.logger = Xferase.logger
11
+
12
+ ENV['MEDIAINFO_XML_PARSER'] ||= 'nokogiri'
13
+
14
+ SRC_DEST_MAP = {
15
+ "#{Xferase::Config.staging}/desktop" => Xferase::Config.library,
16
+ "#{Xferase::Config.staging}/web" => Xferase::Config.library_web,
17
+ }.compact
18
+
19
+ FORMAT_GROUPS = [%w(.jpg .dng .heic), %w(.mov .mp4), %w(.png)]
20
+
21
+ # Setup ------------------------------------------------------------------------
22
+ require 'debouncer'
23
+ require 'fileutils'
24
+ require 'pathname'
25
+ require 'rb-inotify'
26
+ require 'tmpdir'
27
+
28
+ %i[inbox staging library library_web]
29
+ .map { |dir| Xferase::Config.send(dir) }
30
+ .compact
31
+ .reject(&File.method(:directory?))
32
+ .each do |dir|
33
+ Xferase.logger.info("#{dir}: not found; creating now...")
34
+ FileUtils.mkdir_p(dir)
35
+ end
36
+
37
+ Thread.abort_on_exception = true
38
+
39
+ mutex = Mutex.new
40
+ debouncer = Debouncer.new(Xferase::Config.grace_period.to_i) do |*files|
41
+ files = files.map(&Pathname.method(:new))
42
+ .select(&:exist?)
43
+ .reject do |file|
44
+ file.dirname.join(".syncthing.#{file.basename}.tmp").exist?
45
+ end
46
+
47
+ SRC_DEST_MAP.keys.each { |dir| FileUtils.ln(files, dir) }
48
+ FileUtils.rm(files + Dir["#{Xferase::Config.staging}/web/*.DNG"]) # FIXME (ugly hack)
49
+
50
+ break if debouncer.inspect_params[:threads] > 2 # don't let threads pile up
51
+
52
+ mutex.synchronize do
53
+ SRC_DEST_MAP.each do |src, dest|
54
+ Photein::Config.set(
55
+ source: src,
56
+ dest: dest,
57
+ 'optimize-for': Pathname(src).basename.to_sym
58
+ )
59
+
60
+ Photein.run
61
+ end
62
+ rescue => e
63
+ warn e.message
64
+ end
65
+ end.reducer(:+)
66
+
67
+ # Resume from interruption/failure ---------------------------------------------
68
+ debouncer.call unless SRC_DEST_MAP.keys.all?(&Dir.method(:empty?))
69
+
70
+ Dir["#{Xferase::Config.inbox}/**/*"]
71
+ .select(&File.method(:file?))
72
+ .each { |path| debouncer.call(path) }
73
+
74
+ # Start ------------------------------------------------------------------------
75
+ Thread.new do
76
+ call_debouncer = ->(event) do
77
+ next if event.name.start_with?('.')
78
+
79
+ Xferase.logger.debug("#{event.name}: new file detected in watch directory")
80
+ debouncer.call(event.absolute_name)
81
+ rescue => e
82
+ warn e.message
83
+ end
84
+
85
+ import_notifier = INotify::Notifier.new
86
+ import_notifier.watch(Xferase::Config.inbox, :close_write, &call_debouncer)
87
+
88
+ # NOTE: inotify is not recursive,
89
+ # so subdirectories must be watched separately!
90
+ # (Why do Syncthing folders get special treatment?
91
+ # Because ST works by creating hidden tempfiles and moving them upon completion)
92
+ stfolders, simple_subdirs = Dir["#{Xferase::Config.inbox}/**/*"]
93
+ .select(&File.method(:directory?))
94
+ .partition { |dir| Dir.children(dir).include?('.stfolder') }
95
+
96
+ simple_subdirs.each { |dir| import_notifier.watch(dir, :close_write, &call_debouncer) }
97
+ stfolders.each { |dir| import_notifier.watch(dir, :moved_to, &call_debouncer) }
98
+
99
+ import_notifier.run
100
+ end
101
+
102
+ Thread.new do
103
+ sync_deletions = ->(event) do
104
+ Xferase.logger.info("#{event.name} has disappeared!")
105
+
106
+ deleted_file = Pathname(event.absolute_name).expand_path
107
+
108
+ sister_file = if deleted_file.to_s.start_with?(File.expand_path(Xferase::Config.library))
109
+ deleted_file.sub(Xferase::Config.library, Xferase::Config.library_web)
110
+ else
111
+ deleted_file.sub(Xferase::Config.library_web, Xferase::Config.library)
112
+ end
113
+
114
+ sister_formats = FORMAT_GROUPS.find { |group| group.include?(deleted_file.extname) }
115
+
116
+ related_files = [deleted_file, sister_file]
117
+ .product(sister_formats)
118
+ .map { |file, ext| file.sub_ext(ext) }
119
+ .select(&:file?)
120
+
121
+ # (Why mv to tmpdir first? Why not rm straight away?
122
+ # Because rm would recursively trigger this inotify callback.)
123
+ related_files.each { |f| Xferase.logger.info("deleting #{f.realpath}") }
124
+ FileUtils.mv(related_files, Dir.tmpdir)
125
+ FileUtils.rm(related_files.map { |f| File.join(Dir.tmpdir, f.basename) })
126
+ end
127
+
128
+ deletion_notifier = INotify::Notifier.new
129
+
130
+ # NOTE: inotify is not recursive,
131
+ # so subdirectories must be watched separately!
132
+ (Dir["#{Xferase::Config.library}/**/*"] + Dir["#{Xferase::Config.library_web}/**/*"])
133
+ .select(&File.method(:directory?))
134
+ .each { |dir| deletion_notifier.watch(dir, :delete, &sync_deletions) }
135
+
136
+ deletion_notifier.run
137
+ end.join
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'optparse'
5
+
6
+ module Xferase
7
+ class Config
8
+ include Singleton
9
+
10
+ OPTIONS = [
11
+ ['-v', '--verbose', 'print verbose output'],
12
+ ['-i INBOX', '--inbox=INBOX', 'path to the inbox (required)'],
13
+ ['-s STAGING', '--staging=STAGING', 'path to the staging directory (required)'],
14
+ ['-l LIBRARY', '--library=LIBRARY', 'path to the master library (required)'],
15
+ ['-w LIBRARY_WEB', '--library-web=LIBRARY_WEB', 'path to the web-optimized library'],
16
+ ['-g INTERVAL', '--grace-period=INTERVAL', 'wait n seconds for additional files before import'],
17
+ ].freeze
18
+
19
+ OPTION_NAMES = OPTIONS
20
+ .flatten
21
+ .grep(/^--/)
22
+ .map { |option| option[/\w[a-z\-]+/] }
23
+ .map(&:to_sym)
24
+
25
+ class << self
26
+ def parse_opts!
27
+ @params = {}
28
+
29
+ parser = OptionParser.new do |opts|
30
+ opts.version = Xferase::VERSION
31
+ opts.banner = <<~BANNER
32
+ Usage: xferase [--version] [-h | --help] [<args>]
33
+ BANNER
34
+
35
+ OPTIONS.each { |opt| opts.on(*opt) }
36
+ end.tap { |p| p.parse!(into: @params) }
37
+
38
+ @params.freeze
39
+
40
+ raise "no inbox directory given" if !@params.key?(:inbox)
41
+ raise "no staging directory given" if !@params.key?(:staging)
42
+ raise "no master library given" if !@params.key?(:library)
43
+ rescue => e
44
+ warn("#{parser.program_name}: #{e.message}")
45
+ warn(parser.help) if e.is_a?(OptionParser::ParseError)
46
+ exit 1
47
+ end
48
+
49
+ def [](key)
50
+ @params[key]
51
+ end
52
+
53
+ def method_missing(m, *args, &blk)
54
+ m.to_s.tr('_', '-').to_sym
55
+ .then { |key| OPTION_NAMES.include?(key) ? self[key] : super }
56
+ end
57
+
58
+ def respond_to_missing?(m, *args)
59
+ @params.key?(m.to_s.tr('_', '-').to_sym) || super
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'singleton'
5
+
6
+ module Xferase
7
+ class << self
8
+ attr_writer :logger
9
+
10
+ def logger
11
+ @logger ||= Xferase::Logger
12
+ end
13
+ end
14
+
15
+ class Logger
16
+ include Singleton
17
+
18
+ class << self
19
+ attr_reader :stdout, :stderr
20
+
21
+ def open
22
+ @stdout = ::Logger.new($stdout)
23
+ @stderr = ::Logger.new($stderr)
24
+
25
+ Xferase::Config.verbose ? stdout.debug! : stdout.info!
26
+ Xferase::Config.verbose ? stderr.warn! : stderr.fatal!
27
+ end
28
+
29
+ %i[unknown fatal error warn].each do |m|
30
+ define_method(m) { |*args| stderr.send(m, *args) }
31
+ end
32
+
33
+ %i[info debug].each do |m|
34
+ define_method(m) { |*args| stdout.send(m, *args) }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xferase
4
+ VERSION = '0.0.1'
5
+ end
data/lib/xferase.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xferase/version'
4
+ require 'xferase/config'
5
+ require 'xferase/logger'
6
+
7
+ module Xferase
8
+ end
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: xferase
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-12-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: debouncer
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ - !ruby/object:Gem::Dependency
28
+ name: photein
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.0'
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 0.0.9
37
+ type: :runtime
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - "~>"
42
+ - !ruby/object:Gem::Version
43
+ version: '0.0'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 0.0.9
47
+ - !ruby/object:Gem::Dependency
48
+ name: rb-inotify
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.10'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.10'
61
+ - !ruby/object:Gem::Dependency
62
+ name: pry
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.14'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.14'
75
+ - !ruby/object:Gem::Dependency
76
+ name: rspec
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.10'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.10'
89
+ description: ''
90
+ email: hello@ryanlue.com
91
+ executables:
92
+ - xferase
93
+ extensions: []
94
+ extra_rdoc_files: []
95
+ files:
96
+ - README.md
97
+ - bin/xferase
98
+ - lib/xferase.rb
99
+ - lib/xferase/config.rb
100
+ - lib/xferase/logger.rb
101
+ - lib/xferase/version.rb
102
+ homepage: https://github.com/rlue/xferase
103
+ licenses:
104
+ - MIT
105
+ metadata:
106
+ source_code_uri: https://github.com/rlue/xferase
107
+ post_install_message:
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: 2.6.0
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubygems_version: 3.2.3
123
+ signing_key:
124
+ specification_version: 4
125
+ summary: Import/rename photos & videos from one directory to another.
126
+ test_files: []