photein 0.0.4 → 0.0.5
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 +4 -4
- data/README.md +32 -66
- data/lib/photein/version.rb +1 -1
- metadata +1 -3
- data/bin/xferase +0 -170
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f2e520a4d9508ba94604273c88b9f0d3bb1052aae63cef56c856595e5b0b3866
|
|
4
|
+
data.tar.gz: 14633d71731ff23c3350c861bfe33d289bfb53777d6369a6245c203de93dacde
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf796a670cc45ce45238b4e9b0e23641ce1dc21aa67008bb016e9507b0470bebdd4790620bfd5325eabdcbe833165588c32811b244b5f8271ff8fa05d5b2d473
|
|
7
|
+
data.tar.gz: deaea8ad0c1ad541c5e160ea927dbfef115902a7f25b96205e67c780e45477a0def030fe1dc4ba2063cc3c936fb7298ccb9f7f4dca70069e4e7ac4ec7fdd586f
|
data/README.md
CHANGED
|
@@ -1,29 +1,11 @@
|
|
|
1
|
-
Ph📸tein
|
|
2
|
-
|
|
1
|
+
Ph📸tein
|
|
2
|
+
========
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
A no-nonsense way to organize your personal photo library.
|
|
5
5
|
|
|
6
|
-
What
|
|
6
|
+
What does it do?
|
|
7
7
|
----------------
|
|
8
8
|
|
|
9
|
-
`photein` is a CLI utility for batch-importing
|
|
10
|
-
your personal photos & videos into a central library.
|
|
11
|
-
|
|
12
|
-
`xferase` is an always-on background service that uses `photein`
|
|
13
|
-
to continuously import new photos & videos as they come in.
|
|
14
|
-
|
|
15
|
-
When combined with other software,
|
|
16
|
-
they can be used as a kind of self-hosted / DIY alternative
|
|
17
|
-
to cloud photo services like Google Photos or iCloud.
|
|
18
|
-
|
|
19
|
-
> ⚠️ **Note**
|
|
20
|
-
>
|
|
21
|
-
> Unlike true cloud photo services,
|
|
22
|
-
> this approach works by keeping a full copy of your photo library
|
|
23
|
-
> in local storage on each device you sync to.
|
|
24
|
-
|
|
25
|
-
### Photein
|
|
26
|
-
|
|
27
9
|
Photein manages your photos **at the filesystem level**.
|
|
28
10
|
It won’t let you browse or edit your photos,
|
|
29
11
|
but it will give them a uniform folder structure and filenames,
|
|
@@ -55,51 +37,29 @@ based on metadata timestamps, filename timestamps, or file creation times.
|
|
|
55
37
|
> ⚠️ **Note**
|
|
56
38
|
>
|
|
57
39
|
> If you use a photo management app that decides
|
|
58
|
-
> where and how your photos should be stored on
|
|
59
|
-
> Photein is not for you.
|
|
60
|
-
|
|
61
|
-
### Xferase
|
|
62
|
-
|
|
63
|
-
Xferase watches a directory of your choosing (its “inbox”),
|
|
64
|
-
and whenever any files are placed there,
|
|
65
|
-
it automatically imports them into your photo library.
|
|
66
|
-
|
|
67
|
-
It creates and manages two parallel copies of your library
|
|
68
|
-
(one original/hi-res, one optimized for web)
|
|
69
|
-
and ensures that when you delete a photo from one,
|
|
70
|
-
it is automatically removed from the other.
|
|
40
|
+
> where and how your photos should be stored on disk
|
|
41
|
+
> (👀 looking at you, Apple Photos), Photein is not for you.
|
|
71
42
|
|
|
72
|
-
|
|
73
|
-
you can automatically pull new photos from your camera or Android phone
|
|
74
|
-
into Xferase’s inbox.
|
|
75
|
-
Syncthing can also push your complete, web-optimized photo library
|
|
76
|
-
back to your phone (or, say, push your hi-res library out to another machine).
|
|
43
|
+
It can also optimize photos and videos for reduced file size.
|
|
77
44
|
|
|
78
|
-
|
|
45
|
+
What _doesn’t_ it do?
|
|
46
|
+
---------------------
|
|
79
47
|
|
|
80
|
-
|
|
81
|
-
|
|
48
|
+
On its own, Photein is **not** an alternative
|
|
49
|
+
to cloud photo services like Google Photos or iCloud—but
|
|
50
|
+
in combination with other software, it can be.
|
|
82
51
|
|
|
83
|
-
|
|
52
|
+
If you want to:
|
|
84
53
|
|
|
85
|
-
|
|
54
|
+
* import photos from your phone as soon as you take them
|
|
55
|
+
* import photos from a digital camera / SD card as soon as you plug it in
|
|
56
|
+
* mirror a low-res copy of your entire photo library to your Android phone
|
|
86
57
|
|
|
87
|
-
|
|
58
|
+
check out Photein’s sister utility [Xferase][],
|
|
59
|
+
or try the [automation guides][] below.
|
|
88
60
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
(I want to be able to access my photos from the file manager,
|
|
92
|
-
find them in an “Open...” dialog,
|
|
93
|
-
or sync them to other devices with generic tools
|
|
94
|
-
like rsync, Dropbox, or Syncthing.)
|
|
95
|
-
|
|
96
|
-
3. comes with **no recurring subscription fee**—or better yet, is FOSS
|
|
97
|
-
|
|
98
|
-
(My digital photo/video library belongs to me,
|
|
99
|
-
but if I don’t control the pipeline for viewing/managing it,
|
|
100
|
-
then does it really?)
|
|
101
|
-
|
|
102
|
-
4. works with Linux
|
|
61
|
+
[Xferase]: https://github.com/rlue/xferase
|
|
62
|
+
[automation guides]: #automation-guides
|
|
103
63
|
|
|
104
64
|
Installation
|
|
105
65
|
------------
|
|
@@ -110,7 +70,7 @@ $ gem install photein
|
|
|
110
70
|
|
|
111
71
|
### Dependencies
|
|
112
72
|
|
|
113
|
-
* Ruby 2.
|
|
73
|
+
* Ruby 2.6+
|
|
114
74
|
* [ExifTool][]
|
|
115
75
|
* [MediaInfo][]
|
|
116
76
|
* ImageMagick (for `--optimize-for=web` option)
|
|
@@ -123,8 +83,6 @@ $ gem install photein
|
|
|
123
83
|
Usage
|
|
124
84
|
-----
|
|
125
85
|
|
|
126
|
-
### Simple import
|
|
127
|
-
|
|
128
86
|
```sh
|
|
129
87
|
$ photein \
|
|
130
88
|
--source /media/ricoh_gr/DCIM \ # batch-import photos from here
|
|
@@ -145,9 +103,17 @@ Use `photein --help` for a summary of all options.
|
|
|
145
103
|
|
|
146
104
|
### Automation guides
|
|
147
105
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
* [
|
|
106
|
+
Using Photein + systemd, you can:
|
|
107
|
+
|
|
108
|
+
* [📷➡️🖥️ Set up auto-import from a digital camera](guides/auto-import-digital-camera.md)
|
|
109
|
+
|
|
110
|
+
But for more complex tasks, like:
|
|
111
|
+
|
|
112
|
+
* 📱➡️🖥️ Setting up auto-import from an Android phone
|
|
113
|
+
* 📱🔄🖥️ Mirroring your library across multiple devices
|
|
114
|
+
|
|
115
|
+
check out the documentation for [Xferase][],
|
|
116
|
+
an always-on background service based on Photein.
|
|
151
117
|
|
|
152
118
|
Development
|
|
153
119
|
-----------
|
data/lib/photein/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: photein
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ryan Lue
|
|
@@ -154,13 +154,11 @@ description: ''
|
|
|
154
154
|
email: hello@ryanlue.com
|
|
155
155
|
executables:
|
|
156
156
|
- photein
|
|
157
|
-
- xferase
|
|
158
157
|
extensions: []
|
|
159
158
|
extra_rdoc_files: []
|
|
160
159
|
files:
|
|
161
160
|
- README.md
|
|
162
161
|
- bin/photein
|
|
163
|
-
- bin/xferase
|
|
164
162
|
- lib/photein.rb
|
|
165
163
|
- lib/photein/config.rb
|
|
166
164
|
- lib/photein/image.rb
|
data/bin/xferase
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env ruby
|
|
2
|
-
# frozen_string_literal: true
|
|
3
|
-
|
|
4
|
-
# TODO: Explain yourself
|
|
5
|
-
|
|
6
|
-
# Parse opts -------------------------------------------------------------------
|
|
7
|
-
require 'photein'
|
|
8
|
-
require 'optparse'
|
|
9
|
-
|
|
10
|
-
parser = OptionParser.new do |opts|
|
|
11
|
-
opts.version = Photein::VERSION
|
|
12
|
-
opts.banner = <<~BANNER
|
|
13
|
-
Usage: xferase [--version] [-h | --help] [<args>]
|
|
14
|
-
BANNER
|
|
15
|
-
|
|
16
|
-
opts.on(
|
|
17
|
-
'-i INBOX', '--inbox=INBOX',
|
|
18
|
-
'path to the inbox'
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
opts.on(
|
|
22
|
-
'-s STAGING', '--staging=STAGING',
|
|
23
|
-
'path to the staging directory'
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
opts.on(
|
|
27
|
-
'-o LIB_ORIG', '--lib-orig=LIB_ORIG',
|
|
28
|
-
'path to the original/hi-res library'
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
opts.on(
|
|
32
|
-
'-w LIB_WEB', '--lib-web=LIB_WEB',
|
|
33
|
-
'path to the web-optimized library'
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
opts.on(
|
|
37
|
-
'-d INTERVAL', '--debounce=INTERVAL',
|
|
38
|
-
'wait n seconds for additional files before import'
|
|
39
|
-
)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
begin
|
|
43
|
-
params = {}
|
|
44
|
-
parser.parse!(into: params)
|
|
45
|
-
params.freeze
|
|
46
|
-
|
|
47
|
-
%i[inbox staging lib-orig lib-web].each do |type|
|
|
48
|
-
raise "no #{type} directory given" if !params.key?(type)
|
|
49
|
-
end
|
|
50
|
-
rescue => e
|
|
51
|
-
warn(e.message)
|
|
52
|
-
warn(parser.help) if e.is_a?(OptionParser::ParseError)
|
|
53
|
-
exit 1
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Config -----------------------------------------------------------------------
|
|
57
|
-
require 'logger'
|
|
58
|
-
|
|
59
|
-
DEBOUNCE_WAIT = params.fetch(:'debounce') { 60 }.to_i # seconds
|
|
60
|
-
SRC_DEST_MAP = {
|
|
61
|
-
"#{params.fetch(:staging)}/desktop" => params.fetch(:'lib-orig'),
|
|
62
|
-
"#{params.fetch(:staging)}/web" => params.fetch(:'lib-web'),
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
IMPORT_CMD = %(
|
|
66
|
-
photein \
|
|
67
|
-
--verbose \
|
|
68
|
-
--source %s \
|
|
69
|
-
--dest %s \
|
|
70
|
-
--optimize-for %s
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
FORMAT_GROUPS = [%w(.jpg .dng .heic), %w(.mov .mp4), %w(.png)]
|
|
74
|
-
|
|
75
|
-
LOGGER = Logger.new($stdout).tap(&:info!)
|
|
76
|
-
|
|
77
|
-
# Setup ------------------------------------------------------------------------
|
|
78
|
-
require 'debouncer'
|
|
79
|
-
require 'fileutils'
|
|
80
|
-
require 'pathname'
|
|
81
|
-
require 'rb-inotify'
|
|
82
|
-
require 'tmpdir'
|
|
83
|
-
|
|
84
|
-
Thread.abort_on_exception = true
|
|
85
|
-
SRC_DEST_MAP.keys.each(&FileUtils.method(:mkdir_p))
|
|
86
|
-
|
|
87
|
-
mutex = Mutex.new
|
|
88
|
-
debouncer = Debouncer.new(DEBOUNCE_WAIT) do |*files|
|
|
89
|
-
SRC_DEST_MAP.keys.each { |dir| FileUtils.ln(files, dir) }
|
|
90
|
-
FileUtils.rm(files + Dir["#{params.fetch(:staging)}/web/*.DNG"]) # FIXME (ugly hack)
|
|
91
|
-
|
|
92
|
-
break if debouncer.inspect_params[:threads] > 2 # don't let threads pile up
|
|
93
|
-
|
|
94
|
-
mutex.synchronize do
|
|
95
|
-
SRC_DEST_MAP.each do |src, dest|
|
|
96
|
-
system(IMPORT_CMD % [src, dest, src.split('/').last])
|
|
97
|
-
end
|
|
98
|
-
rescue => e
|
|
99
|
-
warn e.message
|
|
100
|
-
end
|
|
101
|
-
end.reducer(:+)
|
|
102
|
-
|
|
103
|
-
# Resume from interruption/failure ---------------------------------------------
|
|
104
|
-
debouncer.call unless SRC_DEST_MAP.keys.all?(&Dir.method(:empty?))
|
|
105
|
-
|
|
106
|
-
Dir["#{params.fetch(:inbox)}/**/*"]
|
|
107
|
-
.select(&File.method(:file?))
|
|
108
|
-
.each { |path| debouncer.call(path) }
|
|
109
|
-
|
|
110
|
-
# Start ------------------------------------------------------------------------
|
|
111
|
-
Thread.new do
|
|
112
|
-
call_debouncer = ->(event) do
|
|
113
|
-
debouncer.call(event.absolute_name)
|
|
114
|
-
rescue => e
|
|
115
|
-
warn e.message
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
import_notifier = INotify::Notifier.new
|
|
119
|
-
import_notifier.watch(params.fetch(:inbox), :close_write, &call_debouncer)
|
|
120
|
-
|
|
121
|
-
# NOTE: inotify is not recursive,
|
|
122
|
-
# so subdirectories must be watched separately!
|
|
123
|
-
# (Why do Syncthing folders get special treatment?
|
|
124
|
-
# Because ST works by creating hidden tempfiles and moving them upon completion)
|
|
125
|
-
stfolders, simple_subdirs = Dir["#{params.fetch(:inbox)}/**/*"]
|
|
126
|
-
.select(&File.method(:directory?))
|
|
127
|
-
.partition { |dir| Dir.children(dir).include?('.stfolder') }
|
|
128
|
-
|
|
129
|
-
simple_subdirs.each { |dir| import_notifier.watch(dir, :close_write, &call_debouncer) }
|
|
130
|
-
stfolders.each { |dir| import_notifier.watch(dir, :moved_to, &call_debouncer) }
|
|
131
|
-
|
|
132
|
-
import_notifier.run
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
Thread.new do
|
|
136
|
-
sync_deletions = ->(event) do
|
|
137
|
-
LOGGER.info("#{event.name} has disappeared!")
|
|
138
|
-
|
|
139
|
-
deleted_file = Pathname(event.absolute_name).expand_path
|
|
140
|
-
|
|
141
|
-
sister_file = if deleted_file.to_s.start_with?(File.expand_path(params['LIB_ORIG']))
|
|
142
|
-
deleted_file.sub(*params.values_at('LIB_ORIG', 'LIB_WEB'))
|
|
143
|
-
else
|
|
144
|
-
deleted_file.sub(*params.values_at('LIB_WEB', 'LIB_ORIG'))
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
sister_formats = FORMAT_GROUPS.find { |group| group.include?(deleted_file.extname) }
|
|
148
|
-
|
|
149
|
-
related_files = [deleted_file, sister_file]
|
|
150
|
-
.product(sister_formats)
|
|
151
|
-
.map { |file, ext| file.sub_ext(ext) }
|
|
152
|
-
.select(&:file?)
|
|
153
|
-
|
|
154
|
-
# (Why mv to tmpdir first? Why not rm straight away?
|
|
155
|
-
# Because rm would recursively trigger this inotify callback.)
|
|
156
|
-
related_files.each { |f| LOGGER.info("deleting #{f.realpath}") }
|
|
157
|
-
FileUtils.mv(related_files, Dir.tmpdir)
|
|
158
|
-
FileUtils.rm(related_files.map { |f| File.join(Dir.tmpdir, f.basename) })
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
deletion_notifier = INotify::Notifier.new
|
|
162
|
-
|
|
163
|
-
# NOTE: inotify is not recursive,
|
|
164
|
-
# so subdirectories must be watched separately!
|
|
165
|
-
(Dir["#{params.fetch(:'lib-orig')}/**/*"] + Dir["#{params.fetch(:'lib-web')}/**/*"])
|
|
166
|
-
.select(&File.method(:directory?))
|
|
167
|
-
.each { |dir| deletion_notifier.watch(dir, :delete, &sync_deletions) }
|
|
168
|
-
|
|
169
|
-
deletion_notifier.run
|
|
170
|
-
end.join
|