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
@@ -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