Artifact 59cfdd75f9a67e4ab39b5ed55d560cdc004083ca1d14eaf8224e5b95e2c4ff7d:

  • File src/remiaudio/vorbiscomment.cr — part of check-in [98921eb869] at 2024-01-05 07:36:37 on branch trunk — Copyright update (user: alexa size: 9166)

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