photein 0.0.6 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5404ec50b0c5a57144fea72eff5639ca90a19a46be319cb8ee634d9f67291617
4
- data.tar.gz: bce672716a27ea28f9d4203e1858affa8793a11b5ca94e3642c6d86399969680
3
+ metadata.gz: 90719855955bcdf3585080664753c65528e9edaad99d2646498965c063ae0299
4
+ data.tar.gz: 874d2e1d44defc090968d10f4c000f84c7501d4f5d3a1bff6b2a2145297bf169
5
5
  SHA512:
6
- metadata.gz: f11aba53e3bcdef67a150a549da7c16c45e0350b4bbdcfb4f1d1ce4c0fd778185845ef4719857e9eea4b1c15894578ae4e1b361952da3961c77fb1b0cc56c0bb
7
- data.tar.gz: 2967ccf17b7d7631325ff9b18e445b052a52db8ddec3043cc0b4b15945b0b0c0bba52f0862044f554067950b578447235771abd1d97103346492ba6de821029a
6
+ metadata.gz: 9a70c02ce6b74cd0cf58f887d4bf6ba21bdf0df50651f6acd230aa0e6382d6bdf3669d637f4f112acdfc4c5a6d8417425cf7a87f0b28dd6df5383cd539fd339f
7
+ data.tar.gz: 0a69d825de1c6f7fb4d81add6db960d2a12793b46e1aa6bb277193511dea0a59bcde2b47b97313419a268b6f38e97cc99e5b75642ebef3cbb88a445d4e1de446
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
@@ -33,25 +29,4 @@ at_exit do
33
29
  end
34
30
 
35
31
  # Core Logic -------------------------------------------------------------------
36
- image_formats = Photein::Image::SUPPORTED_FORMATS
37
- .zip(Photein::Image::SUPPORTED_FORMATS.map(&:upcase))
38
- .flatten
39
-
40
- SRC_DIR
41
- .join(Photein::Config.recursive ? '**' : '')
42
- .join("*{#{image_formats.join(',')}}")
43
- .then { |glob| Dir[glob].sort }
44
- .map(&Photein::Image.method(:new))
45
- .each(&:import)
46
-
47
- # Video compression is time-consuming, so save it for last
48
- video_formats = Photein::Video::SUPPORTED_FORMATS
49
- .zip(Photein::Video::SUPPORTED_FORMATS.map(&:upcase))
50
- .flatten
51
-
52
- SRC_DIR
53
- .join(Photein::Config.recursive ? '**' : '')
54
- .join("*{#{video_formats.join(',')}}")
55
- .then { |glob| Dir[glob].sort }
56
- .map(&Photein::Video.method(:new))
57
- .each(&:import)
32
+ Photein.run
@@ -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
@@ -25,10 +26,14 @@ module Photein
25
26
  .map { |option| option[/\w[a-z\-]+/] }
26
27
  .map(&:to_sym)
27
28
 
29
+ @params = {}
30
+
28
31
  class << self
29
- def parse_opts!
30
- @params = {}
32
+ def set(**params)
33
+ @params.replace(params)
34
+ end
31
35
 
36
+ def parse_opts!
32
37
  parser = OptionParser.new do |opts|
33
38
  opts.version = Photein::VERSION
34
39
  opts.banner = <<~BANNER
@@ -42,7 +47,8 @@ module Photein
42
47
  @params.freeze
43
48
 
44
49
  raise "no source directory given" if !@params.key?(:source)
45
- 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? }
46
52
  rescue => e
47
53
  warn("#{parser.program_name}: #{e.message}")
48
54
  warn(parser.help) if e.is_a?(OptionParser::ParseError)
@@ -61,6 +67,18 @@ module Photein
61
67
  def respond_to_missing?(m, *args)
62
68
  @params.key?(m.to_s.tr('_', '-').to_sym) || super
63
69
  end
70
+
71
+ def source
72
+ @source ||= Pathname(@params[:source])
73
+ end
74
+
75
+ def destinations
76
+ @destinations ||= {
77
+ master: Pathname(@params[:'library-master']),
78
+ desktop: Pathname(@params[:'library-desktop']),
79
+ web: Pathname(@params[:'library-web'])
80
+ }.compact
81
+ end
64
82
  end
65
83
  end
66
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,29 @@ 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')
123
- FileUtils.mv(Dir[collision_glob].first, collision_glob.sub('*', 'a'))
118
+ Photein.logger.info('conflicting timestamp found; adding counter to existing file')
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}") }
131
128
  end
132
129
  end
133
130
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Photein
4
- VERSION = '0.0.6'
4
+ VERSION = '0.1.0'
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
 
data/lib/photein.rb CHANGED
@@ -8,4 +8,16 @@ require 'photein/image'
8
8
  require 'photein/video'
9
9
 
10
10
  module Photein
11
+ class << self
12
+ def run
13
+ [Photein::Image, Photein::Video].each do |media_type|
14
+ Pathname(Photein::Config.source)
15
+ .join(Photein::Config.recursive ? '**' : '')
16
+ .join("*{#{media_type::SUPPORTED_FORMATS.join(',')}}")
17
+ .then { |glob| Dir.glob(glob, File::FNM_CASEFOLD).sort }
18
+ .map(&media_type.method(:new))
19
+ .each(&:import)
20
+ end
21
+ end
22
+ end
11
23
  end
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.6
4
+ version: 0.1.0
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-07-23 00:00:00.000000000 Z
11
+ date: 2021-12-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mediainfo