audio_tag 0.1.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.
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
@@ -0,0 +1,81 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class FrameType
5
+ LOOKUP = {
6
+ # 2.3.0
7
+ APIC: { name: :picture, class: Frames::PictureFrame },
8
+ COMM: { name: :comments, class: Frames::CommentFrame },
9
+ TALB: { name: :album },
10
+ TBPM: { name: :tempo },
11
+ TCOM: { name: :composer },
12
+ TCON: { name: :genre },
13
+ TCOP: { name: :copyright },
14
+ TPE1: { name: :artist },
15
+ TKEY: { name: :key },
16
+ TXXX: { name: :user_frames, class: Frames::UserFrame },
17
+ TDAT: { name: :date },
18
+ TDLY: { name: :playlist_delay },
19
+ TENC: { name: :encoded_by },
20
+ TEXT: { name: :text },
21
+ TFLT: { name: :custom },
22
+ TIME: { name: :time },
23
+ TIT1: { name: :content_group_description },
24
+ TIT2: { name: :title },
25
+ TIT3: { name: :subtitle_description_refinement },
26
+ TLAN: { name: :language },
27
+ TLEN: { name: :length },
28
+ TMED: { name: :custom },
29
+ TOAL: { name: :original_album },
30
+ TOFN: { name: :original_filename },
31
+ TOLY: { name: :original_writer },
32
+ TOPE: { name: :original_artist },
33
+ TOWN: { name: :file_owner },
34
+ TPE2: { name: :album_artist },
35
+ TPE3: { name: :conductor },
36
+ TPE4: { name: :other_artist },
37
+ TPOS: { name: :part_in_set },
38
+ TPUB: { name: :label },
39
+ TRCK: { name: :track },
40
+ TRDA: { name: :recording_date },
41
+ TRSN: { name: :radio_station_name },
42
+ TRSO: { name: :radio_station_owner },
43
+ TSIZ: { name: :size },
44
+ TSRC: { name: :isrc },
45
+ TSSE: { name: :encoder },
46
+
47
+ # 2.4.0
48
+ TDEN: { name: :encoded_at },
49
+ TDOR: { name: :originally_released_at },
50
+ TDRC: { name: :date },
51
+ TDRL: { name: :released_at },
52
+ TDTG: { name: :tagged_at },
53
+ TIPL: { name: :involved_people },
54
+ TMCL: { name: :musician_credits },
55
+ TMOO: { name: :mood },
56
+ TPRO: { name: :produced_notice },
57
+ TSOA: { name: :album_sort },
58
+ TSOP: { name: :performer_sort },
59
+ TSOT: { name: :title_sort },
60
+ TSST: { name: :set_subtitle },
61
+ TYER: { name: :year }
62
+ }.freeze
63
+
64
+ DEFAULT_FRAME_CLASS = Frame
65
+
66
+ attr_reader :name, :frame_class
67
+
68
+ def initialize(**attributes)
69
+ @name = attributes[:name]
70
+ @frame_class = attributes[:class] || DEFAULT_FRAME_CLASS
71
+ end
72
+
73
+ def self.find(key)
74
+ attributes = LOOKUP[key] || { name: key }
75
+
76
+ new(**attributes)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,32 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ module Frames
5
+ class CommentFrame < Frame
6
+ def data
7
+ { language => { description => text } }
8
+ end
9
+
10
+ private
11
+
12
+ attr_reader :encoding, :text
13
+
14
+ def description
15
+ @description.empty? ? :_ : @description.to_sym
16
+ end
17
+
18
+ def language
19
+ @language.to_sym
20
+ end
21
+
22
+ def extract!
23
+ @encoding = read_byte!
24
+ @language = read!(3)
25
+ @description = read_until!(0x00)
26
+ @text = read_until!(0x00)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,63 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ module Frames
5
+ class PictureFrame < Frame
6
+ ENCODINGS = %i[
7
+ ascii
8
+ utf_16
9
+ utf_16be
10
+ utf_8
11
+ ].freeze
12
+
13
+ TYPES = %i[
14
+ other
15
+ thumbnail
16
+ other_thumbnail
17
+ front_cover
18
+ back_cover
19
+ leaflet_page
20
+ media_page
21
+ lead_artist
22
+ artist
23
+ conductor
24
+ band
25
+ composer
26
+ lyricist
27
+ recording_location
28
+ during_recording
29
+ during_performance
30
+ movie_capture
31
+ bright_colored_fish
32
+ illustration
33
+ band_logo
34
+ publisher_logo
35
+ ].freeze
36
+
37
+ def data
38
+ { encoding:, mime:, type:, description: }
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :mime, :description
44
+
45
+ def encoding
46
+ ENCODINGS[@encoding]
47
+ end
48
+
49
+ def type
50
+ TYPES[@type]
51
+ end
52
+
53
+ def extract!
54
+ @encoding = read_byte!
55
+ @mime = read_until!(0x00)
56
+ @type = read_byte!
57
+ @description = read_until!(0x00)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,29 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ module Frames
5
+ class UserFrame < Frame
6
+ DESCRIPTIONS = {}.freeze
7
+
8
+ def data
9
+ { description => value }
10
+ end
11
+
12
+ private
13
+
14
+ attr_reader :encoding, :value
15
+
16
+ def description
17
+ DESCRIPTIONS[@description.to_sym] || @description.downcase.to_sym
18
+ end
19
+
20
+ def extract!
21
+ @encoding = read_byte!
22
+ @description = read_until!(0x00)
23
+ @value = read_until!(0x00)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,39 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class Tag < StreamSection
5
+ attr_reader :header, :frames
6
+
7
+ def initialize(stream)
8
+ @header = TagHeader.new(stream)
9
+
10
+ super(stream)
11
+ end
12
+
13
+ def to_h
14
+ FrameMerger.new(frames).merge
15
+ end
16
+
17
+ def self.from_file(path)
18
+ File.open(path) { |file| new(file) }
19
+ end
20
+
21
+ private
22
+
23
+ def end_of_tag?
24
+ stream.pos >= last_pos || read_byte.zero?
25
+ end
26
+
27
+ def size
28
+ header.tag_size
29
+ end
30
+
31
+ def extract!
32
+ @frames = [].tap do |frames|
33
+ frames << Frame.build(stream) until end_of_tag?
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,71 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class TagHeader < Header
5
+ STRUCTURE = %i[
6
+ identifier identifier identifier
7
+ version
8
+ revision
9
+ flags
10
+ size size size size
11
+ ].freeze
12
+
13
+ IDENTIFIER = "ID3".freeze
14
+
15
+ INVALID_FLAG_MASK = 0x8F
16
+ EXPERIMENTAL_MASK = 0x10
17
+ EXTENDED_MASK = 0x20
18
+ UNSYNCHRONISED_MASK = 0x40
19
+
20
+ def initialize(stream)
21
+ super
22
+
23
+ validate_identifier!
24
+ validate_flags!
25
+ end
26
+
27
+ def experimental?
28
+ (flags & EXPERIMENTAL_MASK).positive?
29
+ end
30
+
31
+ def extended?
32
+ (flags & EXTENDED_MASK).positive?
33
+ end
34
+
35
+ def unsynchronised?
36
+ (flags & UNSYNCHRONISED_MASK).positive?
37
+ end
38
+
39
+ def tag_size
40
+ @tag_size ||= Synchsafe.parse(*bytes_for(:size))
41
+ end
42
+
43
+ def total_size
44
+ @total_size ||= tag_size + size
45
+ end
46
+
47
+ private
48
+
49
+ def identifier
50
+ @identifier ||= bytes_for(:identifier).pack("c*")
51
+ end
52
+
53
+ def flags
54
+ @flags ||= bytes_for(:flags).first || raise
55
+ end
56
+
57
+ def validate_identifier!
58
+ return if identifier == IDENTIFIER
59
+
60
+ raise "invalid ID3 tag identifier: #{identifier}"
61
+ end
62
+
63
+ def validate_flags!
64
+ return if (flags & INVALID_FLAG_MASK).zero?
65
+
66
+ raise "invalid flags set"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,33 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class TextDecoder
5
+ ENCODINGS = {
6
+ ascii: /^\x00(.*?)(\x00)?$/,
7
+ utf_16: /^\x01(.*?)(\x00\x00)?$/,
8
+ utf_16be: /^\x02(.*?)(\x00\x00)?$/,
9
+ utf_8: /^\x03(.*?)(\x00)?$/
10
+ }.freeze
11
+
12
+ def self.encoding(string)
13
+ case string
14
+ when ENCODINGS[:ascii] then :ascii
15
+ when ENCODINGS[:utf_16] then :utf_16
16
+ when ENCODINGS[:utf_16be] then :utf_16be
17
+ when ENCODINGS[:utf_8] then :utf_8
18
+ end
19
+ end
20
+
21
+ def self.decode(string)
22
+ case encoding(string)
23
+ when :ascii then string.gsub(ENCODINGS[:ascii], "\\1")
24
+ when :utf_16 then string.gsub(ENCODINGS[:utf_16], "\\1")
25
+ when :utf_16be then string.gsub(ENCODINGS[:utf_16be], "\\1")
26
+ when :utf_8 then string.gsub(ENCODINGS[:utf_8], "\\1")
27
+ else string
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ module AudioTag
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/audio_tag.rb ADDED
@@ -0,0 +1,7 @@
1
+ require "zeitwerk"
2
+
3
+ module AudioTag
4
+ Zeitwerk::Loader.for_gem(warn_on_extra_files: false).tap do |loader|
5
+ loader.inflector.inflect("id3" => "ID3")
6
+ end.setup
7
+ end
@@ -0,0 +1,17 @@
1
+ module AudioTag
2
+ module ID3
3
+ class Header < StreamSection
4
+ attr_reader bytes: bytes
5
+
6
+ def bytes_for: (Symbol) -> bytes
7
+
8
+ private
9
+
10
+ UNEXTENDED_SIZE: Integer
11
+
12
+ def structure: -> Array[Symbol]
13
+
14
+ def structure_defined?: -> bool
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,33 @@
1
+ module AudioTag
2
+ module ID3
3
+ class StreamReader
4
+ attr_reader stream: IO
5
+
6
+ def initialize: (IO) -> void
7
+
8
+ def read!: (Integer) -> String
9
+
10
+ def read: (Integer) -> String
11
+
12
+ def read_bytes!: (Integer) -> bytes
13
+
14
+ def read_bytes: (Integer) -> bytes
15
+
16
+ def read_byte!: -> Integer
17
+
18
+ def read_byte: -> Integer
19
+
20
+ def read_until!: (Integer, ?consume_delimiter: bool) -> String
21
+
22
+ def read_until: (Integer) -> String
23
+
24
+ def read_bytes_until!: (Integer, ?consume_delimiter: bool) -> bytes
25
+
26
+ def read_bytes_until: (Integer) -> bytes
27
+
28
+ private
29
+
30
+ def end_of_stream?: -> bool
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,16 @@
1
+ module AudioTag
2
+ module ID3
3
+ class StreamSection < StreamReader
4
+ private
5
+
6
+ attr_reader first_pos: Integer
7
+ attr_reader last_pos: Integer
8
+
9
+ def size: -> Integer
10
+
11
+ def extract!: -> void
12
+
13
+ def advance!: -> void
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ module AudioTag
2
+ module ID3
3
+ class Synchsafe
4
+ MAX_BYTE: Integer
5
+
6
+ class InvalidBytesError < StandardError
7
+ end
8
+
9
+ def self.parse: (*Integer) -> Integer
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,24 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class Frame < StreamSection
5
+ attr_reader header: FrameHeader
6
+ attr_reader raw_data: String
7
+
8
+ def initialize: (IO, ?header: FrameHeader) -> void
9
+
10
+ def key: -> Symbol
11
+
12
+ def data: -> untyped
13
+
14
+ def to_h: -> Hash[Symbol, untyped]
15
+
16
+ def self.build: (IO) -> Frame
17
+
18
+ private
19
+
20
+ def size: -> Integer
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,18 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class FrameHeader < Header
5
+ STRUCTURE: Array[Symbol]
6
+
7
+ attr_reader id: Symbol
8
+ attr_reader frame_size: Integer
9
+ attr_reader key: Symbol
10
+ attr_reader frame_class: singleton(Frame)
11
+
12
+ private
13
+
14
+ attr_reader frame_type: FrameType
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class FrameMerger
5
+
6
+ type frames = Array[Frame]
7
+
8
+ attr_reader frames: frames
9
+
10
+ def initialize: (frames) -> void
11
+
12
+ def merge: -> Hash[Symbol, untyped]
13
+
14
+ private
15
+
16
+ def pairs: -> Array[[Symbol, untyped]]
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class FrameType
5
+ type frame_class = singleton(Frame)
6
+
7
+ type frame_attributes = Hash[Symbol, untyped]
8
+
9
+ LOOKUP: Hash[Symbol, frame_attributes]
10
+
11
+ DEFAULT_FRAME_CLASS: frame_class
12
+
13
+ attr_reader name: Symbol
14
+ attr_reader frame_class: frame_class
15
+
16
+ def initialize: (**untyped) -> void
17
+
18
+ def self.find: (Symbol) -> instance
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ module Frames
5
+ class CommentFrame < Frame
6
+ def data: -> Hash[Symbol, Hash[Symbol, String]]
7
+
8
+ private
9
+
10
+ attr_reader encoding: Integer
11
+ attr_reader text: String
12
+
13
+ @description: String
14
+ @language: String
15
+
16
+ def description: -> Symbol
17
+
18
+ def language: -> Symbol
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ module Frames
5
+ class PictureFrame < Frame
6
+ ENCODINGS: Array[Symbol]
7
+
8
+ TYPES: Array[Symbol]
9
+
10
+ def data: -> Hash[Symbol, untyped]
11
+
12
+ private
13
+
14
+ attr_reader mime: String
15
+ attr_reader description: String
16
+
17
+ @encoding: Integer
18
+ @type: Integer
19
+
20
+ def encoding: -> Symbol
21
+
22
+ def type: -> Symbol
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ module Frames
5
+ class UserFrame < Frame
6
+ DESCRIPTIONS: Hash[Symbol, Symbol]
7
+
8
+ def data: -> Hash[Symbol, untyped]
9
+
10
+ private
11
+
12
+ attr_reader encoding: Integer
13
+ attr_reader value: String
14
+
15
+ @description: String
16
+
17
+ def description: -> Symbol
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class Tag < StreamSection
5
+ attr_reader header: TagHeader
6
+ attr_reader frames: Array[Frame]
7
+
8
+ def initialize: (IO) -> void
9
+
10
+ def to_h: -> Hash[Symbol, untyped]
11
+
12
+ def self.from_file: (String) -> Tag
13
+
14
+ private
15
+
16
+ def end_of_tag?: -> bool
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,36 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class TagHeader < Header
5
+ STRUCTURE: Array[Symbol]
6
+
7
+ IDENTIFIER: String
8
+
9
+ INVALID_FLAG_MASK: Integer
10
+ EXPERIMENTAL_MASK: Integer
11
+ EXTENDED_MASK: Integer
12
+ UNSYNCHRONISED_MASK: Integer
13
+
14
+ def experimental?: -> bool
15
+
16
+ def extended?: -> bool
17
+
18
+ def unsynchronised?: -> bool
19
+
20
+ attr_reader tag_size: Integer
21
+
22
+ attr_reader total_size: Integer
23
+
24
+ private
25
+
26
+ attr_reader identifier: String
27
+
28
+ attr_reader flags: Integer
29
+
30
+ def validate_identifier!: -> void
31
+
32
+ def validate_flags!: -> void
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,13 @@
1
+ module AudioTag
2
+ module ID3
3
+ module V2
4
+ class TextDecoder
5
+ ENCODINGS: Hash[Symbol, Regexp]
6
+
7
+ def self.encoding: (String) -> Symbol?
8
+
9
+ def self.decode: (String) -> String
10
+ end
11
+ end
12
+ end
13
+ end
data/sig/audio_tag.rbs ADDED
@@ -0,0 +1,5 @@
1
+ module AudioTag
2
+ VERSION: String
3
+
4
+ type bytes = Array[Integer]
5
+ end