#### RemiAudio
#### Copyright (C) 2022-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify it
#### under the terms of the GNU Affero General Public License as published by
#### the Free Software Foundation, either version 3 of the License, or (at your
#### option) any later version.
####
#### This program is distributed in the hope that it will be useful, but WITHOUT
#### ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#### FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
#### License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program. If not, see <https://www.gnu.org/licenses/>.
require "./common"
module RemiAudio
# Stores [Ogg Vorbis Comment](https://xiph.org/vorbis/doc/v-comment.html)
# metadata.
class VorbisComment
class Error < RemiAudioError
end
# :nodoc:
EQUAL_SIGN = '='.ord.to_u8
# The vendor string for the Vorbis Comments set.
property vendor : String = ""
@comments : Hash(String, Array(String)) = {} of String => Array(String)
# Creates a new, empty set of Vorbis Comments.
def initialize
end
# Creates a new set of Vorbis Comments by parsing `rawData`. If
# `ignoreMissingFramingBit` is `true`, then the parser will not check for
# the framing bit at the end of the comments.
def initialize(rawData : Bytes, ignoreMissingFramingBit : Bool = false)
parse(rawData, ignoreMissingFramingBit)
end
# Creates a new set of Vorbis Comments by reading from `io`. If
# `ignoreMissingFramingBit` is `true`, then the parser will not check for
# the framing bit at the end of the comments.
def initialize(io : IO, ignoreMissingFramingBit : Bool = false)
parse(io, ignoreMissingFramingBit)
end
# Returns `true` if `name` is a valid string for a Vorbis Comment key, or
# `false` otherwise.
def self.validKeyName(name : String) : Bool
name.ascii_only? && name.to_slice.all? { |x| x >= 0x20 && x <= 0x7D }
end
# Returns a Tuple where the first element is the key name, and the second
# element is the value.
@[AlwaysInline]
protected def parseComment(io : IO) : Tuple(String, String)
len = io.readUInt32
str = io.read_string(len)
data = str.split('=', 2)
# Do a few checks
if data.size != 2
raise VorbisComment::Error.new("Invalid Vorbis Comment at #{io.pos}, no key/value pair detected")
#elsif data[0].size == 0
# raise VorbisComment::Error.new("Vorbis Comment has a blank key name at #{io.pos}")
elsif !VorbisComment.validKeyName(data[0])
raise VorbisComment::Error.new("Invalid Vorbis Comment at #{io.pos}, key is invalid")
end
{data[0].upcase, data[1]}
end
# Appends new comments onto this set of Vorbis Comments by parsing
# `rawData`. If `ignoreMissingFramingBit` is `true`, then the parser will
# not check for the framing bit at the end of the comments. This returns
# the number of comments added to the set.
def parse(rawData : Bytes, ignoreMissingFramingBit : Bool = false) : Int32
io = IO::Memory.new(rawData)
parse(io, ignoreMissingFramingBit)
end
# Appends new comments onto this set of Vorbis Comments by parsing
# `rawData`. If `ignoreMissingFramingBit` is `true`, then the parser will
# not check for the framing bit at the end of the comments. This returns
# the number of comments added to the set.
def parse(io : IO, ignoreMissingFramingBit : Bool = false) : Int32
numRead : Int32 = 0
# Read the vendor string
vendLen = io.readUInt32
self.vendor = io.read_string(vendLen)
# Read the comments
numComments = io.readUInt32
numComments.times do |_|
self << parseComment(io)
numRead += 1
end
# Possibly check for the framing bit
unless ignoreMissingFramingBit
begin
if byte = io.read_byte
unless byte == 1
raise VorbisComment::Error.new("Framing bit not set in Vorbis Comments")
end
else
raise VorbisComment::Error.new("Framing bit missing in Vorbis Comments")
end
rescue IO::EOFError
raise VorbisComment::Error.new("Framing bit not found in Vorbis Comments")
end
end
numRead
end
# Writes this comment set to `io`, then returns `io`. If `noFramingBit` is
# `true`, then this will not write the framing bit at the end.
def write(io : IO, noFramingBit : Bool = false) : IO
# Write vendor string.
io.write_bytes(@vendor.size.to_u32, IO::ByteFormat::LittleEndian)
io.write(@vendor.to_slice) unless @vendor.empty?
# Write comments
io.write_bytes(self.size, IO::ByteFormat::LittleEndian)
self.each do |key, val|
io.write_bytes((key.size + val.size + 1).to_u32, IO::ByteFormat::LittleEndian)
io.write(key.upcase.to_slice)
io.write_byte(EQUAL_SIGN)
io.write(val.to_slice)
end
io.write_byte(1u8) unless noFramingBit
io
end
# Returns the set of comments associated with the given key. If the key
# does not exist, this raises a `KeyError` is raised.
#
# The case of `name` does not matter. It cannot be blank.
#
# Note: modifications to the returned array will change the data stored in
# this `VorbisComment` instance.
def [](name : String) : Array(String)
@comments[name.upcase]
end
# Attempts to return the set of comments associated with the given key. If
# the key does not exist, this returns `nil`.
#
# The case of `name` does not matter.
#
# Note: modifications to the returned array will change the data stored in
# this `VorbisComment` instance.
def []?(name : String) : Array(String)?
@comments[name.upcase]?
end
# Returns the number of unique key names stored in this set.
def numKeys
@comments.size
end
# Returns the total number of comments stored in this set.
@[AlwaysInline]
def size : UInt32
ret : UInt32 = 0u32
@comments.each_value do |v|
ret += v.size
end
ret
end
# Returns `true` if this set has no comments, or `false` otherwise.
def empty?
@comments.empty?
end
# Loops over all of the comments, yielding each unique comment as a
# `Tuple(String, String)` to the block.
def each(&)
@comments.each_key do |k|
@comments[k].each do |v|
yield k, v
end
end
end
# Loops over all the unique keys, yielding the `Array(String)` of comments
# for each key to the block.
#
# Note: modifications to the yielded array will change the data stored in
# this `VorbisComment` instance.
def eachKey(&)
@comments.each_key do |k|
yield @comments[k]
end
end
# Adds a new comment to the set. The 0th value of the Tuple is the key
# name. If the key is not a valid key name, this will raise a
# `VorbisComment::Error`. This returns `self.`
#
# The key name will always be converted to upper case.
def <<(value : Tuple(String, String))
unless VorbisComment.validKeyName(value[0])
raise VorbisComment::Error.new("Invalid Vorbis Comment key")
end
key = value[0].upcase
unless @comments.has_key?(key)
@comments[key] = [] of String
end
@comments[key] << value[1]
self
end
# Adds a new comment to the set. If the key is not a valid key name, this
# will raise a `VorbisComment::Error`. This returns `self.`
#
# `key` will always be converted to upper case.
@[AlwaysInline]
def []=(key : String, value : String)
self << {key, value}
self
end
# Removes all comments stored under the key name `key`. If the key does not
# exist, this does nothing. This returns `self.`
#
# The case of `key` does not matter.
@[AlwaysInline]
def delete(key : String)
@comments.delete(key.upcase)
self
end
# Removes a specific comment stored under the key name `key`. If the key
# does not exist, this raises a `KeyError`. This returns the deleted
# comment value.
#
# The case of `key` does not matter.
@[AlwaysInline]
def delete(key : String, valNum : Int) : String
@comments[key.upcase].delete_at(valNum)
end
# Removes all comments from this set, then returns `self`.
def clear
@comments.clear
self
end
# Returns an array of `Tuple(String, String)` containing all of the comments
# in this set. The returned array can be safely modified without affecting
# the underlying data in this `VorbisComment` instance.
def comments : Array(Tuple(String, String))
ret = [] of Tuple(String, String)
@comments.each_key do |k|
@comments.each_value do |v|
ret << {k, v}
end
end
ret
end
end
end