xferase 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []