id3tag 0.0.0 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/.travis.yml +7 -0
- data/Gemfile +9 -9
- data/Gemfile.lock +36 -14
- data/README.md +73 -0
- data/Rakefile +3 -2
- data/VERSION +1 -1
- data/id3tag.gemspec +75 -19
- data/lib/id3tag.rb +34 -0
- data/lib/id3tag/audio_file.rb +90 -0
- data/lib/id3tag/frame_id_advisor.rb +50 -0
- data/lib/id3tag/frames/util/genre_names.rb +140 -0
- data/lib/id3tag/frames/v1/comments_frame.rb +24 -0
- data/lib/id3tag/frames/v1/genre_frame.rb +19 -0
- data/lib/id3tag/frames/v1/text_frame.rb +27 -0
- data/lib/id3tag/frames/v1/track_nr_frame.rb +18 -0
- data/lib/id3tag/frames/v2/basic_frame.rb +26 -0
- data/lib/id3tag/frames/v2/comments_frame.rb +44 -0
- data/lib/id3tag/frames/v2/frame_fabricator.rb +36 -0
- data/lib/id3tag/frames/v2/genre_frame.rb +34 -0
- data/lib/id3tag/frames/v2/genre_frame/genre_parser.rb +36 -0
- data/lib/id3tag/frames/v2/genre_frame/genre_parser_24.rb +15 -0
- data/lib/id3tag/frames/v2/genre_frame/genre_parser_pre_24.rb +39 -0
- data/lib/id3tag/frames/v2/text_frame.rb +37 -0
- data/lib/id3tag/frames/v2/unique_file_id_frame.rb +25 -0
- data/lib/id3tag/id3_v1_frame_parser.rb +87 -0
- data/lib/id3tag/id3_v2_frame_parser.rb +72 -0
- data/lib/id3tag/id3_v2_tag_header.rb +61 -0
- data/lib/id3tag/number_util.rb +14 -0
- data/lib/id3tag/string_util.rb +7 -0
- data/lib/id3tag/synchsafe_integer.rb +28 -0
- data/lib/id3tag/tag.rb +121 -0
- data/spec/fixtures/id3v1_and_v2.mp3 +0 -0
- data/spec/fixtures/id3v1_with_track_nr.mp3 +0 -0
- data/spec/fixtures/id3v1_without_track_nr.mp3 +0 -0
- data/spec/fixtures/id3v2.mp3 +0 -0
- data/spec/lib/id3tag/audio_file_spec.rb +40 -0
- data/spec/lib/id3tag/frames/util/genre_name_by_id_finder_spec.rb +18 -0
- data/spec/lib/id3tag/frames/v1/comments_frame_spec.rb +31 -0
- data/spec/lib/id3tag/frames/v1/genre_frame_spec.rb +20 -0
- data/spec/lib/id3tag/frames/v1/text_frame_spec.rb +14 -0
- data/spec/lib/id3tag/frames/v1/track_nr_frame_spec.rb +19 -0
- data/spec/lib/id3tag/frames/v2/basic_frame_spec.rb +25 -0
- data/spec/lib/id3tag/frames/v2/comments_frame_spec.rb +45 -0
- data/spec/lib/id3tag/frames/v2/genre_frame/genre_parser_24_spec.rb +26 -0
- data/spec/lib/id3tag/frames/v2/genre_frame/genre_parser_pre_24_spec.rb +48 -0
- data/spec/lib/id3tag/frames/v2/genre_frame_spec.rb +44 -0
- data/spec/lib/id3tag/frames/v2/text_frame_spec.rb +59 -0
- data/spec/lib/id3tag/frames/v2/unique_file_id_frame_spec.rb +31 -0
- data/spec/lib/id3tag/id3_v1_frame_parser_spec.rb +67 -0
- data/spec/lib/id3tag/id3_v2_frame_parser_spec.rb +18 -0
- data/spec/lib/id3tag/id3_v2_tag_header_spec.rb +60 -0
- data/spec/lib/id3tag/id3tag_spec.rb +17 -0
- data/spec/lib/id3tag/number_util_spec.rb +24 -0
- data/spec/lib/id3tag/string_util_spec.rb +21 -0
- data/spec/lib/id3tag/synchsafe_integer_spec.rb +14 -0
- data/spec/lib/id3tag/tag_spec.rb +84 -0
- data/spec/spec_helper.rb +9 -1
- data/spec/support/mp3_fixtures.rb +4 -0
- metadata +102 -38
- data/README.rdoc +0 -19
- data/spec/id3tag_spec.rb +0 -7
@@ -0,0 +1,39 @@
|
|
1
|
+
module ID3Tag
|
2
|
+
module Frames
|
3
|
+
module V2
|
4
|
+
class GenreFrame
|
5
|
+
class GenreParserPre24 < GenreParser
|
6
|
+
REFINEMENT_IN_PARENTHESES = /(\(\([^()]+\))/
|
7
|
+
GENRE_NUMBERS_OR_NAMES = /\(([^)]+)\)/
|
8
|
+
REFINEMENTS = /([)](?<blank>)[(][^(])|(\)(?<regular>[^()]+)[)]*)|(\((?<in_parentheses>\([^()]+\)))/
|
9
|
+
|
10
|
+
def genres
|
11
|
+
result = []
|
12
|
+
just_genres.each_with_index do |genre, index|
|
13
|
+
if ID3Tag::StringUtil.blank?(just_requirements[index])
|
14
|
+
result << expand_abbreviation(genre)
|
15
|
+
else
|
16
|
+
result << just_requirements[index]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
result
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def just_genres
|
25
|
+
genre_string.gsub(REFINEMENT_IN_PARENTHESES, '').scan(GENRE_NUMBERS_OR_NAMES).flatten
|
26
|
+
end
|
27
|
+
|
28
|
+
def just_requirements
|
29
|
+
result = []
|
30
|
+
genre_string.scan(REFINEMENTS) do |blank, regular, in_parentheses|
|
31
|
+
result << (regular || in_parentheses || blank)
|
32
|
+
end
|
33
|
+
result
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module ID3Tag
|
2
|
+
module Frames
|
3
|
+
module V2
|
4
|
+
class TextFrame < BasicFrame
|
5
|
+
class UnsupportedTextEncoding < StandardError; end
|
6
|
+
ENCODING_MAP = {
|
7
|
+
0b0 => Encoding::ISO8859_1,
|
8
|
+
0b1 => Encoding::UTF_16,
|
9
|
+
0b10 => Encoding::UTF_16BE,
|
10
|
+
0b11 => Encoding::UTF_8
|
11
|
+
}
|
12
|
+
|
13
|
+
def content
|
14
|
+
@content ||= content_without_encoding_byte.encode(destination_encoding, source_encoding)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def source_encoding
|
20
|
+
ENCODING_MAP[get_encoding_byte] || raise(UnsupportedTextEncoding)
|
21
|
+
end
|
22
|
+
|
23
|
+
def destination_encoding
|
24
|
+
Encoding::UTF_8
|
25
|
+
end
|
26
|
+
|
27
|
+
def get_encoding_byte
|
28
|
+
@raw_content.getbyte(0)
|
29
|
+
end
|
30
|
+
|
31
|
+
def content_without_encoding_byte
|
32
|
+
@raw_content.byteslice(1, @raw_content.bytesize - 1)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module ID3Tag
|
2
|
+
module Frames
|
3
|
+
module V2
|
4
|
+
class UniqueFileIdFrame < BasicFrame
|
5
|
+
def owner_identifier
|
6
|
+
content_split_apart_by_null_byte.first
|
7
|
+
end
|
8
|
+
|
9
|
+
def content
|
10
|
+
content_split_apart_by_null_byte.last
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def content_split_apart_by_null_byte
|
16
|
+
@raw_content.split("\x00", 2)
|
17
|
+
end
|
18
|
+
|
19
|
+
def inspect_content
|
20
|
+
"#{owner_identifier}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
module ID3Tag
|
2
|
+
class ID3V1FrameParser
|
3
|
+
|
4
|
+
def initialize(input)
|
5
|
+
@input = StringIO.new(input)
|
6
|
+
end
|
7
|
+
|
8
|
+
def frames
|
9
|
+
@frames ||= get_frames
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def get_frames
|
15
|
+
frames = Set.new
|
16
|
+
frames << title_frame
|
17
|
+
frames << artist_frame
|
18
|
+
frames << album_frame
|
19
|
+
frames << year_frame
|
20
|
+
frames << comment_frame
|
21
|
+
frames << genre_frame
|
22
|
+
frames << track_nr_frame if tag_version_1_1?
|
23
|
+
frames
|
24
|
+
end
|
25
|
+
|
26
|
+
def title_frame
|
27
|
+
Frames::V1::TextFrame.new(:title, read_from_pos_till_null_byte_or_limit(0, 30))
|
28
|
+
end
|
29
|
+
|
30
|
+
def artist_frame
|
31
|
+
Frames::V1::TextFrame.new(:artist, read_from_pos_till_null_byte_or_limit(30, 30))
|
32
|
+
end
|
33
|
+
|
34
|
+
def album_frame
|
35
|
+
Frames::V1::TextFrame.new(:album, read_from_pos_till_null_byte_or_limit(60, 30))
|
36
|
+
end
|
37
|
+
|
38
|
+
def year_frame
|
39
|
+
Frames::V1::TextFrame.new(:year, read_from_pos_till_null_byte_or_limit(90, 4))
|
40
|
+
end
|
41
|
+
|
42
|
+
def comment_frame
|
43
|
+
Frames::V1::CommentsFrame.new(:comments, read_from_pos_till_null_byte_or_limit(94, comment_frame_size))
|
44
|
+
end
|
45
|
+
|
46
|
+
def genre_frame
|
47
|
+
Frames::V1::GenreFrame.new(:genre, read_from_pos_till_limit(124, 1))
|
48
|
+
end
|
49
|
+
|
50
|
+
def track_nr_frame
|
51
|
+
Frames::V1::TrackNrFrame.new(:track_nr, read_from_pos_till_null_byte_or_limit(123, 1))
|
52
|
+
end
|
53
|
+
|
54
|
+
def comment_frame_size
|
55
|
+
if tag_version_1_1?
|
56
|
+
28
|
57
|
+
else
|
58
|
+
30
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def tag_version_1_1?
|
63
|
+
@tag_version_1_1 ||= get_tag_version_1_1?
|
64
|
+
end
|
65
|
+
|
66
|
+
def get_tag_version_1_1?
|
67
|
+
second_last_comment_byte, last_comment_byte = read_from_pos_till_limit(122, 2).bytes.to_a
|
68
|
+
(second_last_comment_byte == 0) && (last_comment_byte != 0)
|
69
|
+
end
|
70
|
+
|
71
|
+
def read_from_pos_till_null_byte_or_limit(position, limit)
|
72
|
+
@input.seek(position)
|
73
|
+
value = ""
|
74
|
+
limit.times do
|
75
|
+
char = @input.getc
|
76
|
+
break if char == "\x00"
|
77
|
+
value << char
|
78
|
+
end
|
79
|
+
value
|
80
|
+
end
|
81
|
+
|
82
|
+
def read_from_pos_till_limit(position, limit)
|
83
|
+
@input.seek(position)
|
84
|
+
@input.read(limit)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module ID3Tag
|
2
|
+
class ID3V2FrameParser
|
3
|
+
def initialize(input, major_version_number)
|
4
|
+
@input, @major_version_number = StringIO.new(input), major_version_number
|
5
|
+
end
|
6
|
+
|
7
|
+
def frames
|
8
|
+
@frames ||= get_frames
|
9
|
+
end
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
def get_frames
|
14
|
+
frames = []
|
15
|
+
rewind_input
|
16
|
+
loop do
|
17
|
+
frame_id = read_next_frame_id
|
18
|
+
frame_size = read_next_frame_size
|
19
|
+
frame_flags = read_next_frame_flags
|
20
|
+
frame_content = read_next_bytes(frame_size)
|
21
|
+
frames << Frames::V2::FrameFabricator.fabricate(frame_id, frame_content, frame_flags, @major_version_number)
|
22
|
+
break if padding_or_eof_reached?
|
23
|
+
end
|
24
|
+
frames
|
25
|
+
end
|
26
|
+
|
27
|
+
def read_next_frame_id
|
28
|
+
read_next_bytes(frame_id_length)
|
29
|
+
end
|
30
|
+
|
31
|
+
def read_next_frame_size
|
32
|
+
size_bytes = case frame_size_length
|
33
|
+
when 4
|
34
|
+
read_next_bytes(frame_size_length)
|
35
|
+
when 3
|
36
|
+
"\x00" + read_next_bytes(frame_size_length)
|
37
|
+
end
|
38
|
+
SynchsafeInteger.decode(NumberUtil.convert_string_to_32bit_integer(size_bytes))
|
39
|
+
end
|
40
|
+
|
41
|
+
def read_next_frame_flags
|
42
|
+
frames_has_flags? ? read_next_bytes(2) : nil
|
43
|
+
end
|
44
|
+
|
45
|
+
def frame_id_length
|
46
|
+
@major_version_number <= 2 ? 3 : 4
|
47
|
+
end
|
48
|
+
|
49
|
+
def frames_has_flags?
|
50
|
+
@major_version_number > 2
|
51
|
+
end
|
52
|
+
|
53
|
+
def frame_size_length
|
54
|
+
@major_version_number <= 2 ? 3 : 4
|
55
|
+
end
|
56
|
+
|
57
|
+
def read_next_bytes(limit)
|
58
|
+
@input.read(limit)
|
59
|
+
end
|
60
|
+
|
61
|
+
def rewind_input
|
62
|
+
@input.rewind
|
63
|
+
end
|
64
|
+
|
65
|
+
def padding_or_eof_reached?
|
66
|
+
current_position = @input.pos
|
67
|
+
next_byte = @input.getbyte
|
68
|
+
@input.pos = current_position
|
69
|
+
(next_byte == 0) || @input.eof?
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
module ID3Tag
|
2
|
+
class ID3v2TagHeader
|
3
|
+
def initialize(header_chunk)
|
4
|
+
@content = StringIO.new(header_chunk)
|
5
|
+
end
|
6
|
+
|
7
|
+
def major_version_number
|
8
|
+
@content.seek(3)
|
9
|
+
@content.readbyte
|
10
|
+
end
|
11
|
+
|
12
|
+
def minor_version_number
|
13
|
+
@content.seek(4)
|
14
|
+
@content.readbyte
|
15
|
+
end
|
16
|
+
|
17
|
+
def version_number
|
18
|
+
sprintf("%s.%s", major_version_number, minor_version_number)
|
19
|
+
end
|
20
|
+
|
21
|
+
def unsynchronisation?
|
22
|
+
flags_byte[7] == 1
|
23
|
+
end
|
24
|
+
|
25
|
+
def extended_header?
|
26
|
+
flags_byte[6] == 1
|
27
|
+
end
|
28
|
+
|
29
|
+
def experimental?
|
30
|
+
flags_byte[5] == 1
|
31
|
+
end
|
32
|
+
|
33
|
+
def footer_present?
|
34
|
+
flags_byte[4] == 1
|
35
|
+
end
|
36
|
+
|
37
|
+
def tag_size
|
38
|
+
@tag_size ||= get_tag_size
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def flags_byte
|
44
|
+
@flags_byte ||= get_flags_byte
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_flags_byte
|
48
|
+
@content.seek(5)
|
49
|
+
@content.readbyte
|
50
|
+
end
|
51
|
+
|
52
|
+
def get_tag_size
|
53
|
+
SynchsafeInteger.decode(NumberUtil.convert_string_to_32bit_integer(tag_size_bytes))
|
54
|
+
end
|
55
|
+
|
56
|
+
def tag_size_bytes
|
57
|
+
@content.seek(6)
|
58
|
+
@content.read(4)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module ID3Tag
|
2
|
+
module NumberUtil
|
3
|
+
FORMAT_FOR_8_BIT_SIGNED_INTEGER = 'c'
|
4
|
+
FORMAT_FOR_32BIT_INTEGER = 'N'
|
5
|
+
def self.convert_string_to_32bit_integer(string)
|
6
|
+
integers = string.unpack(FORMAT_FOR_32BIT_INTEGER)
|
7
|
+
integers.first || raise(ArgumentError, "String: '#{string}' could not be decoded as 32-bit integer")
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.convert_32bit_integer_to_string(integer)
|
11
|
+
[integer].pack(FORMAT_FOR_32BIT_INTEGER)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module ID3Tag
|
2
|
+
class SynchsafeInteger
|
3
|
+
class << self
|
4
|
+
def encode(int)
|
5
|
+
mask = 0x7F
|
6
|
+
while mask <= 0x7FFFFFFF do
|
7
|
+
out = int & ~mask
|
8
|
+
out = out << 1
|
9
|
+
out = out | (int & mask)
|
10
|
+
mask = ((mask + 1) << 8) - 1
|
11
|
+
int = out
|
12
|
+
end
|
13
|
+
out
|
14
|
+
end
|
15
|
+
|
16
|
+
def decode(synchsafe_int)
|
17
|
+
out = 0
|
18
|
+
mask = 0x7F000000
|
19
|
+
while mask > 0 do
|
20
|
+
out = out >> 1
|
21
|
+
out = out | (synchsafe_int & mask)
|
22
|
+
mask = mask >> 8
|
23
|
+
end
|
24
|
+
out
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/id3tag/tag.rb
ADDED
@@ -0,0 +1,121 @@
|
|
1
|
+
module ID3Tag
|
2
|
+
class Tag
|
3
|
+
class MultipleFrameError < StandardError; end
|
4
|
+
|
5
|
+
class << self
|
6
|
+
def read(source, version = nil)
|
7
|
+
new(source, version)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(source, version = nil)
|
12
|
+
@source, @version = source, version
|
13
|
+
end
|
14
|
+
|
15
|
+
def artist
|
16
|
+
get_frame_content(frame_id_by_name(:artist))
|
17
|
+
end
|
18
|
+
|
19
|
+
def title
|
20
|
+
get_frame_content(frame_id_by_name(:title))
|
21
|
+
end
|
22
|
+
|
23
|
+
def album
|
24
|
+
get_frame_content(frame_id_by_name(:album))
|
25
|
+
end
|
26
|
+
|
27
|
+
def year
|
28
|
+
get_frame_content(frame_id_by_name(:year))
|
29
|
+
end
|
30
|
+
|
31
|
+
def comments(language = nil)
|
32
|
+
all_comments_frames = get_frames(frame_id_by_name(:comments))
|
33
|
+
comments_frame = if language
|
34
|
+
all_comments_frames.select { |frame| frame.language == language.to_s.downcase }.first
|
35
|
+
else
|
36
|
+
in_english = all_comments_frames.select { |frame| frame.language == 'eng' }
|
37
|
+
in_english.first || all_comments_frames.first
|
38
|
+
end
|
39
|
+
comments_frame && comments_frame.content
|
40
|
+
end
|
41
|
+
|
42
|
+
def track_nr
|
43
|
+
get_frame_content(frame_id_by_name(:track_nr))
|
44
|
+
end
|
45
|
+
|
46
|
+
def genre
|
47
|
+
get_frame_content(frame_id_by_name(:genre))
|
48
|
+
end
|
49
|
+
|
50
|
+
def get_frame(frame_id)
|
51
|
+
frames = get_frames(frame_id)
|
52
|
+
if frames.count > 1
|
53
|
+
raise MultipleFrameError, "Could not return only one frame with id: #{frame_id}. Tag has #{frames.count} of them"
|
54
|
+
else
|
55
|
+
frames.first
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def get_frames(frame_id)
|
60
|
+
frames.select { |frame| frame.id == frame_id }
|
61
|
+
end
|
62
|
+
|
63
|
+
def frame_ids
|
64
|
+
frames.map { |frame| frame.id }
|
65
|
+
end
|
66
|
+
|
67
|
+
def frames
|
68
|
+
@frames ||= parse_frames
|
69
|
+
end
|
70
|
+
|
71
|
+
def get_frame_content(frame_id)
|
72
|
+
frame = get_frame(frame_id)
|
73
|
+
frame && frame.content
|
74
|
+
end
|
75
|
+
|
76
|
+
def parsable_version
|
77
|
+
@parsable_version ||= calc_parsable_version
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def frame_id_by_name(name)
|
83
|
+
case parsable_version
|
84
|
+
when 2
|
85
|
+
FrameIdAdvisor.new(2, audio_file.v2_tag_major_version_number).advise(name)
|
86
|
+
when 1
|
87
|
+
FrameIdAdvisor.new(1, 'x').advise(name)
|
88
|
+
else
|
89
|
+
nil
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def parse_frames
|
94
|
+
case parsable_version
|
95
|
+
when 2
|
96
|
+
ID3V2FrameParser.new(audio_file.v2_tag_body, audio_file.v2_tag_major_version_number).frames
|
97
|
+
when 1
|
98
|
+
ID3V1FrameParser.new(audio_file.v1_tag_body).frames
|
99
|
+
else
|
100
|
+
[]
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def calc_parsable_version
|
105
|
+
if @version
|
106
|
+
method = "v#{@version}_tag_present?"
|
107
|
+
if audio_file.respond_to?(method) && audio_file.send(method)
|
108
|
+
@version
|
109
|
+
else
|
110
|
+
nil
|
111
|
+
end
|
112
|
+
else
|
113
|
+
audio_file.greatest_tag_version
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def audio_file
|
118
|
+
@audio_file ||= AudioFile.new(@source)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|