File src/remiaudio/vorbiscomment.cr from the latest check-in


     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
   100
   101
   102
   103
   104
   105
   106
   107
   108
   109
   110
   111
   112
   113
   114
   115
   116
   117
   118
   119
   120
   121
   122
   123
   124
   125
   126
   127
   128
   129
   130
   131
   132
   133
   134
   135
   136
   137
   138
   139
   140
   141
   142
   143
   144
   145
   146
   147
   148
   149
   150
   151
   152
   153
   154
   155
   156
   157
   158
   159
   160
   161
   162
   163
   164
   165
   166
   167
   168
   169
   170
   171
   172
   173
   174
   175
   176
   177
   178
   179
   180
   181
   182
   183
   184
   185
   186
   187
   188
   189
   190
   191
   192
   193
   194
   195
   196
   197
   198
   199
   200
   201
   202
   203
   204
   205
   206
   207
   208
   209
   210
   211
   212
   213
   214
   215
   216
   217
   218
   219
   220
   221
   222
   223
   224
   225
   226
   227
   228
   229
   230
   231
   232
   233
   234
   235
   236
   237
   238
   239
   240
   241
   242
   243
   244
   245
   246
   247
   248
   249
   250
   251
   252
   253
   254
   255
   256
   257
   258
   259
   260
   261
   262
   263
   264
   265
   266
   267
   268
   269
   270
   271
   272
   273
   274
   275
   276
   277
#### 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