photein 0.0.8 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8005c4c7e035db2675a5f6cdcc0a0567b53aec6117b279d1242c4dae8d671758
4
- data.tar.gz: 9d3ddc4eaf083903e45eaa55de9c93bd97c2201777bc676ee815436b427c0641
3
+ metadata.gz: 808d8afeddb75b1ec710f2335b5e1f51d9d6ffec91b1cb5a2cd2c1d8950486ca
4
+ data.tar.gz: b45b43586500db69c52fcc2a046063b90e1e375dd4d5ef41a042eef0a2a5a984
5
5
  SHA512:
6
- metadata.gz: 683fd6aa36b788f9e985210e7ae2fc97474b3d155afa04db1d8e1a87c1892b68d9a262e93798b0b3449b83285809e6035c3a82bb4955f1dd3f02f01768173a4f
7
- data.tar.gz: 74cfbc6831fb2fbdb88a09dd0d3412ceac70ddc62379bf37e3bc800e442433b9b13d112bdff358cf04276b5ed6d59dc8804ef0fef757d223dcb466ebc726a627
6
+ metadata.gz: d9c2de4bf1b3e0433dbee191e44ef80c2d910a1416c02ccc0bd44fda3acc583282b2a07f2745a1c2345d39a83d055c270604858163b83c39ba7e8e519ae28c2b
7
+ data.tar.gz: bde27862fbeb2b281cb23f8504bb16fe603ff59ed4401765128566b46e024a3afc66684f88ff07c4c0932de08f83fde35d7b5e3f03c1665c799e0733959cb60f
data/bin/photein CHANGED
@@ -5,26 +5,22 @@ require 'photein'
5
5
  require 'pathname'
6
6
 
7
7
  Photein::Config.parse_opts!
8
- Photein::Logger.open
8
+ Photein.logger.open
9
9
 
10
10
  # Setup ------------------------------------------------------------------------
11
11
 
12
- SRC_DIR = Pathname(Photein::Config.source)
13
- DEST_DIR = Pathname(Photein::Config.dest)
14
-
15
12
  begin
16
- raise "#{Photein::Config.source}: no such directory" unless SRC_DIR.exist?
17
- raise "#{Photein::Config.dest}: no such directory" unless DEST_DIR.exist?
18
- raise "#{Photein::Config.source}: no photos or videos found" if Dir.empty?(SRC_DIR)
13
+ raise "#{Photein::Config.source}: no such directory" unless Photein::Config.source.exist?
14
+ raise "#{Photein::Config.source}: no photos or videos found" if Dir.empty?(Photein::Config.source)
19
15
  rescue => e
20
- Photein::Logger.fatal(e.message)
16
+ Photein.logger.fatal(e.message)
21
17
  exit 1
22
18
  end
23
19
 
24
20
  # Cleanup ----------------------------------------------------------------------
25
21
  at_exit do
26
22
  unless Photein::Config.keep
27
- Dir[SRC_DIR.join('**/')].sort
23
+ Dir[Photein::Config.source.join('**/')].sort
28
24
  .drop(1)
29
25
  .reverse_each { |d| Dir.rmdir(d) if Dir.empty?(d) }
30
26
  end
@@ -8,15 +8,16 @@ module Photein
8
8
  include Singleton
9
9
 
10
10
  OPTIONS = [
11
- ['-v', '--verbose', 'print verbose output'],
12
- ['-s SOURCE', '--source=SOURCE', 'specify the source directory'],
13
- ['-d DESTINATION', '--dest=DESTINATION', 'specify the destination directory'],
14
- ['-r', '--recursive', 'ingest source files recursively'],
15
- ['-k', '--keep', 'do not delete source files'],
16
- ['-i', '--interactive', 'ask whether to import each file found'],
17
- ['-n', '--dry-run', 'perform a "no-op" trial run'],
18
- [ '--safe', 'skip files in use by other processes'],
19
- [ '--optimize-for=TARGET', %i[desktop web], 'compress images/video before importing']
11
+ ['-v', '--verbose', 'print verbose output'],
12
+ ['-s SOURCE', '--source=SOURCE', 'path to the source directory'],
13
+ ['-m MASTER', '--library-master=MASTER', 'path to a destination directory (master)'],
14
+ ['-d DESKTOP', '--library-desktop=DESKTOP', 'path to a destination directory (desktop-optimized)'],
15
+ ['-w WEB', '--library-web=WEB', 'path to a destination directory (web-optimized)'],
16
+ ['-r', '--recursive', 'ingest source files recursively'],
17
+ ['-k', '--keep', 'do not delete source files'],
18
+ ['-i', '--interactive', 'ask whether to import each file found'],
19
+ ['-n', '--dry-run', 'perform a "no-op" trial run'],
20
+ [ '--safe', 'skip files in use by other processes'],
20
21
  ].freeze
