photein 0.0.2 → 0.0.4
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 +33 -19
- data/bin/xferase +170 -0
- data/lib/photein/version.rb +1 -1
- metadata +35 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 852660eba8fefe43e1b18967162a149c13f35f85e811f08089403da3f2dd0652
|
4
|
+
data.tar.gz: 8a6af0e7ce270985f6a427dfc509be093d884baedccd33ac778611285fedf71a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f7fc315331d2de5da4df3fb5aacfd7a58220f3581e3753a2c01802e428432e57ab89ac7d66c44bbecfbd8aae7f2e8966e6a18c1857ed26b68b0e2dc046bbf780
|
7
|
+
data.tar.gz: 27f432d3dae62069f2197051e523da3db8e3743467ce2dd17be4e7788720ef74090beb345ee98072d1df2cffe86fb7be22bde32d8d4460048eb7603f92537dbe
|
data/README.md
CHANGED
@@ -1,16 +1,30 @@
|
|
1
|
-
Ph📸tein
|
2
|
-
|
1
|
+
Ph📸tein / 🔀ferase
|
2
|
+
===================
|
3
3
|
|
4
4
|
All your photos under one roof.
|
5
5
|
|
6
|
-
What
|
6
|
+
What do they do?
|
7
7
|
----------------
|
8
8
|
|
9
|
-
|
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.
|
10
24
|
|
11
25
|
### Photein
|
12
26
|
|
13
|
-
Photein
|
27
|
+
Photein manages your photos **at the filesystem level**.
|
14
28
|
It won’t let you browse or edit your photos,
|
15
29
|
but it will give them a uniform folder structure and filenames,
|
16
30
|
no matter where they come from:
|
@@ -35,6 +49,9 @@ no matter where they come from:
|
|
35
49
|
└── VID_20210212_081939.mp4 └── 2021-04-28_000008.jpg
|
36
50
|
```
|
37
51
|
|
52
|
+
Photein generates these folders & filenames
|
53
|
+
based on metadata timestamps, filename timestamps, or file creation times.
|
54
|
+
|
38
55
|
> ⚠️ **Note**
|
39
56
|
>
|
40
57
|
> If you use a photo management app that decides
|
@@ -43,8 +60,7 @@ no matter where they come from:
|
|
43
60
|
|
44
61
|
### Xferase
|
45
62
|
|
46
|
-
Xferase
|
47
|
-
It watches a directory of your choosing,
|
63
|
+
Xferase watches a directory of your choosing (its “inbox”),
|
48
64
|
and whenever any files are placed there,
|
49
65
|
it automatically imports them into your photo library.
|
50
66
|
|
@@ -53,9 +69,13 @@ It creates and manages two parallel copies of your library
|
|
53
69
|
and ensures that when you delete a photo from one,
|
54
70
|
it is automatically removed from the other.
|
55
71
|
|
56
|
-
|
57
|
-
|
58
|
-
|
72
|
+
With the help of [Syncthing][] and systemd,
|
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).
|
77
|
+
|
78
|
+
[Syncthing]: https://syncthing.net/
|
59
79
|
|
60
80
|
Why?
|
61
81
|
----
|
@@ -70,7 +90,8 @@ I could not find any existing software product that:
|
|
70
90
|
|
71
91
|
(I want to be able to access my photos from the file manager,
|
72
92
|
find them in an “Open...” dialog,
|
73
|
-
or sync them to other devices with tools
|
93
|
+
or sync them to other devices with generic tools
|
94
|
+
like rsync, Dropbox, or Syncthing.)
|
74
95
|
|
75
96
|
3. comes with **no recurring subscription fee**—or better yet, is FOSS
|
76
97
|
|
@@ -80,12 +101,6 @@ I could not find any existing software product that:
|
|
80
101
|
|
81
102
|
4. works with Linux
|
82
103
|
|
83
|
-
> ⚠️ **Note**
|
84
|
-
>
|
85
|
-
> Strictly speaking, Photein does not handle requirement #1;
|
86
|
-
> for that, use Xferase in combination with other software,
|
87
|
-
> such as Syncthing or systemd.
|
88
|
-
|
89
104
|
Installation
|
90
105
|
------------
|
91
106
|
|
@@ -146,8 +161,7 @@ Contributions welcome.
|
|
146
161
|
> it defines expectations against the effects of `system('photein <args>')`.
|
147
162
|
>
|
148
163
|
> Because `Kernel#system` runs the given command in a subprocess,
|
149
|
-
> it prints to a different stdout than
|
150
|
-
> native Ruby code in a normal RSpec example.
|
164
|
+
> it prints to a different stdout than `rspec` itself.
|
151
165
|
> This makes test failures cumbersome to debug,
|
152
166
|
> because `puts` statements never appear in the test output,
|
153
167
|
> and `binding.pry` will cause the test to appear to hang
|
data/bin/xferase
ADDED
@@ -0,0 +1,170 @@
|
|
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
|
data/lib/photein/version.rb
CHANGED
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
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.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Ryan Lue
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2021-05-
|
11
|
+
date: 2021-05-28 00:00:00.000000000 Z
|
12
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'
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: mediainfo
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -52,6 +66,20 @@ dependencies:
|
|
52
66
|
- - "~>"
|
53
67
|
- !ruby/object:Gem::Version
|
54
68
|
version: '4.11'
|
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'
|
55
83
|
- !ruby/object:Gem::Dependency
|
56
84
|
name: optipng
|
57
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -67,19 +95,19 @@ dependencies:
|
|
67
95
|
- !ruby/object:Gem::Version
|
68
96
|
version: '0.2'
|
69
97
|
- !ruby/object:Gem::Dependency
|
70
|
-
name:
|
98
|
+
name: rb-inotify
|
71
99
|
requirement: !ruby/object:Gem::Requirement
|
72
100
|
requirements:
|
73
101
|
- - "~>"
|
74
102
|
- !ruby/object:Gem::Version
|
75
|
-
version: '
|
103
|
+
version: '0.10'
|
76
104
|
type: :runtime
|
77
105
|
prerelease: false
|
78
106
|
version_requirements: !ruby/object:Gem::Requirement
|
79
107
|
requirements:
|
80
108
|
- - "~>"
|
81
109
|
- !ruby/object:Gem::Version
|
82
|
-
version: '
|
110
|
+
version: '0.10'
|
83
111
|
- !ruby/object:Gem::Dependency
|
84
112
|
name: streamio-ffmpeg
|
85
113
|
requirement: !ruby/object:Gem::Requirement
|
@@ -126,11 +154,13 @@ description: ''
|
|
126
154
|
email: hello@ryanlue.com
|
127
155
|
executables:
|
128
156
|
- photein
|
157
|
+
- xferase
|
129
158
|
extensions: []
|
130
159
|
extra_rdoc_files: []
|
131
160
|
files:
|
132
161
|
- README.md
|
133
162
|
- bin/photein
|
163
|
+
- bin/xferase
|
134
164
|
- lib/photein.rb
|
135
165
|
- lib/photein/config.rb
|
136
166
|
- lib/photein/image.rb
|