audio_tag 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/.rubocop.yml +78 -0
  4. data/.ruby-version +1 -0
  5. data/Gemfile +9 -0
  6. data/Gemfile.lock +127 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +38 -0
  9. data/Rakefile +10 -0
  10. data/Steepfile +12 -0
  11. data/audio_tag.gemspec +24 -0
  12. data/lib/audio_tag/id3/header.rb +36 -0
  13. data/lib/audio_tag/id3/stream_reader.rb +98 -0
  14. data/lib/audio_tag/id3/stream_section.rb +57 -0
  15. data/lib/audio_tag/id3/synchsafe.rb +19 -0
  16. data/lib/audio_tag/id3/v2/frame.rb +43 -0
  17. data/lib/audio_tag/id3/v2/frame_header.rb +35 -0
  18. data/lib/audio_tag/id3/v2/frame_merger.rb +30 -0
  19. data/lib/audio_tag/id3/v2/frame_type.rb +81 -0
  20. data/lib/audio_tag/id3/v2/frames/comment_frame.rb +32 -0
  21. data/lib/audio_tag/id3/v2/frames/picture_frame.rb +63 -0
  22. data/lib/audio_tag/id3/v2/frames/user_frame.rb +29 -0
  23. data/lib/audio_tag/id3/v2/tag.rb +39 -0
  24. data/lib/audio_tag/id3/v2/tag_header.rb +71 -0
  25. data/lib/audio_tag/id3/v2/text_decoder.rb +33 -0
  26. data/lib/audio_tag/version.rb +3 -0
  27. data/lib/audio_tag.rb +7 -0
  28. data/sig/audio_tag/id3/header.rbs +17 -0
  29. data/sig/audio_tag/id3/stream_reader.rbs +33 -0
  30. data/sig/audio_tag/id3/stream_section.rbs +16 -0
  31. data/sig/audio_tag/id3/synchsafe.rbs +12 -0
  32. data/sig/audio_tag/id3/v2/frame.rbs +24 -0
  33. data/sig/audio_tag/id3/v2/frame_header.rbs +18 -0
  34. data/sig/audio_tag/id3/v2/frame_merger.rbs +20 -0
  35. data/sig/audio_tag/id3/v2/frame_type.rbs +22 -0
  36. data/sig/audio_tag/id3/v2/frames/comment_frame.rbs +23 -0
  37. data/sig/audio_tag/id3/v2/frames/picture_frame.rbs +27 -0
  38. data/sig/audio_tag/id3/v2/frames/user_frame.rbs +22 -0
  39. data/sig/audio_tag/id3/v2/tag.rbs +20 -0
  40. data/sig/audio_tag/id3/v2/tag_header.rbs +36 -0
  41. data/sig/audio_tag/id3/v2/text_decoder.rbs +13 -0
  42. data/sig/audio_tag.rbs +5 -0
  43. data/sig/zeitwerk/gem_inflector.rbs +7 -0
  44. data/sig/zeitwerk/loader.rbs +13 -0
  45. metadata +100 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b2a866655361246969d5b9c9e5eea420ea5da7824bfcb360548f8254dfc90159
4
+ data.tar.gz: 5217901d0cf091b0d72efbace39fdfe57b137c0d711793b4a5d4e11be46259d5
5
+ SHA512:
6
+ metadata.gz: 4939147cc3ff78d702c5e5780ca256b3de3a7a09360d8ad3f99cd92a3d818a32d49681557ce561d2f91d93e92dd005ca6a49c9898733d881e8b629361abf6a4f
7
+ data.tar.gz: 0432477f2ba124bc21606584889499932ca1dddc08ed57cc839822f6f52a95d68579355d630c1e2412b419f053fb82566929f35ee05170c01a37435be4b89636
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,78 @@
1
+ require:
2
+ - rubocop-rspec
3
+
4
+ AllCops:
5
+ NewCops: enable
6
+ SuggestExtensions: false
7
+
8
+ Gemspec/RequireMFA:
9
+ Enabled: false
10
+
11
+ Layout/ExtraSpacing:
12
+ AllowForAlignment: true
13
+ Layout/HashAlignment:
14
+ Enabled: false
15
+ Layout/LineEndStringConcatenationIndentation:
16
+ Enabled: false
17
+ Layout/LineLength:
18
+ Max: 80
19
+ Layout/MultilineMethodCallIndentation:
20
+ Enabled: false
21
+
22
+ Lint/AmbiguousBlockAssociation:
23
+ AllowedMethods:
24
+ - change
25
+ Lint/ConstantDefinitionInBlock:
26
+ Exclude:
27
+ - spec/spec_helper.rb
28
+ Lint/MissingSuper:
29
+ Enabled: false
30
+
31
+ Naming/MethodParameterName:
32
+ AllowedNames:
33
+ - by
34
+ - to
35
+ Naming/VariableNumber:
36
+ Enabled: false
37
+
38
+ Style/CharacterLiteral:
39
+ Enabled: false
40
+ Style/Documentation:
41
+ Enabled: false
42
+ Style/ExplicitBlockArgument:
43
+ Enabled: false
44
+ Style/FormatStringToken:
45
+ EnforcedStyle: template
46
+ Style/FrozenStringLiteralComment:
47
+ Enabled: false
48
+ Style/RescueStandardError:
49
+ EnforcedStyle: implicit
50
+ Style/StringLiterals:
51
+ EnforcedStyle: double_quotes
52
+
53
+ RSpec/AnyInstance:
54
+ Enabled: false
55
+ RSpec/ContextWording:
56
+ Prefixes:
57
+ - when
58
+ - with
59
+ - without
60
+ - if
61
+ - unless
62
+ - and
63
+ - but
64
+ RSpec/DescribeClass:
65
+ Exclude:
66
+ - spec/acceptance/**/*_spec.rb
67
+ RSpec/DescribedClass:
68
+ SkipBlocks: true
69
+ RSpec/ExpectChange:
70
+ EnforcedStyle: block
71
+ RSpec/LetSetup:
72
+ Enabled: false
73
+ RSpec/MultipleMemoizedHelpers:
74
+ Max: 10
75
+ RSpec/NestedGroups:
76
+ Enabled: false
77
+ RSpec/SharedExamples:
78
+ Enabled: false
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.2.0
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rspec", "~> 3.0"
7
+ gem "rubocop", "~> 1.21"
8
+ gem "rubocop-rspec", "~> 2.24"
9
+ gem "steep", "~> 1.5"
data/Gemfile.lock ADDED
@@ -0,0 +1,127 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ audio_tag (0.1.0)
5
+ zeitwerk
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ activesupport (7.1.1)
11
+ base64
12
+ bigdecimal
13
+ concurrent-ruby (~> 1.0, >= 1.0.2)
14
+ connection_pool (>= 2.2.5)
15
+ drb
16
+ i18n (>= 1.6, < 2)
17
+ minitest (>= 5.1)
18
+ mutex_m
19
+ tzinfo (~> 2.0)
20
+ ast (2.4.2)
21
+ base64 (0.2.0)
22
+ bigdecimal (3.1.4)
23
+ concurrent-ruby (1.2.2)
24
+ connection_pool (2.4.1)
25
+ csv (3.2.8)
26
+ diff-lcs (1.5.0)
27
+ drb (2.2.0)
28
+ ruby2_keywords
29
+ ffi (1.16.3)
30
+ fileutils (1.7.2)
31
+ i18n (1.14.1)
32
+ concurrent-ruby (~> 1.0)
33
+ json (2.6.3)
34
+ language_server-protocol (3.17.0.3)
35
+ listen (3.8.0)
36
+ rb-fsevent (~> 0.10, >= 0.10.3)
37
+ rb-inotify (~> 0.9, >= 0.9.10)
38
+ logger (1.6.0)
39
+ minitest (5.20.0)
40
+ mutex_m (0.2.0)
41
+ parallel (1.23.0)
42
+ parser (3.2.2.4)
43
+ ast (~> 2.4.1)
44
+ racc
45
+ racc (1.7.3)
46
+ rainbow (3.1.1)
47
+ rake (13.1.0)
48
+ rb-fsevent (0.11.2)
49
+ rb-inotify (0.10.1)
50
+ ffi (~> 1.0)
51
+ rbs (3.2.2)
52
+ regexp_parser (2.8.2)
53
+ rexml (3.2.6)
54
+ rspec (3.12.0)
55
+ rspec-core (~> 3.12.0)
56
+ rspec-expectations (~> 3.12.0)
57
+ rspec-mocks (~> 3.12.0)
58
+ rspec-core (3.12.2)
59
+ rspec-support (~> 3.12.0)
60
+ rspec-expectations (3.12.3)
61
+ diff-lcs (>= 1.2.0, < 2.0)
62
+ rspec-support (~> 3.12.0)
63
+ rspec-mocks (3.12.6)
64
+ diff-lcs (>= 1.2.0, < 2.0)
65
+ rspec-support (~> 3.12.0)
66
+ rspec-support (3.12.1)
67
+ rubocop (1.57.2)
68
+ json (~> 2.3)
69
+ language_server-protocol (>= 3.17.0)
70
+ parallel (~> 1.10)
71
+ parser (>= 3.2.2.4)
72
+ rainbow (>= 2.2.2, < 4.0)
73
+ regexp_parser (>= 1.8, < 3.0)
74
+ rexml (>= 3.2.5, < 4.0)
75
+ rubocop-ast (>= 1.28.1, < 2.0)
76
+ ruby-progressbar (~> 1.7)
77
+ unicode-display_width (>= 2.4.0, < 3.0)
78
+ rubocop-ast (1.30.0)
79
+ parser (>= 3.2.1.0)
80
+ rubocop-capybara (2.19.0)
81
+ rubocop (~> 1.41)
82
+ rubocop-factory_bot (2.24.0)
83
+ rubocop (~> 1.33)
84
+ rubocop-rspec (2.25.0)
85
+ rubocop (~> 1.40)
86
+ rubocop-capybara (~> 2.17)
87
+ rubocop-factory_bot (~> 2.22)
88
+ ruby-progressbar (1.13.0)
89
+ ruby2_keywords (0.0.5)
90
+ securerandom (0.3.0)
91
+ steep (1.5.3)
92
+ activesupport (>= 5.1)
93
+ concurrent-ruby (>= 1.1.10)
94
+ csv (>= 3.0.9)
95
+ fileutils (>= 1.1.0)
96
+ json (>= 2.1.0)
97
+ language_server-protocol (>= 3.15, < 4.0)
98
+ listen (~> 3.0)
99
+ logger (>= 1.3.0)
100
+ parser (>= 3.1)
101
+ rainbow (>= 2.2.2, < 4.0)
102
+ rbs (>= 3.1.0)
103
+ securerandom (>= 0.1)
104
+ strscan (>= 1.0.0)
105
+ terminal-table (>= 2, < 4)
106
+ strscan (3.0.7)
107
+ terminal-table (3.0.2)
108
+ unicode-display_width (>= 1.1.1, < 3)
109
+ tzinfo (2.0.6)
110
+ concurrent-ruby (~> 1.0)
111
+ unicode-display_width (2.5.0)
112
+ zeitwerk (2.6.12)
113
+
114
+ PLATFORMS
115
+ arm64-darwin-21
116
+ x86_64-linux
117
+
118
+ DEPENDENCIES
119
+ audio_tag!
120
+ rake (~> 13.0)
121
+ rspec (~> 3.0)
122
+ rubocop (~> 1.21)
123
+ rubocop-rspec (~> 2.24)
124
+ steep (~> 1.5)
125
+
126
+ BUNDLED WITH
127
+ 2.4.1
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Chris Welham
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # AudioTag
2
+
3
+ A Ruby library for reading and writing various audio file metadata. Currently only reading ID3v2 MP3 metadata is supported.
4
+
5
+
6
+ ## Installation
7
+
8
+ Install the gem and add to the application's Gemfile by executing:
9
+
10
+ $ bundle add audio_tag
11
+
12
+ If bundler is not being used to manage dependencies, install the gem by executing:
13
+
14
+ $ gem install audio_tag
15
+
16
+
17
+ ## Usage
18
+
19
+ To read MP3 file metadata:
20
+
21
+ ```ruby
22
+ AudioTag::ID3::V2::Tag.from_file(path_to_file).to_h
23
+ ```
24
+
25
+ ## Development
26
+
27
+ - Note that `AudioTag` is still in active development
28
+ - It is planned for further audio metadata formats to be supported in the future
29
+
30
+
31
+ ## License
32
+
33
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
34
+
35
+
36
+ ## Copyright
37
+
38
+ - Please note that the samples in `spec/examples` have been redacted to contain audio metadata only. These files contain no playable audio data.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ require "rubocop/rake_task"
7
+
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
data/Steepfile ADDED
@@ -0,0 +1,12 @@
1
+ target :lib do
2
+ signature "sig"
3
+
4
+ check "lib"
5
+
6
+ configure_code_diagnostics do |hash|
7
+ hash[Steep::Diagnostic::Ruby::FallbackAny] = nil
8
+ hash[Steep::Diagnostic::Ruby::UnknownConstant] = :error
9
+ hash[Steep::Diagnostic::Ruby::MethodDefinitionMissing] = nil
10
+ hash[Steep::Diagnostic::Ruby::UnsupportedSyntax] = :hint
11
+ end
12
+ end
data/audio_tag.gemspec ADDED
@@ -0,0 +1,24 @@
1
+ require_relative "lib/audio_tag/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "audio_tag"
5
+ spec.version = AudioTag::VERSION
6
+ spec.authors = ["Chris Welham"]
7
+ spec.email = ["71787007+apexatoll@users.noreply.github.com"]
8
+
9
+ spec.summary = "Ruby library for reading and writing audio file metadata"
10
+ spec.license = "MIT"
11
+ spec.required_ruby_version = ">= 3.2.0"
12
+
13
+ spec.files = Dir.chdir(__dir__) do
14
+ `git ls-files -z`.split("\x0").reject do |f|
15
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|spec)/|\.(?:git))})
16
+ end
17
+ end
18
+
19
+ spec.bindir = "exe"
20
+ spec.executables = []
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency "zeitwerk"
24
+ end
@@ -0,0 +1,36 @@
1
+ module AudioTag
2
+ module ID3
3
+ class Header < StreamSection
4
+ attr_reader :bytes
5
+
6
+ def bytes_for(key)
7
+ first = structure.index(key) || raise("invalid key :#{key}")
8
+ last = structure.rindex(key)
9
+
10
+ bytes[first..last] || raise
11
+ end
12
+
13
+ private
14
+
15
+ UNEXTENDED_SIZE = 10
16
+
17
+ def size
18
+ UNEXTENDED_SIZE
19
+ end
20
+
21
+ def structure
22
+ raise "structure constant not set" unless structure_defined?
23
+
24
+ self.class.const_get(:STRUCTURE)
25
+ end
26
+
27
+ def structure_defined?
28
+ self.class.const_defined?(:STRUCTURE)
29
+ end
30
+
31
+ def extract!
32
+ @bytes = read_bytes!(size)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,98 @@
1
+ module AudioTag
2
+ module ID3
3
+ # Wrapper for native IO stream objects that defines a consistent reading
4
+ # interface.
5
+ #
6
+ # Bang methods indicate that the read operation is not idempotent, as
7
+ # calling them will result in the stream position being advanced. Non-bang
8
+ # methods are idempotent, as they reset the stream position after reading
9
+ # meaning the net effect on the stream position is 0.
10
+ class StreamReader
11
+ attr_reader :stream
12
+
13
+ def initialize(stream)
14
+ @stream = stream
15
+ end
16
+
17
+ def read!(length)
18
+ stream.read(length) || raise
19
+ end
20
+
21
+ def read(length)
22
+ pos = stream.pos
23
+
24
+ read!(length)
25
+ ensure
26
+ stream.seek(pos)
27
+ end
28
+
29
+ def read_bytes!(length)
30
+ stream.read(length)&.bytes || raise
31
+ end
32
+
33
+ def read_bytes(length)
34
+ pos = stream.pos
35
+
36
+ read_bytes!(length)
37
+ ensure
38
+ stream.seek(pos)
39
+ end
40
+
41
+ def read_byte!
42
+ read_bytes!(1).first || raise
43
+ end
44
+
45
+ def read_byte
46
+ read_bytes(1).first || raise
47
+ end
48
+
49
+ # Reads the stream up to the specified byte, for example a null
50
+ # delimiter. The given byte is not included in the output, but the stream
51
+ # is advanced to the position after it unless the consume_delimiter flag
52
+ # is false.
53
+ #
54
+ # This is useful for extracting string fragments until a delimiter
55
+ # without including it in the extracted fragment nor the start of the
56
+ # next.
57
+ def read_until!(byte, consume_delimiter: true)
58
+ read_bytes_until!(byte, consume_delimiter:).pack("c*")
59
+ end
60
+
61
+ def read_until(byte)
62
+ read_bytes_until(byte).pack("c*")
63
+ end
64
+
65
+ # Uses instance method rather than stream.eof? directly as this can be
66
+ # overridden in child classes to prevent read*_until methods extending
67
+ # past the end of a settable limit.
68
+ def read_bytes_until!(byte, consume_delimiter: true)
69
+ [].tap do |bytes|
70
+ until end_of_stream?
71
+ next_byte = stream.getbyte
72
+
73
+ if next_byte == byte
74
+ stream.ungetbyte(1) unless consume_delimiter
75
+ break
76
+ end
77
+
78
+ bytes << next_byte
79
+ end
80
+ end
81
+ end
82
+
83
+ def read_bytes_until(byte)
84
+ pos = stream.pos
85
+
86
+ read_bytes_until!(byte)
87
+ ensure
88
+ stream.seek(pos)
89
+ end
90
+
91
+ private
92
+
93
+ def end_of_stream?
94
+ stream.eof?
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,57 @@
1
+ module AudioTag
2
+ module ID3
3
+ # Abstract class that models a discrete section of a continuous stream.
4
+ # Child classes must define `#size` and `#extract!` instance methods.
5
+
6
+ # The initial stream position is cached at the point of instantiation and
7
+ # is inferred to be the first position of the section. The last point of
8
+ # the section is calculated by adding the section size to this initial
9
+ # position.
10
+
11
+ # Because of the assumption that the stream is in the correct position for
12
+ # extraction at the point of instantiation, the class performs extraction
13
+ # automatically at this point, rather than exposing a method to do this
14
+ # on-demand. This is not idempotent, but it ensures that the extract!
15
+ # method is always called exactly once for every object.
16
+
17
+ # The overridden extract! method may not need to read all data within the
18
+ # stream section (for example if it contains embedded binary data).
19
+ # Therefore the initializer will also ensure that the stream is advanced to
20
+ # the end of the current section after extraction to prevent serial
21
+ # sections from overlapping.
22
+ class StreamSection < StreamReader
23
+ def initialize(stream)
24
+ super
25
+
26
+ @first_pos = stream.pos
27
+
28
+ extract! && advance!
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :first_pos
34
+
35
+ def last_pos
36
+ @last_pos ||= first_pos + size
37
+ end
38
+
39
+ def size
40
+ raise NotImplementedError, "size is not defined"
41
+ end
42
+
43
+ def extract!
44
+ raise NotImplementedError, "extraction method is not defined"
45
+ end
46
+
47
+ def advance!
48
+ stream.seek(last_pos)
49
+ end
50
+
51
+ # Ensures that read*_until methods do not read past end of the section.
52
+ def end_of_stream?
53
+ super || stream.pos == last_pos
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,19 @@
1
+ module AudioTag
2
+ module ID3
3
+ class Synchsafe
4
+ MAX_BYTE = 128
5
+
6
+ class InvalidBytesError < StandardError
7
+ def message = "MSB of byte must be 0"
8
+ end
9
+
10
+ def self.parse(*bytes)
11
+ raise InvalidBytesError if bytes.any? { |byte| byte >= MAX_BYTE }
12
+
13
+ bytes.reverse_each.with_index.map do |byte, index|
14
+ byte << (7 * index)
15
+ end.reduce(:|)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,43 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class Frame < StreamSection
5
+ attr_reader :header, :raw_data
6
+
7
+ def initialize(stream, header: FrameHeader.new(stream))
8
+ @header = header
9
+
10
+ super(stream)
11
+ end
12
+
13
+ def key
14
+ header.key
15
+ end
16
+
17
+ def data
18
+ TextDecoder.decode(raw_data)
19
+ end
20
+
21
+ def to_h
22
+ { key => data }
23
+ end
24
+
25
+ def self.build(stream)
26
+ header = FrameHeader.new(stream)
27
+
28
+ header.frame_class.new(stream, header:)
29
+ end
30
+
31
+ private
32
+
33
+ def size
34
+ header.frame_size
35
+ end
36
+
37
+ def extract!
38
+ @raw_data = read!(size)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,35 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class FrameHeader < Header
5
+ STRUCTURE = %i[
6
+ id id id id
7
+ size size size size
8
+ flags flags
9
+ ].freeze
10
+
11
+ def id
12
+ @id ||= bytes_for(:id).pack("c*").to_sym
13
+ end
14
+
15
+ def frame_size
16
+ @frame_size ||= Synchsafe.parse(*bytes_for(:size))
17
+ end
18
+
19
+ def key
20
+ @key ||= frame_type.name
21
+ end
22
+
23
+ def frame_class
24
+ @frame_class ||= frame_type.frame_class
25
+ end
26
+
27
+ private
28
+
29
+ def frame_type
30
+ @frame_type ||= FrameType.find(id)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,30 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class FrameMerger
5
+ attr_reader :frames
6
+
7
+ def initialize(frames)
8
+ @frames = frames
9
+ end
10
+
11
+ def merge
12
+ pairs.each_with_object({}) do |(key, value), hash|
13
+ case hash[key]
14
+ when Hash then hash[key].merge!(value)
15
+ when String then hash[key] = [hash[key], value]
16
+ when Array then hash[key] << value
17
+ else hash[key] = value
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def pairs
25
+ frames.map(&:to_h).flat_map(&:to_a)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end