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.
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