21
22
 
22
23
  OPTION_NAMES = OPTIONS
@@ -46,7 +47,8 @@ module Photein
46
47
  @params.freeze
47
48
 
48
49
  raise "no source directory given" if !@params.key?(:source)
49
- raise "no destination directory given" if !@params.key?(:dest)
50
+ (%i[library-master library-desktop library-web] & @params.keys)
51
+ .then { |dest_dirs| raise "no destination directory given" if dest_dirs.empty? }
50
52
  rescue => e
51
53
  warn("#{parser.program_name}: #{e.message}")
52
54
  warn(parser.help) if e.is_a?(OptionParser::ParseError)
@@ -65,6 +67,18 @@ module Photein
65
67
  def respond_to_missing?(m, *args)
66
68
  @params.key?(m.to_s.tr('_', '-').to_sym) || super
67
69
  end
70
+
71
+ def source
72
+ @source ||= Pathname(@params[:source])
73
+ end
74
+
75
+ def destinations
76
+ @destinations ||= {
77
+ master: @params[:'library-master'],
78
+ desktop: @params[:'library-desktop'],
79
+ web: @params[:'library-web']
80
+ }.compact.transform_values(&Pathname.method(:new))
81
+ end
68
82
  end
69
83
  end
70
84
  end
data/lib/photein/image.rb CHANGED
@@ -22,14 +22,14 @@ module Photein
22
22
  }.freeze
23
23
  MAX_RES_WEB = 2097152 # 2MP
24
24
 
25
- def optimize
26
- return if Photein::Config.optimize_for == :desktop
25
+ def optimize(tempfile:, lib_type:)
26
+ return unless lib_type == :web
27
27
 
28
28
  case extname
29
29
  when '.jpg', '.heic'
30
30
  return false if image.dimensions.reduce(&:*) < MAX_RES_WEB
31
31
 
32
- Photein::Logger.info "optimizing #{path}"
32
+ Photein.logger.info "optimizing #{path}"
33
33
  MiniMagick::Tool::Convert.new do |convert|
34
34
  convert << path
35
35
  convert.colorspace('sRGB')
@@ -42,11 +42,11 @@ module Photein
42
42
  end unless Photein::Config.dry_run
43
43
  when '.png'
44
44
  FileUtils.cp(path, tempfile, noop: Photein::Config.dry_run)
45
- Photein::Logger.info "optimizing #{path}"
45
+ Photein.logger.info "optimizing #{path}"
46
46
  begin
47
47
  Optipng.optimize(tempfile, level: 4) unless Photein::Config.dry_run
48
48
  rescue Errno::ENOENT
49
- Photein::Logger.error('optipng is required to compress PNG images')
49
+ Photein.logger.error('optipng is required to compress PNG images')
50
50
  raise
51
51
  end
52
52
  end
@@ -57,7 +57,7 @@ module Photein
57
57
  def image
58
58
  @image ||= MiniMagick::Image.open(path)
59
59
  rescue MiniMagick::Invalid => e
60
- Photein::Logger.error(<<~MSG) if e.message.match?(/You must have ImageMagick/)
60
+ Photein.logger.error(<<~MSG) if e.message.match?(/You must have ImageMagick/)
61
61
  ImageMagick is required to manipulate image files
62
62
  MSG
63
63
  raise
@@ -66,7 +66,7 @@ module Photein
66
66
  def metadata_stamp
67
67
  MiniExiftool.new(path.to_s).date_time_original
68
68
  rescue MiniExiftool::Error => e
69
- Photein::Logger.error(<<~MSG) if e.message.match?(/exiftool: not found/)
69
+ Photein.logger.error(<<~MSG) if e.message.match?(/exiftool: not found/)
70
70
  exiftool is required to read timestamp metadata
71
71
  MSG
72
72
  raise
@@ -95,10 +95,8 @@ module Photein
95
95
  end
96
96
  end
97
97
 
98
- def non_optimizable_format?
99
- return false if !Photein::Config.optimize_for
100
- return false if Photein::Config.optimize_for == :desktop
101
- return true if extname == '.dng'
98
+ def non_optimizable_format?(lib_type)
99
+ return true if lib_type == :web && extname == '.dng'
102
100
 
103
101
  return false
104
102
  end
@@ -4,6 +4,14 @@ require 'logger'
4
4
  require 'singleton'
5
5
 
6
6
  module Photein
7
+ class << self
8
+ attr_writer :logger
9
+
10
+ def logger
11
+ @logger ||= Photein::Logger
12
+ end
13
+ end
14
+
7
15
  class Logger
8
16
  include Singleton
9
17
 
