metaclean 4.0.1 → 4.2.0
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 +96 -25
- data/SECURITY.md +59 -0
- data/bin/metaclean +0 -1
- data/lib/metaclean/cli.rb +46 -43
- data/lib/metaclean/committer.rb +130 -0
- data/lib/metaclean/discovery.rb +127 -0
- data/lib/metaclean/display.rb +73 -90
- data/lib/metaclean/exiftool.rb +36 -67
- data/lib/metaclean/ffmpeg.rb +96 -50
- data/lib/metaclean/file_ops.rb +214 -0
- data/lib/metaclean/mat2.rb +28 -77
- data/lib/metaclean/qpdf.rb +46 -46
- data/lib/metaclean/runner.rb +138 -418
- data/lib/metaclean/strategy.rb +75 -100
- data/lib/metaclean/version.rb +1 -5
- data/lib/metaclean.rb +87 -31
- metadata +7 -1
data/lib/metaclean/display.rb
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# All terminal output lives here: ANSI colors, headers, tables, the
|
|
4
|
-
# before/after diff. Colors are gated on `tty?` (see color?).
|
|
5
|
-
|
|
6
3
|
module Metaclean
|
|
7
4
|
module Display
|
|
8
5
|
COLORS = {
|
|
@@ -17,14 +14,8 @@ module Metaclean
|
|
|
17
14
|
gray: "\e[90m"
|
|
18
15
|
}.freeze
|
|
19
16
|
|
|
20
|
-
# ExifTool reports four "groups" that are descriptions of the file
|
|
21
|
-
# itself, not embedded metadata: System (filesystem stat), File (header
|
|
22
|
-
# info), ExifTool (its own version), Composite (computed values).
|
|
23
|
-
# Excluding these makes the diff focus on what actually got stripped.
|
|
24
17
|
NON_METADATA_GROUPS = %w[System File ExifTool Composite].freeze
|
|
25
18
|
|
|
26
|
-
# ASCII wordmark shown at the top of --help / --version. Printed by `banner`
|
|
27
|
-
# (see there for why it's colored line-by-line).
|
|
28
19
|
LOGO = <<~ART
|
|
29
20
|
███╗ ███╗███████╗████████╗ █████╗ ██████╗██╗ ███████╗ █████╗ ███╗ ██╗
|
|
30
21
|
████╗ ████║██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔════╝██╔══██╗████╗ ██║
|
|
@@ -36,21 +27,28 @@ module Metaclean
|
|
|
36
27
|
|
|
37
28
|
module_function
|
|
38
29
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
def configure(quiet: false, redact_values: false)
|
|
31
|
+
@quiet = quiet
|
|
32
|
+
@redact_values = redact_values
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def quiet?
|
|
36
|
+
@quiet == true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def redact_values?
|
|
40
|
+
@redact_values == true
|
|
41
|
+
end
|
|
42
|
+
|
|
42
43
|
def color?
|
|
43
44
|
return @color if defined?(@color)
|
|
44
45
|
|
|
45
|
-
# Per https://no-color.org: disable only when NO_COLOR is set to a
|
|
46
|
-
# non-empty value. An unset or empty NO_COLOR leaves colors on.
|
|
47
46
|
no_color = ENV['NO_COLOR'].to_s
|
|
48
47
|
@color = $stdout.tty? && no_color.empty?
|
|
49
48
|
@color = true if ENV['FORCE_COLOR']
|
|
50
49
|
@color
|
|
51
50
|
end
|
|
52
51
|
|
|
53
|
-
# Wrap text in a color, or pass it through plain when colors are off.
|
|
54
52
|
def c(text, color)
|
|
55
53
|
text = printable(text)
|
|
56
54
|
return text unless color?
|
|
@@ -58,35 +56,30 @@ module Metaclean
|
|
|
58
56
|
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
59
57
|
end
|
|
60
58
|
|
|
61
|
-
# Red ASCII wordmark (matches Ruby's brand color) + one-line tagline for
|
|
62
|
-
# --help / --version. Colored line-by-line on purpose: `c` runs text through
|
|
63
|
-
# `printable`, which turns control chars (including the heredoc's newlines)
|
|
64
|
-
# into spaces — so coloring the whole block at once would collapse the logo
|
|
65
|
-
# onto one line.
|
|
66
59
|
def banner
|
|
67
60
|
LOGO.each_line { |line| puts c(line.chomp, :red) }
|
|
68
61
|
puts c(' strip EXIF · IPTC · XMP · GPS · ID3 — leave the file clean', :gray)
|
|
69
62
|
end
|
|
70
63
|
|
|
71
64
|
def header(text)
|
|
65
|
+
return if quiet?
|
|
66
|
+
|
|
72
67
|
puts
|
|
73
68
|
puts c('━' * 64, :gray)
|
|
74
69
|
puts c(text, :bold)
|
|
75
70
|
puts c('━' * 64, :gray)
|
|
76
71
|
end
|
|
77
72
|
|
|
78
|
-
def section(text); puts c("▸ #{text}", :cyan)
|
|
79
|
-
def info(text); puts c(" #{text}", :gray)
|
|
80
|
-
def success(text); puts c("✓ #{text}", :green)
|
|
73
|
+
def section(text); puts c("▸ #{text}", :cyan) unless quiet?; end
|
|
74
|
+
def info(text); puts c(" #{text}", :gray) unless quiet?; end
|
|
75
|
+
def success(text); puts c("✓ #{text}", :green) unless quiet?; end
|
|
81
76
|
def warning(text); puts c("⚠ #{text}", :yellow);end
|
|
82
77
|
|
|
83
|
-
# `error` returns a string instead of printing it — callers usually want
|
|
84
|
-
# to send it to STDERR via `warn`, not stdout via `puts`.
|
|
85
78
|
def error(text); c("✗ #{text}", :red); end
|
|
86
79
|
|
|
87
|
-
# Prints a metadata Hash as a grouped, indented table.
|
|
88
|
-
# `only_embedded:` filters out the System/File/etc. noise.
|
|
89
80
|
def metadata_table(meta, only_embedded: false)
|
|
81
|
+
return if quiet?
|
|
82
|
+
|
|
90
83
|
rows = meta.reject { |k, _| k == 'SourceFile' }
|
|
91
84
|
rows = rows.select { |k, _| embedded_key?(k) } if only_embedded
|
|
92
85
|
|
|
@@ -95,120 +88,110 @@ module Metaclean
|
|
|
95
88
|
return
|
|
96
89
|
end
|
|
97
90
|
|
|
98
|
-
# Group "GPS:*", "EXIF:*", … each into its own labeled sub-table.
|
|
99
91
|
grouped = rows.group_by { |k, _| group_of(k) }
|
|
100
92
|
grouped.sort_by { |g, _| g.to_s }.each do |group, pairs|
|
|
101
93
|
puts c(" [#{group}]", :magenta)
|
|
102
94
|
pairs.sort_by { |k, _| k.to_s }.each do |k, v|
|
|
103
95
|
tag = k.to_s.split(':', 2).last
|
|
104
|
-
line = format(' %-38s %s', truncate(tag, 38), truncate(
|
|
96
|
+
line = format(' %-38s %s', truncate(tag, 38), truncate(visible_value(v), 60))
|
|
105
97
|
puts c(line, :dim)
|
|
106
98
|
end
|
|
107
99
|
end
|
|
108
100
|
end
|
|
109
101
|
|
|
110
|
-
# Compares two metadata hashes (before vs after) and prints three
|
|
111
|
-
# sections: removed, changed, still-present. This is the "before/after"
|
|
112
|
-
# the user asked for.
|
|
113
102
|
def diff(before, after)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
removed =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
elsif !b.nil? && a != b
|
|
126
|
-
changed << [k, b, a]
|
|
127
|
-
elsif !b.nil?
|
|
128
|
-
kept << [k, b]
|
|
129
|
-
end
|
|
103
|
+
return if quiet?
|
|
104
|
+
|
|
105
|
+
removed, changed, kept = classify_diff(before, after)
|
|
106
|
+
render_removed(removed)
|
|
107
|
+
render_changed(changed)
|
|
108
|
+
render_kept(kept)
|
|
109
|
+
|
|
110
|
+
if [removed, changed, kept].all?(&:empty?)
|
|
111
|
+
info 'Nothing to strip — file already clean.'
|
|
112
|
+
elsif removed.empty? && changed.empty?
|
|
113
|
+
info 'No tags were removed — see "Still present" above.'
|
|
130
114
|
end
|
|
115
|
+
end
|
|
131
116
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
117
|
+
def classify_diff(before, after)
|
|
118
|
+
rows = [[], [], []]
|
|
119
|
+
keys = (before.keys + after.keys).uniq.select { |key| embedded_key?(key) }
|
|
120
|
+
keys.sort.each do |key|
|
|
121
|
+
old_value = before[key]
|
|
122
|
+
new_value = after[key]
|
|
123
|
+
if new_value.nil? && !old_value.nil?
|
|
124
|
+
rows[0] << [key, old_value]
|
|
125
|
+
elsif !old_value.nil? && new_value != old_value
|
|
126
|
+
rows[1] << [key, old_value, new_value]
|
|
127
|
+
elsif !old_value.nil?
|
|
128
|
+
rows[2] << [key, old_value]
|
|
136
129
|
end
|
|
137
130
|
end
|
|
131
|
+
rows
|
|
132
|
+
end
|
|
138
133
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
end
|
|
134
|
+
def render_removed(rows)
|
|
135
|
+
return if rows.empty?
|
|
136
|
+
|
|
137
|
+
section "Removed (#{rows.size})"
|
|
138
|
+
rows.each do |key, value|
|
|
139
|
+
puts " #{c('-', :red)} #{c(key, :red)} #{c(truncate(visible_value(value), 60), :gray)}"
|
|
146
140
|
end
|
|
141
|
+
end
|
|
147
142
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
143
|
+
def render_changed(rows)
|
|
144
|
+
return if rows.empty?
|
|
145
|
+
|
|
146
|
+
section "Changed (#{rows.size})"
|
|
147
|
+
rows.each do |key, old_value, new_value|
|
|
148
|
+
puts " #{c('~', :yellow)} #{c(key, :yellow)}"
|
|
149
|
+
puts " #{c('-', :red)} #{truncate(visible_value(old_value), 60)}"
|
|
150
|
+
puts " #{c('+', :green)} #{truncate(visible_value(new_value), 60)}"
|
|
153
151
|
end
|
|
152
|
+
end
|
|
154
153
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
def render_kept(rows)
|
|
155
|
+
return if rows.empty?
|
|
156
|
+
|
|
157
|
+
section "Still present (#{rows.size})"
|
|
158
|
+
rows.each do |key, value|
|
|
159
|
+
puts " #{c('=', :gray)} #{c(key, :gray)} #{c(truncate(visible_value(value), 60), :gray)}"
|
|
159
160
|
end
|
|
160
161
|
end
|
|
161
162
|
|
|
162
|
-
# Group name out of "Group:Tag" (split caps at 2 so a ":" in the value is safe).
|
|
163
163
|
def group_of(key)
|
|
164
164
|
key.to_s.split(':', 2).first.to_s
|
|
165
165
|
end
|
|
166
166
|
|
|
167
|
-
# True when `key` names real embedded metadata: not the SourceFile
|
|
168
|
-
# bookkeeping key, and not one of the System/File/ExifTool/Composite
|
|
169
|
-
# groups that describe the file rather than its embedded tags. Single
|
|
170
|
-
# source of truth for "is this a tag we actually stripped?" — shared by
|
|
171
|
-
# the table, diff, count, removed-count and privacy-residual checks.
|
|
172
167
|
def embedded_key?(key)
|
|
173
168
|
key != 'SourceFile' && !NON_METADATA_GROUPS.include?(group_of(key))
|
|
174
169
|
end
|
|
175
170
|
|
|
176
|
-
# Make any value safe to print on a single line. Hashes/Arrays get
|
|
177
|
-
# `inspect` (shows their structure); strings are collapsed to single
|
|
178
|
-
# spaces so a multiline tag value doesn't wreck the table.
|
|
179
171
|
def format_value(v)
|
|
180
172
|
case v
|
|
181
173
|
when Hash, Array then printable(v.inspect)
|
|
182
174
|
else
|
|
183
|
-
# Guard the regexp gsub against invalid-encoding tag values — gsub raises
|
|
184
|
-
# ArgumentError on them. Exiftool.read already scrubs; this is belt-and-
|
|
185
|
-
# suspenders so the display layer can never crash the run on hostile bytes.
|
|
186
175
|
s = printable(v)
|
|
187
176
|
s.gsub(/\s+/, ' ')
|
|
188
177
|
end
|
|
189
178
|
end
|
|
190
179
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
# (C1, U+0080–U+009F, holds the 8-bit CSI/OSC introducers some terminals honor).
|
|
180
|
+
def visible_value(value)
|
|
181
|
+
redact_values? ? '[redacted]' : format_value(value)
|
|
182
|
+
end
|
|
183
|
+
|
|
196
184
|
def printable(text)
|
|
197
185
|
s = text.to_s
|
|
198
186
|
s = s.scrub unless s.valid_encoding?
|
|
199
187
|
s.gsub(/[[:cntrl:]]/, ' ')
|
|
200
188
|
end
|
|
201
189
|
|
|
202
|
-
# Truncate to N chars with a single-character ellipsis. We use "…"
|
|
203
|
-
# (one Unicode char) instead of "..." so the truncation doesn't itself
|
|
204
|
-
# spill over the budget.
|
|
205
190
|
def truncate(s, n)
|
|
206
191
|
s = s.to_s
|
|
207
192
|
s.length > n ? "#{s[0, n - 1]}…" : s
|
|
208
193
|
end
|
|
209
194
|
|
|
210
|
-
# How many "real" embedded tags are there? Used for the
|
|
211
|
-
# "Before (24 embedded tags) → After (0)" summary line.
|
|
212
195
|
def count_embedded(meta)
|
|
213
196
|
meta.keys.count { |k| embedded_key?(k) }
|
|
214
197
|
end
|
data/lib/metaclean/exiftool.rb
CHANGED
|
@@ -1,66 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Thin wrapper around the external `exiftool` binary.
|
|
4
|
-
#
|
|
5
|
-
# Open3.capture3 with multiple args bypasses the shell, so a filename like
|
|
6
|
-
# `cat; rm -rf /` is one argument, not a command. That's not the whole story:
|
|
7
|
-
# exiftool still parses its own arguments, so a filename beginning with "-"
|
|
8
|
-
# (e.g. `-config`) would be read as an option. Every path goes through
|
|
9
|
-
# `Metaclean.safe_path`, which prefixes a leading dash with "./" so it's
|
|
10
|
-
# always seen as a filename.
|
|
11
|
-
|
|
12
|
-
require 'open3'
|
|
13
3
|
require 'json'
|
|
14
4
|
|
|
15
5
|
module Metaclean
|
|
16
6
|
module Exiftool
|
|
17
7
|
module_function
|
|
18
8
|
|
|
19
|
-
# True if `exiftool` is on PATH. Memoized so repeated checks don't re-spawn
|
|
20
|
-
# it (defined? not nil? — the cached value can legitimately be false).
|
|
21
9
|
def available?
|
|
22
10
|
return @available if defined?(@available)
|
|
23
11
|
|
|
24
|
-
out, _err, status =
|
|
12
|
+
out, _err, status = Metaclean.capture3(
|
|
13
|
+
'exiftool', '-ver',
|
|
14
|
+
timeout: Metaclean::PROBE_TIMEOUT,
|
|
15
|
+
max_output: Metaclean::PROBE_MAX_OUTPUT_BYTES
|
|
16
|
+
)
|
|
25
17
|
@available = status.success?
|
|
26
|
-
# Stash the version off the same call so `version` need not re-spawn.
|
|
27
18
|
@version = @available ? out.strip : nil
|
|
28
19
|
@available
|
|
29
|
-
rescue Errno::ENOENT
|
|
20
|
+
rescue Errno::ENOENT, Error
|
|
30
21
|
@version = nil
|
|
31
|
-
@available = false
|
|
22
|
+
@available = false
|
|
32
23
|
end
|
|
33
24
|
|
|
34
|
-
# Returns the version string, or nil if exiftool is missing/broken.
|
|
35
|
-
# Captured by `available?`, so this never re-runs the binary.
|
|
36
25
|
def version
|
|
37
26
|
available? ? @version : nil
|
|
38
27
|
end
|
|
39
28
|
|
|
40
|
-
# Reads metadata from a file and returns a flat Hash of "Group:Tag" => value.
|
|
41
|
-
#
|
|
42
|
-
# ExifTool flag glossary:
|
|
43
|
-
# -j JSON output (machine-parseable)
|
|
44
|
-
# -G1 Include the family-1 group name. NB: with -G1 mainstream EXIF
|
|
45
|
-
# tags appear under "IFD0"/"ExifIFD"/"IFD1", not "EXIF" (that's
|
|
46
|
-
# the family-0 name); GPS/IPTC/XMP-dc keep those group names.
|
|
47
|
-
# -a Allow duplicate tags (some formats have several with same name)
|
|
48
|
-
# -u Include unknown/unidentified tags
|
|
49
|
-
# -s Short tag names (no descriptions)
|
|
50
|
-
# -n Numeric values (no human formatting like "1/100 sec")
|
|
51
|
-
# -api largefilesupport=1 Allow files >4 GB
|
|
52
29
|
def read(path)
|
|
53
|
-
out, err, status =
|
|
30
|
+
out, err, status = Metaclean.capture3(
|
|
54
31
|
'exiftool', '-j', '-G1', '-a', '-u', '-s', '-n', '-api', 'largefilesupport=1',
|
|
55
32
|
Metaclean.safe_path(path)
|
|
56
33
|
)
|
|
57
|
-
raise Error, "ExifTool read failed: #{err
|
|
34
|
+
raise Error, "ExifTool read failed: #{read_error_detail(out, err)}" unless status.success?
|
|
58
35
|
|
|
59
|
-
# ExifTool's JSON output is an array (one entry per file). We always
|
|
60
|
-
# pass one file, so we take the first element. `|| {}` handles the
|
|
61
|
-
# edge case where exiftool returns an empty array. A non-array shape is
|
|
62
|
-
# unexpected — bail with a clear error instead of crashing later on
|
|
63
|
-
# `.first` returning a Hash/scalar.
|
|
64
36
|
data = JSON.parse(out)
|
|
65
37
|
raise Error, 'Unexpected ExifTool output (expected a JSON array)' unless data.is_a?(Array)
|
|
66
38
|
|
|
@@ -69,12 +41,17 @@ module Metaclean
|
|
|
69
41
|
raise Error, "Could not parse ExifTool output: #{e.message}"
|
|
70
42
|
end
|
|
71
43
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
44
|
+
def read_error_detail(out, err)
|
|
45
|
+
return err.strip unless err.strip.empty?
|
|
46
|
+
|
|
47
|
+
data = JSON.parse(out)
|
|
48
|
+
entry = data.is_a?(Array) ? data.first : nil
|
|
49
|
+
detail = entry && (entry['ExifTool:Error'] || entry['Error'])
|
|
50
|
+
detail ? detail.to_s.strip : 'unknown error'
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
'unknown error'
|
|
53
|
+
end
|
|
54
|
+
|
|
78
55
|
def scrub_encoding(obj)
|
|
79
56
|
case obj
|
|
80
57
|
when String then obj.valid_encoding? ? obj : obj.scrub
|
|
@@ -84,35 +61,27 @@ module Metaclean
|
|
|
84
61
|
end
|
|
85
62
|
end
|
|
86
63
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# safe because the post-strip residual re-read still gates the :cleaned status.
|
|
96
|
-
WRITE_UNSUPPORTED_RE = /not yet support|can't currently write|writing of .* files/i
|
|
97
|
-
|
|
98
|
-
# Removes every removable tag, in place. Returns true on success,
|
|
99
|
-
# :unsupported when ExifTool cannot write the format, and raises on failure.
|
|
100
|
-
#
|
|
101
|
-
# `-all=` sets every tag to empty, which deletes them. `-overwrite_original`
|
|
102
|
-
# makes ExifTool replace the file directly instead of writing `file_original`
|
|
103
|
-
# next to it. `-api largefilesupport=1` lets files larger than 4 GB through.
|
|
64
|
+
WRITE_UNSUPPORTED_RE = /
|
|
65
|
+
writing\sof\s(?:this\stype\sof\s|[^\s]+\s)?files?\sis\snot\s(?:yet\s)?supported |
|
|
66
|
+
can't\scurrently\swrite |
|
|
67
|
+
does\snot\syet\ssupport\swriting
|
|
68
|
+
/ix
|
|
69
|
+
|
|
70
|
+
PRESERVE_TAGS = %w[Orientation ICC_Profile].freeze
|
|
71
|
+
|
|
104
72
|
def strip!(path, also_delete: [])
|
|
105
|
-
|
|
106
|
-
# IFD0 directory and leaves its tags (Artist, Software, …) behind. So we
|
|
107
|
-
# ALSO delete the known privacy tags by name and clear the GPS group: both
|
|
108
|
-
# are no-ops where `-all=` already removed them (e.g. JPEG), but they make
|
|
109
|
-
# the strip complete AND lossless (no re-encode) for IFD0-preserving formats.
|
|
73
|
+
FileOps.regular_stat!(path)
|
|
110
74
|
args = ['exiftool', '-all=', '-gps:all=']
|
|
111
75
|
also_delete.each { |tag| args << "-#{tag}=" }
|
|
76
|
+
args.concat(['-tagsfromfile', '@'])
|
|
77
|
+
PRESERVE_TAGS.each { |tag| args << "-#{tag}" }
|
|
112
78
|
args.concat(['-overwrite_original', '-q', '-q', '-api', 'largefilesupport=1', Metaclean.safe_path(path)])
|
|
113
79
|
|
|
114
|
-
_out, err, status =
|
|
115
|
-
|
|
80
|
+
_out, err, status = Metaclean.capture3(*args)
|
|
81
|
+
if status.success?
|
|
82
|
+
FileOps.validate_output!(path)
|
|
83
|
+
return true
|
|
84
|
+
end
|
|
116
85
|
return :unsupported if err.match?(WRITE_UNSUPPORTED_RE)
|
|
117
86
|
|
|
118
87
|
raise Error, "ExifTool strip failed: #{err.strip}"
|
data/lib/metaclean/ffmpeg.rb
CHANGED
|
@@ -1,34 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
#
|
|
5
|
-
# ffmpeg is used for ONE job the other tools can't do: the Matroska containers
|
|
6
|
-
# (mkv/webm). ExifTool is read-only for Matroska, and mat2 has no Matroska
|
|
7
|
-
# parser, so without ffmpeg those formats can't be cleaned at all. ffmpeg
|
|
8
|
-
# rewrites the container while copying every stream verbatim (`-c copy`) — no
|
|
9
|
-
# re-encode, so the audio/video is bit-identical and only the metadata is gone.
|
|
10
|
-
#
|
|
11
|
-
# Like mat2, ffmpeg can't edit in place: it muxes to a new file. We write to a
|
|
12
|
-
# SecureRandom-named sibling and move it back over the source on success.
|
|
13
|
-
|
|
14
|
-
require 'open3'
|
|
15
|
-
require 'fileutils'
|
|
16
|
-
require 'securerandom'
|
|
3
|
+
require 'json'
|
|
17
4
|
|
|
18
5
|
module Metaclean
|
|
19
6
|
module Ffmpeg
|
|
7
|
+
NESTED_IMAGE_CODECS = %w[bmp gif mjpeg mjpegb png tiff webp].freeze
|
|
8
|
+
|
|
20
9
|
module_function
|
|
21
10
|
|
|
22
|
-
# Memoized PATH check (same pattern as the other wrappers).
|
|
23
11
|
def available?
|
|
24
12
|
return @available if defined?(@available)
|
|
25
13
|
|
|
26
|
-
out, _err, status =
|
|
27
|
-
|
|
28
|
-
|
|
14
|
+
out, _err, status = Metaclean.capture3(
|
|
15
|
+
'ffmpeg', '-version',
|
|
16
|
+
timeout: Metaclean::PROBE_TIMEOUT,
|
|
17
|
+
max_output: Metaclean::PROBE_MAX_OUTPUT_BYTES
|
|
18
|
+
)
|
|
19
|
+
_probe_out, _probe_err, probe_status = Metaclean.capture3(
|
|
20
|
+
'ffprobe', '-version',
|
|
21
|
+
timeout: Metaclean::PROBE_TIMEOUT,
|
|
22
|
+
max_output: Metaclean::PROBE_MAX_OUTPUT_BYTES
|
|
23
|
+
)
|
|
24
|
+
@available = status.success? && probe_status.success?
|
|
29
25
|
@version = @available ? out.lines.first.to_s.split[2] : nil
|
|
30
26
|
@available
|
|
31
|
-
rescue Errno::ENOENT
|
|
27
|
+
rescue Errno::ENOENT, Error
|
|
32
28
|
@version = nil
|
|
33
29
|
@available = false
|
|
34
30
|
end
|
|
@@ -37,44 +33,94 @@ module Metaclean
|
|
|
37
33
|
available? ? @version : nil
|
|
38
34
|
end
|
|
39
35
|
|
|
40
|
-
# Strips all metadata from `path` in place, losslessly. Returns true on
|
|
41
|
-
# success, raises Metaclean::Error on failure.
|
|
42
|
-
#
|
|
43
|
-
# -map 0 keep every stream (video, audio, subtitles)
|
|
44
|
-
# -map_metadata -1 drop global/container metadata
|
|
45
|
-
# -map_chapters -1 drop chapter markers (they can carry titles)
|
|
46
|
-
# -c copy remux without re-encoding — bit-identical streams
|
|
47
36
|
def strip!(path)
|
|
48
37
|
raise Error, 'ffmpeg not available' unless available?
|
|
49
38
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
39
|
+
source = FileOps.regular_stat!(path)
|
|
40
|
+
structure = probe_structure(path)
|
|
41
|
+
reject_unverifiable_streams!(structure[:streams])
|
|
42
|
+
FileOps.with_private_workspace(path, 'ffmpeg') do |workspace|
|
|
43
|
+
tmp = File.join(workspace, "output#{File.extname(path)}")
|
|
44
|
+
args = [
|
|
45
|
+
'ffmpeg', '-y', '-v', 'error', '-nostdin', '-i', file_url(path),
|
|
46
|
+
'-map', '0', '-map_metadata', '-1',
|
|
47
|
+
'-map_chapters', structure[:chapters].positive? ? '0' : '-1'
|
|
48
|
+
]
|
|
49
|
+
args.concat(['-metadata:c', 'title=']) if structure[:chapters].positive?
|
|
50
|
+
append_safe_stream_languages(args, structure[:streams])
|
|
51
|
+
args.concat(['-c', 'copy', file_url(tmp)])
|
|
52
|
+
|
|
53
|
+
_out, err, status = Metaclean.capture3(*args)
|
|
54
|
+
raise Error, "ffmpeg failed: #{err.strip}" unless status.success? && File.exist?(tmp)
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
FileOps.validate_output!(tmp)
|
|
57
|
+
ensure_structure_preserved!(structure, probe_structure(tmp))
|
|
58
|
+
FileOps.ensure_same_file!(path, source)
|
|
59
|
+
FileOps.restore_metadata(tmp, source)
|
|
60
|
+
File.rename(tmp, path)
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def probe_structure(path)
|
|
66
|
+
out, err, status = Metaclean.capture3(
|
|
67
|
+
'ffprobe', '-v', 'error', '-show_streams', '-show_chapters',
|
|
68
|
+
'-of', 'json', file_url(path)
|
|
58
69
|
)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
raise Error, "ffprobe failed: #{err.strip}" unless status.success?
|
|
71
|
+
|
|
72
|
+
data = JSON.parse(out)
|
|
73
|
+
streams = data.fetch('streams', [])
|
|
74
|
+
chapters = data.fetch('chapters', [])
|
|
75
|
+
raise TypeError unless streams.is_a?(Array) && chapters.is_a?(Array)
|
|
76
|
+
|
|
77
|
+
{ streams: streams, chapters: chapters.size }
|
|
78
|
+
rescue JSON::ParserError, KeyError, TypeError => e
|
|
79
|
+
raise Error, "Could not parse ffprobe output: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reject_unverifiable_streams!(streams)
|
|
83
|
+
video_streams = streams.count { |stream| stream['codec_type'] == 'video' }
|
|
84
|
+
nested = streams.any? do |stream|
|
|
85
|
+
tags = stream['tags'].is_a?(Hash) ? stream['tags'] : {}
|
|
86
|
+
disposition = stream['disposition'].is_a?(Hash) ? stream['disposition'] : {}
|
|
87
|
+
nested_image = stream['codec_type'] == 'video' &&
|
|
88
|
+
NESTED_IMAGE_CODECS.include?(stream['codec_name']) &&
|
|
89
|
+
(video_streams > 1 || streams.any? { |candidate| candidate['codec_type'] == 'audio' })
|
|
90
|
+
stream['codec_type'] == 'attachment' ||
|
|
91
|
+
nested_image ||
|
|
92
|
+
disposition['attached_pic'].to_i == 1 ||
|
|
93
|
+
!tags['filename'].to_s.empty? ||
|
|
94
|
+
!tags['mimetype'].to_s.empty?
|
|
95
|
+
end
|
|
96
|
+
return unless nested
|
|
97
|
+
|
|
98
|
+
raise Error, 'Matroska contains attachments or cover art whose nested metadata cannot be verified'
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ensure_structure_preserved!(before, after)
|
|
102
|
+
before_signatures = stream_signatures(before[:streams])
|
|
103
|
+
after_signatures = stream_signatures(after[:streams])
|
|
104
|
+
unless before_signatures == after_signatures && before[:chapters] == after[:chapters]
|
|
105
|
+
raise Error, 'ffmpeg output changed the stream or chapter structure'
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def stream_signatures(streams)
|
|
110
|
+
streams.map { |stream| [stream['codec_type'], stream['codec_name']] }
|
|
69
111
|
end
|
|
70
112
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
113
|
+
def append_safe_stream_languages(args, streams)
|
|
114
|
+
streams.each do |stream|
|
|
115
|
+
index = Integer(stream.fetch('index'))
|
|
116
|
+
tags = stream['tags'].is_a?(Hash) ? stream['tags'] : {}
|
|
117
|
+
language = tags['language'].to_s.downcase
|
|
118
|
+
if language.match?(/\A[a-z]{2,3}(?:-[a-z0-9]{2,8})*\z/)
|
|
119
|
+
args.concat(["-metadata:s:#{index}", "language=#{language}"])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
rescue KeyError, TypeError, ArgumentError => e
|
|
123
|
+
raise Error, "Unexpected ffprobe stream data: #{e.message}"
|
|
78
124
|
end
|
|
79
125
|
|
|
80
126
|
def file_url(path)
|