taglib-simple 0.1.1
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 +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
|