@@ -23,22 +23,38 @@ module Photein
23
23
  return if corrupted?
24
24
  return if Photein::Config.interactive && denied_by_user?
25
25
  return if Photein::Config.safe && in_use?
26
- return if Photein::Config.optimize_for && non_optimizable_format?
27
26
 
28
- FileUtils.mkdir_p(parent_dir, noop: Photein::Config.dry_run)
29
-
30
- optimize if Photein::Config.optimize_for
31
-
32
- Photein::Logger.info(<<~MSG.chomp)
33
- #{Photein::Config.keep ? 'copying' : 'moving'} #{path.basename} to #{dest_path}
34
- MSG
35
-
36
- if File.exist?(tempfile)
37
- FileUtils.mv(tempfile, dest_path, noop: Photein::Config.dry_run)
38
- else
39
- FileUtils.cp(path, dest_path, noop: Photein::Config.dry_run)
40
- FileUtils.chmod('-x', dest_path, noop: Photein::Config.dry_run)
41
- end
27
+ Photein::Config.destinations.map do |lib_type, lib_path|
28
+ next if non_optimizable_format?(lib_type)
29
+
30
+ Thread.new do
31
+ dest_basename = timestamp.strftime(DATE_FORMAT)
32
+ dest_extname = self.class::OPTIMIZATION_FORMAT_MAP.dig(lib_type, extname) || extname
33
+ dest_path = lib_path
34
+ .join(timestamp.strftime('%Y'))
35
+ .join("#{dest_basename}#{dest_extname}")
36
+ .then(&method(:resolve_name_collision))
37
+ tempfile = Pathname(Dir.tmpdir)
38
+ .join('photein').join(lib_type.to_s)
39
+ .tap(&FileUtils.method(:mkdir_p))
40
+ .join(dest_path.basename)
41
+
42
+ optimize(tempfile: tempfile, lib_type: lib_type)
43
+
44
+ Photein.logger.info(<<~MSG.chomp)
45
+ #{Photein::Config.keep ? 'copying' : 'moving'} #{path.basename} to #{dest_path}
46
+ MSG
47
+
48
+ FileUtils.mkdir_p(dest_path.dirname, noop: Photein::Config.dry_run)
49
+
50
+ if File.exist?(tempfile)
51
+ FileUtils.mv(tempfile, dest_path, noop: Photein::Config.dry_run)
52
+ else
53
+ FileUtils.cp(path, dest_path, noop: Photein::Config.dry_run)
54
+ FileUtils.chmod('-x', dest_path, noop: Photein::Config.dry_run)
55
+ end
56
+ end
57
+ end.each(&:join)
42
58
 
43
59
  FileUtils.rm(path, noop: Photein::Config.dry_run || Photein::Config.keep)
44
60
  end
@@ -47,7 +63,7 @@ module Photein
47
63
 
48
64
  def corrupted?(result = false)
49
65
  return result.tap do |r|
50
- Photein::Logger.error("#{path.basename}: cannot import corrupted file") if r
66
+ Photein.logger.error("#{path.basename}: cannot import corrupted file") if r
51
67
  end
52
68
  end
53
69
 
@@ -61,36 +77,17 @@ module Photein
61
77
 
62
78
  if status.success? # Do open files ALWAYS return exit status 0? (I think so.)
63
79
  cmd, pid = out.lines[1]&.split&.first(2)
64
- Photein::Logger.fatal("skipping #{path}: file in use by #{cmd} (PID #{pid})")
80
+ Photein.logger.fatal("skipping #{path}: file in use by #{cmd} (PID #{pid})")
65
81
  return true
66
82
  else
67
83
  return false
68
84
  end
69
85
  end
70
86
 
71
- def non_optimizable_format? # may be overridden by subclasses
87
+ def non_optimizable_format?(lib_type = :master) # may be overridden by subclasses
72
88
  return false
73
89
  end
74
90
 
75
- def parent_dir
76
- Pathname(Photein::Config.dest).join(timestamp.strftime('%Y'))
77
- end
78
-
79
- def tempfile
80
- Pathname(Dir.tmpdir).join('photein')
81
- .tap(&FileUtils.method(:mkdir_p))
82
- .join(dest_path.basename)
83
- end
84
-
85
- def dest_path
86
- @dest_path ||= begin
87
- base_path = parent_dir.join("#{timestamp.strftime(DATE_FORMAT)}#{dest_extname}")
88
- counter = resolve_name_collision(base_path.sub_ext("*#{dest_extname}"))
89
-
90
- base_path.sub_ext("#{counter}#{dest_extname}")
91
- end
92
- end
93
-
94
91
  def timestamp
95
92
  @timestamp ||= (metadata_stamp || filename_stamp)
