taglib-simple 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.yardopts +8 -0
- data/INTERNALS.md +40 -0
- data/LICENSE.txt +20 -0
- data/README.md +167 -0
- data/bin/taglib.rb +146 -0
- data/ext/taglib_simple_fileref/FileRef.cpp +270 -0
- data/ext/taglib_simple_fileref/FileRef.hpp +164 -0
- data/ext/taglib_simple_fileref/IOStream.cpp +148 -0
- data/ext/taglib_simple_fileref/IOStream.hpp +48 -0
- data/ext/taglib_simple_fileref/conversions.h +38 -0
- data/ext/taglib_simple_fileref/convert_ruby_to_taglib.cpp +205 -0
- data/ext/taglib_simple_fileref/convert_taglib_to_ruby.cpp +133 -0
- data/ext/taglib_simple_fileref/extconf.rb +23 -0
- data/ext/taglib_simple_fileref/taglib_simple_fileref.cpp +57 -0
- data/ext/taglib_simple_fileref/taglib_wrap.h +5 -0
- data/lib/taglib_simple/audio_properties.rb +32 -0
- data/lib/taglib_simple/audio_tag.rb +67 -0
- data/lib/taglib_simple/error.rb +5 -0
- data/lib/taglib_simple/media_file.rb +598 -0
- data/lib/taglib_simple/types.rb +47 -0
- data/lib/taglib_simple/version.rb +8 -0
- data/lib/taglib_simple.rb +22 -0
- data/lib/yard_rice.rb +53 -0
- metadata +199 -0
@@ -0,0 +1,598 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'error'
|
4
|
+
require_relative 'audio_properties'
|
5
|
+
require_relative 'audio_tag'
|
6
|
+
require_relative 'types'
|
7
|
+
require_relative '../taglib_simple_fileref'
|
8
|
+
require 'forwardable'
|
9
|
+
|
10
|
+
module TagLib
|
11
|
+
# rubocop:disable Metrics/ClassLength
|
12
|
+
|
13
|
+
# Attribute and Hash like semantics unifying various TagLib concepts into a single interface:
|
14
|
+
#
|
15
|
+
# | Retrieve Method | Attribute access | Hash Key | Example Key | Value Type | TagLib Type |
|
16
|
+
# |:---------------------|:---------------------|:------------|:------------|:--------------------|:-----------------|
|
17
|
+
# | {audio_properties} | read only | Symbol (r) | :bitrate | Integer | AudioProperties |
|
18
|
+
# | {tag} | read/write | Symbol (rw) | :title | String or Integer | Tag |
|
19
|
+
# | {properties} | read/write (dynamic) | String (rw) | "COMPOSER" | Array[String] | PropertyMap |
|
20
|
+
# | {complex_properties} | read/write (dynamic) | String (rw) | "PICTURE" | Array[Hash[String]] | List[VariantMap] |
|
21
|
+
#
|
22
|
+
# TagLib requires {#audio_properties} to be specifically requested at {#initialize} while the other components can be
|
23
|
+
# lazily loaded as required.
|
24
|
+
#
|
25
|
+
# For read-only usage {.read} can be used to {#retrieve} the required components from TagLib,
|
26
|
+
# {#close} the file, and continue working with the read-only result.
|
27
|
+
#
|
28
|
+
# @example general read/write usage (auto saved)
|
29
|
+
# TagLib::MediaFile.open(filename, audio_properties: true) do |media_file|
|
30
|
+
# media_file.sample_rate # => 44100
|
31
|
+
# media_file.title # => 'A Title'
|
32
|
+
# media_file['LANGUAGE'] # => 'English'
|
33
|
+
# media_file.language # => 'English'
|
34
|
+
# media_file['ARTISTS', all: true] # => ['Artist 1', 'Artist 2']
|
35
|
+
# media_file.all_artists # => ['Artist 1', 'Artist 2']
|
36
|
+
# media_file.title = 'A New Title' # => ['A New Title']
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# @example general read only usage
|
40
|
+
# media_file = TagLib::MediaFile.read(filename) # => <MediaFile closed?=true>
|
41
|
+
# media_file.properties # => { 'TITLE' => 'Title',...}
|
42
|
+
# media_file.tag # => <AudioTag>
|
43
|
+
# media_file.audio_properties # => nil
|
44
|
+
# media_file.title # => 'Title'
|
45
|
+
# media_file.title = 'A New Title' # Error! not writable
|
46
|
+
# @example read with cover art (explicit complex property)
|
47
|
+
# media_file = TagLib::MediaFile.read(filename, complex_property_keys: ['PICTURE'])
|
48
|
+
# media_file.picture # => { 'data' => '<binary data', 'mimeType' => 'image/png'... }
|
49
|
+
# @example read everything available to taglib
|
50
|
+
# media_file = TagLib::MediaFile.read(filename, all: true)
|
51
|
+
# media_file.audio_properties # => <AudioProperties>
|
52
|
+
# media_file.complex_property_keys # => ['PICTURE'...]
|
53
|
+
# @example only tag
|
54
|
+
# tag = TagLib::AudioTag.read(filename) # => <AudioTag>
|
55
|
+
# @example only audio_properties
|
56
|
+
# audio_properties = TagLib::AudioProperties.read(filename) # => <AudioProperties>
|
57
|
+
class MediaFile
|
58
|
+
class << self
|
59
|
+
# Open a file with TagLib
|
60
|
+
# @param [String, Pathname, IO] filename The path to the media file
|
61
|
+
# @param [Hash] init see {#initialize}
|
62
|
+
# @yield [media_file]
|
63
|
+
# When a block is given, opens the file, yields it to the block, saves any changes,
|
64
|
+
# ensures the file is closed, and returns the block's result.
|
65
|
+
# @yieldparam [MediaFile] media_file The open file if a block is given
|
66
|
+
# @return [MediaFile] If no block is given, returns the open media file
|
67
|
+
# @return [Object] otherwise returns the result of the block
|
68
|
+
# @raise [Error] If TagLib is unable to process the file
|
69
|
+
def open(filename, **init)
|
70
|
+
f = new(filename, **init)
|
71
|
+
return f unless block_given?
|
72
|
+
|
73
|
+
begin
|
74
|
+
yield(f).tap { f.save! if f.modified? }
|
75
|
+
ensure
|
76
|
+
f.close
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Read information from TagLib, close the file, returning the MediaFile in a read-only state.
|
81
|
+
# @param [String, Pathname, IO] filename
|
82
|
+
# @param [Hash<Symbol>] init see {#initialize}.
|
83
|
+
# defaults to retrieving only {#properties} and #{tag}
|
84
|
+
# @return [MediaFile] a {#closed?} media file
|
85
|
+
# @see AudioTag.read
|
86
|
+
# @see AudioProperties.read
|
87
|
+
def read(filename, properties: true, tag: true, **init)
|
88
|
+
self.open(filename, properties:, tag:, **init, &:itself)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
include Enumerable
|
93
|
+
extend Forwardable
|
94
|
+
|
95
|
+
# @param [String, Pathname, IO] file
|
96
|
+
# either the name of a file, an open File or an IO stream
|
97
|
+
# @param [Symbol<:fast,:average,:accurate>] audio_properties
|
98
|
+
# if not set no {AudioProperties} will be read otherwise :fast, :average or :accurate
|
99
|
+
# @param [Hash] retrieve property types to retrieve on initial load. The default is to pre-fetch nothing.
|
100
|
+
# See {#retrieve}.
|
101
|
+
# @raise [Error] if TagLib cannot open or process the file
|
102
|
+
def initialize(file, all: false, audio_properties: all && :average, **retrieve)
|
103
|
+
@fr = file.respond_to?(:valid?) ? file : Simple::FileRef.new(file, audio_properties)
|
104
|
+
raise Error, "TagLib could not open #{file}" unless @fr.valid?
|
105
|
+
|
106
|
+
@audio_properties = (audio_properties && @fr.audio_properties) || nil
|
107
|
+
reset
|
108
|
+
self.retrieve(all:, **retrieve)
|
109
|
+
end
|
110
|
+
|
111
|
+
# Retrieve and cache specific property types rom TagLib
|
112
|
+
#
|
113
|
+
# Properties will be lazily loaded as long as the file is open so calling this method is generally not required.
|
114
|
+
#
|
115
|
+
# Typically called from #{initialize} but can be invoked directly, eg to force reload of data after {#save!}.
|
116
|
+
#
|
117
|
+
# @param [Boolean] all default for other properties
|
118
|
+
# @param [Boolean] tag if true forces retrieve of {tag}
|
119
|
+
# @param [Boolean] properties if true forces retrieve of {properties}
|
120
|
+
# @param [Array<String>|Boolean|Symbol<:lazy,:all>|nil] complex_property_keys
|
121
|
+
# list of properties to specifically treat as _complex_
|
122
|
+
#
|
123
|
+
# * given an Array the specifically requested complex properties will be immediately retrieved from TagLib
|
124
|
+
# * explicitly false is equivalent to passing an empty array
|
125
|
+
# * given `true` will immediately fetch the list from TagLib, but not the properties themselves
|
126
|
+
# * given ':all' will retrieve the list from TagLib and then retrieve those properties
|
127
|
+
# * given ':lazy' will explicitly reset the list to be lazily fetched
|
128
|
+
# * otherwise nil does not change the previously setting
|
129
|
+
#
|
130
|
+
# While the file is open, a specific complex property can be retrieved using {#complex_properties}[] regardless of
|
131
|
+
# what is set here.
|
132
|
+
# @return [self]
|
133
|
+
def retrieve(all: false, tag: all, properties: all, complex_property_keys: (all && :all) || nil)
|
134
|
+
self.properties if properties
|
135
|
+
self.tag if tag
|
136
|
+
|
137
|
+
retrieve_complex_property_keys(complex_property_keys) && fill_complex_properties
|
138
|
+
|
139
|
+
self
|
140
|
+
end
|
141
|
+
|
142
|
+
# @return [Boolean] true if the file or IO is closed in TagLib
|
143
|
+
# @note Properties retrieved from TagLib before #{close} remain accessible. Reader methods for any missing
|
144
|
+
# property types will return as though those properties are not set. Writer methods will raise {Error}.
|
145
|
+
def closed?
|
146
|
+
!valid?
|
147
|
+
end
|
148
|
+
|
149
|
+
# @return [Boolean] true if the file is open and writable
|
150
|
+
def writable?
|
151
|
+
!closed? && !read_only?
|
152
|
+
end
|
153
|
+
|
154
|
+
# Close this file - releasing memory , file descriptors etc... on the TagLib library side while retaining
|
155
|
+
# any previously retrieved data in a read-only state.
|
156
|
+
# @return [self]
|
157
|
+
def close
|
158
|
+
warn "closing with unsaved properties #{@mutated.keys}" if @mutated&.any?
|
159
|
+
self
|
160
|
+
ensure
|
161
|
+
@fr.close
|
162
|
+
end
|
163
|
+
|
164
|
+
# @!group Audio Properties (delegated)
|
165
|
+
|
166
|
+
# @!attribute [r] audio_properties
|
167
|
+
# @return [AudioProperties]
|
168
|
+
# @return [nil] if audio_properties were not retrieved at {#initialize}
|
169
|
+
attr_reader :audio_properties
|
170
|
+
|
171
|
+
# @!attribute [r] audio_length
|
172
|
+
# @return [Integer] The length of the audio in milliseconds if available
|
173
|
+
|
174
|
+
# @!attribute [r] bitrate
|
175
|
+
# @return [Integer] The bitrate of the audio in kb/s if available
|
176
|
+
|
177
|
+
# @!attribute [r] sample_rate
|
178
|
+
# @return [Integer] The sample rate in Hz if available
|
179
|
+
|
180
|
+
# @!attribute [r] channels
|
181
|
+
# @return [Integer] The number of audio channels
|
182
|
+
|
183
|
+
def_delegators :audio_properties, *AudioProperties.members
|
184
|
+
|
185
|
+
# @!endgroup
|
186
|
+
|
187
|
+
# @!macro [new] lazy
|
188
|
+
# @note If the file is open this will lazily retrieve all necessary data from TagLib, otherwise only data
|
189
|
+
# retrieved before the file was closed will be available.
|
190
|
+
|
191
|
+
# @!group Tag Attributes (delegated)
|
192
|
+
|
193
|
+
# @!attribute [r] tag
|
194
|
+
# @return [AudioTag] normalised tag information.
|
195
|
+
# @return [nil] if file was closed without retrieving tag
|
196
|
+
# @!macro lazy
|
197
|
+
def tag(lazy: !closed?)
|
198
|
+
@tag ||= (lazy || nil) && @fr.tag
|
199
|
+
end
|
200
|
+
|
201
|
+
# @!attribute [rw] title
|
202
|
+
# @return [String, nil] The title of the track if available
|
203
|
+
|
204
|
+
# @!attribute [rw] artist
|
205
|
+
# @return [String, nil] The artist name if available
|
206
|
+
|
207
|
+
# @!attribute [rw] album
|
208
|
+
# @return [String, nil] The album name if available
|
209
|
+
|
210
|
+
# @!attribute [rw] genre
|
211
|
+
# @return [String, nil] The genre of the track if available
|
212
|
+
|
213
|
+
# @!attribute [rw] year
|
214
|
+
# @return [Integer, nil] The release year if available
|
215
|
+
|
216
|
+
# @!attribute [rw] track
|
217
|
+
# @return [Integer, nil] The track number if available
|
218
|
+
|
219
|
+
# @!attribute [rw] comment
|
220
|
+
# @return [String, nil] Additional comments about the track if available
|
221
|
+
|
222
|
+
AudioTag.members.each do |tag_member|
|
223
|
+
define_method tag_member do
|
224
|
+
return @mutated[tag_member] if @mutated.key?(tag_member)
|
225
|
+
|
226
|
+
# if the file is closed then try and use #properties if we don't have #tag
|
227
|
+
# probably won't work for track and year
|
228
|
+
tag_value = tag ? tag.public_send(tag_member) : properties.fetch(tag_member.to_s.upcase, [])&.first
|
229
|
+
tag_value && (%i[year track].include?(tag_member) ? tag_value.to_i : tag_value)
|
230
|
+
end
|
231
|
+
|
232
|
+
define_method :"#{tag_member}=" do |value|
|
233
|
+
write_property(tag_member, AudioTag.check_value(tag_member, value))
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# @!endgroup
|
238
|
+
|
239
|
+
# @!group General Properties
|
240
|
+
|
241
|
+
# @!attribute [r] properties
|
242
|
+
# @return [Hash<String, Array<String>>]
|
243
|
+
# the available simple string properties (frozen)
|
244
|
+
# @return [nil] if file was closed without retrieving properties.
|
245
|
+
# @!macro lazy
|
246
|
+
def properties(lazy: !closed?)
|
247
|
+
@properties ||= (lazy || nil) && @fr.properties
|
248
|
+
end
|
249
|
+
|
250
|
+
# @!attribute [r] complex_properties
|
251
|
+
# @return [Hash<String>] a hash that lazily pulls complex properties from TagLib
|
252
|
+
attr_reader :complex_properties
|
253
|
+
|
254
|
+
# @!attribute [r] complex_property_keys
|
255
|
+
# Set of keys that represent complex properties.
|
256
|
+
#
|
257
|
+
# Used to determine whether #{complex_properties} or #{properties} is used to find a given key.
|
258
|
+
#
|
259
|
+
# * Any keys already retrieved into {#complex_properties} are always included.
|
260
|
+
# * If no keys were provided to {#retrieve} the list of keys will be lazily fetched from TagLib if possible.
|
261
|
+
#
|
262
|
+
# @return [Array<String>] subset of keys that represent complex properties
|
263
|
+
# @!macro lazy
|
264
|
+
def complex_property_keys(lazy: !closed?)
|
265
|
+
@complex_properties.keys | ((lazy && (@complex_property_keys ||= @fr.complex_property_keys)) || [])
|
266
|
+
end
|
267
|
+
|
268
|
+
# @!endgroup
|
269
|
+
|
270
|
+
# @!group Hash Semantics
|
271
|
+
|
272
|
+
# Get a property
|
273
|
+
# @param [String, Symbol] key
|
274
|
+
# @param [Boolean] all if set property keys will return a list, otherwise just the first value
|
275
|
+
# @param [Boolean] saved if set only saved values will be used, ie {#modifications} will be ignored
|
276
|
+
# @return [Integer] where *key* is an {AudioProperties} member
|
277
|
+
# @return [String, Integer] where *key* is an {AudioTag} member
|
278
|
+
# @return [String, Array<String>] for a simple property
|
279
|
+
# @return [Hash, Array<Hash>] for a complex property
|
280
|
+
# @return [nil] if the *key* is not found
|
281
|
+
# @!macro lazy
|
282
|
+
def [](key, all: false, saved: false)
|
283
|
+
public_send(all ? :fetch_all : :fetch, key, nil, saved:)
|
284
|
+
end
|
285
|
+
|
286
|
+
# Fetch the first available value for a property from the media file
|
287
|
+
# @param [String, Symbol] key
|
288
|
+
# @param [Object] default optional value to return when *key* is not found and no block given
|
289
|
+
# @param [Boolean] saved if set only saved values will be used, ie {#modifications} will be ignored
|
290
|
+
# @yield [key] optional block to execute when *key* is not found
|
291
|
+
# @return [Integer] where *key* is an {AudioProperties} member
|
292
|
+
# @return [String, Integer] where *key* is an {AudioTag} member
|
293
|
+
# @return [String] for a simple property
|
294
|
+
# @return [Hash] for a complex property
|
295
|
+
# @return [Object] when *key* is not found and a *default* or block given
|
296
|
+
# @raise [KeyError] when *key* is not found and no *default* or block given
|
297
|
+
# @!macro lazy
|
298
|
+
# @see fetch_all
|
299
|
+
def fetch(key, *default, saved: false, &)
|
300
|
+
result = fetch_all(key, *default, saved:, &)
|
301
|
+
key.is_a?(String) && result.is_a?(Array) ? result.first : result
|
302
|
+
end
|
303
|
+
|
304
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
305
|
+
|
306
|
+
# Fetch a potentially multi-value property from the media file
|
307
|
+
# @param [String, Symbol] key
|
308
|
+
# @param [Object] default optional value to return when *key* is not found and no block given
|
309
|
+
# @param [Boolean] saved if set only saved values will be used, ie {#modifications} will be ignored
|
310
|
+
# @yield [key] optional block to execute when *key* is not found
|
311
|
+
# @return [Integer] where *key* is an {AudioProperties} member
|
312
|
+
# @return [String, Integer] where *key* is an {AudioTag} member
|
313
|
+
# @return [Array<String>] for a simple property
|
314
|
+
# @return [Array<Hash>] for a complex property
|
315
|
+
# @return [Object] when *key* is not found and a *default* or block given
|
316
|
+
# @raise [KeyError] when *key* is not found and no *default* or block given
|
317
|
+
# @!macro lazy
|
318
|
+
def fetch_all(key, *default, saved: false, lazy: !closed?, &)
|
319
|
+
return @mutated[key] if !saved && @mutated.include?(key)
|
320
|
+
|
321
|
+
case key
|
322
|
+
when String
|
323
|
+
fetch_property(key, *default, lazy:, &)
|
324
|
+
when *AudioTag.members
|
325
|
+
tag(lazy: lazy).to_h.fetch(key, *default, &)
|
326
|
+
when *AudioProperties.members
|
327
|
+
audio_properties.to_h.fetch(key, *default, &)
|
328
|
+
else
|
329
|
+
raise ArgumentError, "Invalid key: #{key}"
|
330
|
+
end
|
331
|
+
end
|
332
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
333
|
+
|
334
|
+
# Set (replace) a key, these are stored in memory and only sent to taglib on {#save!}
|
335
|
+
# @param [String, Symbol] key a property or tag key
|
336
|
+
# @param [Array<String|Hash<String>|Integer>] values
|
337
|
+
# @return [Array<String>] the values set for simple properties
|
338
|
+
# @return [Array<Hash>] the values set for complex properties
|
339
|
+
# @return [String|Integer] the value set for {AudioTag} attributes (Symbol key)
|
340
|
+
def []=(key, *values)
|
341
|
+
case key
|
342
|
+
when String
|
343
|
+
raise ArgumentError, 'expected 2.. arguments, received 1' if values.empty?
|
344
|
+
|
345
|
+
write_string_property(key, values)
|
346
|
+
when *AudioTag.members
|
347
|
+
raise ArgumentError, "expected 2 arguments, receive #{values.size} + 1" unless values.size == 1
|
348
|
+
|
349
|
+
write_property(key, AudioTag.check_value(key, values.first))
|
350
|
+
else
|
351
|
+
raise ArgumentError, "expected String or AudioTag member, received #{key}"
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
# Deletes the entry for the given *key* and returns its previously associated value.
|
356
|
+
# @param [String, Symbol] key
|
357
|
+
# @return [String, Integer, Array<String>, Array<Hash>] the previously associated value for *key*
|
358
|
+
# @return [nil] if *key* was not available
|
359
|
+
def delete(key, &)
|
360
|
+
fetch(key, &).tap { self[key] = nil }
|
361
|
+
rescue KeyError
|
362
|
+
nil
|
363
|
+
end
|
364
|
+
|
365
|
+
# @return [Array<String,Symbol>] the list of available property keys
|
366
|
+
# @!macro lazy
|
367
|
+
def keys(lazy: !closed?)
|
368
|
+
[
|
369
|
+
@mutated.keys,
|
370
|
+
audio_properties.to_h.keys,
|
371
|
+
tag(lazy:).to_h.keys,
|
372
|
+
properties(lazy:).to_h.keys,
|
373
|
+
complex_property_keys(lazy:)
|
374
|
+
].compact.flatten.uniq
|
375
|
+
end
|
376
|
+
|
377
|
+
# rubocop:disable Metrics/AbcSize
|
378
|
+
|
379
|
+
# @param [String|Symbol] key
|
380
|
+
# simple or complex property (String), or a member attribute of {AudioTag} or {AudioProperties} (Symbol)
|
381
|
+
# @param [Boolean] saved if set only saved keys are checked, ie {#modifications} are ignored
|
382
|
+
# @return [Boolean]
|
383
|
+
# @!macro lazy
|
384
|
+
def include?(key, saved: false, lazy: !closed?)
|
385
|
+
return true if !saved && @mutated.keys.include?(key)
|
386
|
+
|
387
|
+
case key
|
388
|
+
when String
|
389
|
+
complex_property_keys(lazy:).include?(key) || properties(lazy:).to_h.key?(key)
|
390
|
+
when *AudioTag.members
|
391
|
+
tag(lazy:).to_h.keys.include?(key)
|
392
|
+
when *AudioProperties.members
|
393
|
+
!!audio_properties
|
394
|
+
else
|
395
|
+
false
|
396
|
+
end
|
397
|
+
end
|
398
|
+
# rubocop:enable Metrics/AbcSize
|
399
|
+
|
400
|
+
alias key? include?
|
401
|
+
alias member? include?
|
402
|
+
|
403
|
+
# Iterates over each key-value pair in the media file's properties
|
404
|
+
#
|
405
|
+
# @yield [key, values]
|
406
|
+
# @yieldparam [String|Symbol] key
|
407
|
+
# @yieldparam [String, Integer, Array<String>, Array<Hash>, nil] value
|
408
|
+
# @return [Enumerator] if no block is given
|
409
|
+
# @return [self] when a block is given
|
410
|
+
# @example Iterating over properties
|
411
|
+
# media_file.each do |key, value|
|
412
|
+
# puts "#{key}: #{value}"
|
413
|
+
# end
|
414
|
+
#
|
415
|
+
# @example Using Enumerable methods
|
416
|
+
# media_file.to_h
|
417
|
+
# @!macro lazy
|
418
|
+
def each
|
419
|
+
return enum_for(:each) unless block_given?
|
420
|
+
|
421
|
+
keys.each do |k|
|
422
|
+
v = fetch_all(k, nil)
|
423
|
+
yield k, v if v
|
424
|
+
end
|
425
|
+
self
|
426
|
+
end
|
427
|
+
|
428
|
+
# @!endgroup
|
429
|
+
|
430
|
+
# @!group Dynamic Property Methods
|
431
|
+
|
432
|
+
# @!visibility private
|
433
|
+
DYNAMIC_METHOD_MATCHER = /^(?<all>all_)?(?<key>[a-z_]+)(?<setter>=)?$/
|
434
|
+
|
435
|
+
# @!visibility private
|
436
|
+
def respond_to_missing?(method, _include_private = false)
|
437
|
+
DYNAMIC_METHOD_MATCHER.match?(method) || super
|
438
|
+
end
|
439
|
+
|
440
|
+
# Provide read/write accessor like semantics for properties
|
441
|
+
#
|
442
|
+
# Method names are converted to tag keys and sent to {#[]} (readers) or {#[]=} (writers).
|
443
|
+
#
|
444
|
+
# Reader methods prefixed with 'all_' will return a list, otherwise the first available value
|
445
|
+
#
|
446
|
+
# Tag keys are generally uppercase and without underscores between words so these are removed.
|
447
|
+
# A double-underscore in a method name will be retained as a single underscore in the tag key.
|
448
|
+
#
|
449
|
+
# Keys with spaces or other non-method matching characters cannot be accessed dynamically.
|
450
|
+
#
|
451
|
+
# @return [String, Array<String>, Hash, Array<Hash>, nil]
|
452
|
+
# @example
|
453
|
+
# mf.composer # -> mf['COMPOSER']
|
454
|
+
# mf.composer = 'New Composer' # -> mf['COMPOSER'] = 'New Composer'
|
455
|
+
# mf.musicbrainz__album_id # -> mf['MUSICBRAINZ_ALBUMID']
|
456
|
+
# mf.custom__tag__id # -> mf['CUSTOM_TAG_ID']
|
457
|
+
# mf.artists # -> mf['ARTISTS']
|
458
|
+
# mf.all_artists # -> mf['ARTISTS', all: true]
|
459
|
+
# @!macro lazy
|
460
|
+
def method_missing(method, *args, &)
|
461
|
+
if (match = method.match(DYNAMIC_METHOD_MATCHER))
|
462
|
+
key = match[:key].gsub('__', '~').delete('_').upcase.gsub('~', '_')
|
463
|
+
if match[:setter]
|
464
|
+
public_send(:[]=, key, *args)
|
465
|
+
else
|
466
|
+
raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 0)" unless args.empty?
|
467
|
+
|
468
|
+
public_send(:[], key, all: match[:all])
|
469
|
+
end
|
470
|
+
else
|
471
|
+
super
|
472
|
+
end
|
473
|
+
end
|
474
|
+
|
475
|
+
# @!endgroup
|
476
|
+
|
477
|
+
# return [Hash<String|Symbol>] accumulated, unsaved properties (frozen)
|
478
|
+
def modifications
|
479
|
+
@mutated.dup.freeze
|
480
|
+
end
|
481
|
+
|
482
|
+
# @return [Boolean] if any properties been updated and not yet saved.
|
483
|
+
# @note Does not check if the values being set are different to their originals, only that something has been set
|
484
|
+
def modified?
|
485
|
+
@mutated.any?
|
486
|
+
end
|
487
|
+
|
488
|
+
# Remove all existing properties from the file. Any pending modifications will be also lost.
|
489
|
+
# @return [self]
|
490
|
+
def clear!
|
491
|
+
@mutated.clear
|
492
|
+
save!(replace_all: true)
|
493
|
+
self
|
494
|
+
end
|
495
|
+
|
496
|
+
# Save accumulated property changes back to the file.
|
497
|
+
# @param [Boolean] replace_all if set the accumulated property changes will replace all previous properties
|
498
|
+
# @return [self]
|
499
|
+
# @raise [IOError] if the file is not {#writable?}
|
500
|
+
# @note all cached data is reset after saving. See {#retrieve}
|
501
|
+
def save!(replace_all: false)
|
502
|
+
# raise error even if nothing written - you shouldn't be making this call
|
503
|
+
raise IOError, 'cannot save, stream not writable' unless writable?
|
504
|
+
|
505
|
+
update(replace_all)
|
506
|
+
@fr.save
|
507
|
+
reset
|
508
|
+
self
|
509
|
+
end
|
510
|
+
|
511
|
+
private
|
512
|
+
|
513
|
+
def_delegators :@fr, :valid?, :read_only?
|
514
|
+
|
515
|
+
# try properties first, then complex properties
|
516
|
+
def fetch_property(key, *default, lazy: !closed?, &)
|
517
|
+
if complex_property_keys(lazy: lazy).include?(key)
|
518
|
+
# first try to lazy fetch complex properties because normal hash fetch does not use the default proc
|
519
|
+
return (lazy && @complex_properties[key]) || @complex_properties.compact.fetch(key, *default, &)
|
520
|
+
end
|
521
|
+
|
522
|
+
(properties(lazy: lazy) || {}).fetch(key, *default, &)
|
523
|
+
end
|
524
|
+
|
525
|
+
def write_string_property(key, values)
|
526
|
+
values.flatten!(1) # leniently allow an explicit array passed as a value
|
527
|
+
values.compact! # explicitly nil resulting in an empty list representing a property to be removed.
|
528
|
+
|
529
|
+
# best efforts fail fast on TypeErrors rather than wait for save
|
530
|
+
Types.check_value_types(values) unless values.empty?
|
531
|
+
write_property(key, values)
|
532
|
+
end
|
533
|
+
|
534
|
+
def write_property(key, values)
|
535
|
+
raise Error, 'Read only stream' unless writable?
|
536
|
+
|
537
|
+
@mutated[key] = values
|
538
|
+
end
|
539
|
+
|
540
|
+
# for #retrieve
|
541
|
+
def retrieve_complex_property_keys(keys)
|
542
|
+
@complex_property_keys, fetch =
|
543
|
+
case keys
|
544
|
+
when false
|
545
|
+
[]
|
546
|
+
when nil
|
547
|
+
@complex_property_keys ||= nil
|
548
|
+
when Array
|
549
|
+
[keys, true]
|
550
|
+
when :lazy
|
551
|
+
[nil]
|
552
|
+
else
|
553
|
+
[@fr.complex_property_keys, keys == :all]
|
554
|
+
end
|
555
|
+
fetch
|
556
|
+
end
|
557
|
+
|
558
|
+
def fill_complex_properties
|
559
|
+
@complex_property_keys&.each { |k| @complex_properties[k] }
|
560
|
+
end
|
561
|
+
|
562
|
+
def update(replace_all)
|
563
|
+
group = @mutated.group_by do |k, v|
|
564
|
+
if k.is_a?(Symbol)
|
565
|
+
:tag
|
566
|
+
elsif Types.complex_property?(k, v)
|
567
|
+
:complex
|
568
|
+
else
|
569
|
+
:standard
|
570
|
+
end
|
571
|
+
end.transform_values(&:to_h)
|
572
|
+
|
573
|
+
%i[standard complex tag].each { |g| send(:"merge_#{g}_properties", group[g], replace_all) }
|
574
|
+
end
|
575
|
+
|
576
|
+
def merge_standard_properties(props, replace_all)
|
577
|
+
@fr.merge_properties(props || {}, replace_all) if replace_all || props&.any?
|
578
|
+
end
|
579
|
+
|
580
|
+
def merge_complex_properties(props, replace_all)
|
581
|
+
@fr.merge_complex_properties(props || {}, replace_all) if replace_all || props&.any?
|
582
|
+
end
|
583
|
+
|
584
|
+
def merge_tag_properties(props, _ignored)
|
585
|
+
@fr.merge_tag_properties(props || {}) if props&.any?
|
586
|
+
end
|
587
|
+
|
588
|
+
def reset
|
589
|
+
(@mutated ||= {}).clear
|
590
|
+
(@complex_properties ||= Hash.new { |h, k| h[k] = @fr.complex_property(k) unless closed? }).clear
|
591
|
+
@complex_property_keys = nil
|
592
|
+
@tag = nil
|
593
|
+
@properties = nil
|
594
|
+
end
|
595
|
+
end
|
596
|
+
|
597
|
+
# rubocop:enable Metrics/ClassLength
|
598
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TagLib
|
4
|
+
# @!visibility private
|
5
|
+
# Type checking methods
|
6
|
+
module Types
|
7
|
+
class << self
|
8
|
+
def check_value_types(values)
|
9
|
+
return if values.empty?
|
10
|
+
return check_complex_property_value(values) if values.first.is_a?(Hash)
|
11
|
+
|
12
|
+
values.each do |v|
|
13
|
+
raise TypeError, "expected property value to be String, received #{v.class.name}" unless v.is_a?(String)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def check_complex_property_value(values)
|
18
|
+
values.each do |v|
|
19
|
+
raise TypeError, "expected complex property value to be Hash, received #{v.class.name}" unless v.is_a?(Hash)
|
20
|
+
|
21
|
+
check_variant_value(v)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def check_variant_value(obj)
|
26
|
+
case obj
|
27
|
+
when String, Integer
|
28
|
+
# nothing to do
|
29
|
+
when Array
|
30
|
+
obj.each { |v| check_variant_value(v) }
|
31
|
+
when Hash
|
32
|
+
obj.each do |k, v|
|
33
|
+
raise TypeError "VariantMap keys must be String, received #{k.class.name}" unless k.is_a?(String)
|
34
|
+
|
35
|
+
check_variant_value(v)
|
36
|
+
end
|
37
|
+
else
|
38
|
+
raise TypeError, "Variant Type expected String, Integer, Array or Hash, received #{obj.class.name}"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def complex_property?(_key, values)
|
43
|
+
values.first.is_a?(Hash)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'taglib_simple/version'
|
4
|
+
require_relative 'taglib_simple/media_file'
|
5
|
+
|
6
|
+
# Ruby interface over TagLib's simple, abstract APIs for audio file tags
|
7
|
+
# @see http://taglib.github.io/
|
8
|
+
module TagLib
|
9
|
+
# @!parse
|
10
|
+
#
|
11
|
+
# # TagLib library major version number
|
12
|
+
# MAJOR_VERSION = runtime_version().major_version()
|
13
|
+
#
|
14
|
+
# # TagLib library minor version number
|
15
|
+
# MINOR_VERSION = runtime_version().minor_version()
|
16
|
+
#
|
17
|
+
# # TagLib library patch version number
|
18
|
+
# PATCH_VERSION = runtime_version().patch_version()
|
19
|
+
#
|
20
|
+
# # TagLib library version - <major>.<minor>.<patch>
|
21
|
+
# LIBRARY_VERSION = runtime_version().toString()
|
22
|
+
end
|