photein 0.0.2 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (5) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +33 -19
  3. data/bin/xferase +170 -0
  4. data/lib/photein/version.rb +1 -1
  5. metadata +35 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8358c084cdf56bab539d9f429706a606cb346b33e7832e056054b2a4ead67e08
4
- data.tar.gz: c9e77f835d2ba2d39a3edb08e5fc6973a1871b82c52a823e3bf15a446c263f80
3
+ metadata.gz: 852660eba8fefe43e1b18967162a149c13f35f85e811f08089403da3f2dd0652
4
+ data.tar.gz: 8a6af0e7ce270985f6a427dfc509be093d884baedccd33ac778611285fedf71a
5
5
  SHA512:
6
- metadata.gz: '08be8726129d9b2c2ffcfe4f9ba206db82619c6b6cd1f8f50339d7390988a3c224c9ecb69da7dedc6edfee5e0c479f6ea4119551af75716039e55eda4635cdc7'
7
- data.tar.gz: a91a5a3be62081f8a25333f323a1ed98f2e34421134878679d5af8f3fce674c8e7ef8e1a299d4caa3afd9364af38fbe6ef8136045bfe15ac614afd9cf4d2f9ba
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 does it do?
6
+ What do they do?
7
7
  ----------------
8
8
 
9
- This repo provides two related programs: `photein` and `xferase`.
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 is a CLI utility for managing your photos **at the filesystem level**.
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 is a background service built on top of photein.
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
- When combined with other software,
57
- Xferase can be used as a self-hosted / DIY alternative
58
- to cloud photo services like Google Photos or iCloud.
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 like Dropbox or Syncthing.)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Photein
4
- VERSION = '0.0.2'
4
+ VERSION = '0.0.4'
5
5
  end
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.2
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-25 00:00:00.000000000 Z
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: nokogiri
98
+ name: rb-inotify
71
99
  requirement: !ruby/object:Gem::Requirement
72
100
  requirements:
73
101
  - - "~>"
74
102
  - !ruby/object:Gem::Version
75
- version: '1.11'
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: '1.11'
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