96
93
  end
@@ -105,29 +102,40 @@ module Photein
105
102
  end
106
103
  end
107
104
 
108
- def dest_extname
109
- self.class::OPTIMIZATION_FORMAT_MAP
110
- .dig(Photein::Config.optimize_for, extname) || extname
111
- end
112
-
113
105
  def extname
114
106
  @extname ||= NORMAL_EXTNAME_MAP[path.extname.downcase] || path.extname.downcase
115
107
  end
116
108
 
117
- def resolve_name_collision(collision_glob)
109
+ def resolve_name_collision(filename)
110
+ raise ArgumentError, 'Invalid filename' if filename.to_s.include?('*')
111
+
112
+ collision_glob = Pathname(filename).sub_ext("*#{filename.extname}")
113
+
118
114
  case Dir[collision_glob].length
119
115
  when 0 # if no files found, no biggie
120
116
  when 1 # if one file found, WITH OR WITHOUT COUNTER, reset counter to a
121
117
  if Dir[collision_glob].first != collision_glob.sub('*', 'a') # don't try if it's already a lone, correctly-countered file
122
- Photein::Logger.info('conflicting timestamp found; adding counter to existing file')
118
+ Photein.logger.info('conflicting timestamp found; adding counter to existing file')
123
119
  FileUtils.mv(Dir[collision_glob].first, collision_glob.sub('*', 'a'), noop: Photein::Config.dry_run)
124
120
  end
125
121
  else # TODO: if multiple files found, rectify them?
126
122
  end
127
123
 
128
- # return the next usable counter
124
+ # return the next usable filename
129
125
  Dir[collision_glob].max&.slice(/.(?=#{Regexp.escape(collision_glob.extname)})/)&.next
130
126
  .tap { |counter| raise 'Unresolved timestamp conflict' unless [*Array('a'..'z'), nil].include?(counter) }
127
+ .then { |counter| filename.sub_ext("#{counter}#{filename.extname}") }
128
+ end
129
+
130
+ class << self
131
+ def for(file)
132
+ file = Pathname(file)
133
+ raise Errno::ENOENT, "#{file}" unless file.exist?
134
+
135
+ [Image, Video].find { |type| type::SUPPORTED_FORMATS.include?(file.extname) }
136
+ .tap { |type| raise ArgumentError, "#{file}: Invalid media file" if type.nil? }
137
+ .then { |type| type.new(file) }
138
+ end
131
139
  end
132
140
  end
133
141
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Photein
4
- VERSION = '0.0.8'
4
+ VERSION = '0.1.2'
5
5
  end
data/lib/photein/video.rb CHANGED
@@ -33,10 +33,11 @@ module Photein
33
33
  web: '35',
34
34
  }.freeze
35
35
 
36
- def optimize
37
- return if video.bitrate < BITRATE_THRESHOLD[Photein::Config.optimize_for]
36
+ def optimize(tempfile:, lib_type:)
37
+ return if lib_type == :master
38
+ return if video.bitrate < BITRATE_THRESHOLD[lib_type]
38
39
 
39
- Photein::Logger.info("transcoding #{tempfile}")
40
+ Photein.logger.info("transcoding #{tempfile}")
40
41
  return if Photein::Config.dry_run
41
42
 
42
43
  video.transcode(
@@ -45,7 +46,7 @@ module Photein
45
46
  '-map_metadata', '0', # https://video.stackexchange.com/a/26076
46
47
  '-movflags', 'use_metadata_tags',
47
48
  '-c:v', 'libx264',
48
- '-crf', TARGET_CRF[Photein::Config.optimize_for],
49
+ '-crf', TARGET_CRF[lib_type],
49
50
  ],
50
51
  &method(:display_progress_bar)
51
52
  )
@@ -60,7 +61,7 @@ module Photein
60
61
  def video
61
62
  @video ||= FFMPEG::Movie.new(path.to_s)
62
63
  rescue Errno::ENOENT
63
- Photein::Logger.error('ffmpeg is required to manipulate video files')
64
+ Photein.logger.error('ffmpeg is required to manipulate video files')
64
65
  raise
65
66
  end
66
67
 
@@ -68,7 +69,7 @@ module Photein
68
69
  # video timestamps are typically UTC
69
70
  MediaInfo.from(path.to_s).general.encoded_date&.getlocal
70
71
  rescue MediaInfo::EnvironmentError
71
- Photein::Logger.error('mediainfo is required to read timestamp metadata')
72
+ Photein.logger.error('mediainfo is required to read timestamp metadata')
72
73
  raise
73
74
  end
74
75
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: photein
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.8
4
+ version: 0.1.2
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-12-06 00:00:00.000000000 Z
11
+ date: 2021-12-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mediainfo