Artifact 09c71c0ddbf090544b801c2f9ec65f06053961577f3c13d7fa4e3e3e7cd519de:

  • File src/remiaudio/xspf/xspf.cr — part of check-in [004d267a98] at 2024-09-05 05:26:51 on branch trunk — Add XSPF/JSPF support. (user: alexa size: 23801)

#### 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 "uri"
require "xml"
require "./json"

module RemiAudio::Xspf
  class Error < Exception
  end

  # The `Playlist` class contains general information about the playlist, as
  # well as a list of tracks.  This also acts as the toplevel class for
  # XSPF/JSPF data, allowing you to read and write XSPF and JSPF files.
  #
  # Reading an XSPF file:
  #
  # ```crystal
  # playlist = File.open("/path/to/file.xspf", "r") do |file|
  #   RemiAudio::Xspf::Playlist.read(file)
  # end
  #
  # # Print the locations for every track
  # playlist.each do |trk|
  #   puts trk.locations
  # end
  # ```
  #
  # Writing an XSPF file:
  #
  # ```crystal
  # playlist = RemiAudio::Xspf::Playlist.new
  # playlist.title = "A Neat Playlist"
  # playlist.add(title: "Rabio", creator: "Partition 36", "/path/to/file/03 - Rabio.mp3")
  # playlist.add(title: "Searching for My Identity", creator: "Partition 36",
  #              "/path/to/file/06 - Searching for My Identity.mp3")
  #
  # puts playlist.write # Write XML to a String
  # playlist.write("/path/to/dest.xspf") # Write XSPF to a file
  #
  # # Write JSPF to a file.
  # playlist.write("/path/to/dest.xspf", RemiAudio::Xspf::Playlist::Format::Json)
  # ```
  class Playlist
    include JSON::Serializable

    # The version of the specification that we support.  Note that version 1 is
    # backwards compatible with version 0.
    VERSION = 1

    # :nodoc:
    NAMESPACE = "http://xspf.org/ns/0/"

    # Describes the format of a playlist.
    enum Format
      # XSPF, XML Sharable Playlist Format
      Xml

      # JSPF, JSON Sharable Playlist Format
      Json
    end

    # The human-readable name title of this playlist, if any.  If this is nil,
    # then no `<title>` element is emitted for the `<playlist>` element.
    property title : String?

    # The human-readable name of the creator of this playlist, if any.  If this
    # is nil, then no `<creator>` element is emitted for the `<playlist>` element.
    property creator : String?

    # A human-readable comment for this playlist, if any.  If this is nil, then
    # no `<annotation>` element is emitted for the `<playlist>` element.  This
    # should never contain markup.
    @[JSON::Field(key: "annotation")]
    property annotate : String?

    # A URI of a web page that provides more information about this playlist.
    # If this is `nil`, then no `<info>` element is emitted for the `<playlist>`
    # element.
    property info : URI?

    # A URI of a web page that acts as the source for this playlist.
    # If this is `nil`, then no `<location>` element is emitted for the `<playlist>`
    # element.
    property location : URI?

    # A URI describing the canonical identifier of this playlist.  If this is
    # `nil`, then no `<identifier>` element is emitted for the `<playlist>`
    # element.
    property identifier : URI?

    # A URI pointing to an image that can be displayed when a track has no
    # artwork of its own.  If this is `nil`, then no `<image>` element is
    # emitted for the `<playlist>` element.
    property image : URI?

    # A URI to a resource that provides information about the license this
    # playlist is under.  If this is `nil`, then no `<license>` element is
    # emitted for the `<playlist>` element.
    property license : URI?

    # The date this playlist was created (NOT the last-modified date).  If this
    # is `nil`, then no `<attribution>` element is emitted for the `<playlist>`
    # element.
    @[JSON::Field(converter: RemiAudio::Xspf::XmlDateConverter)]
    property date : Time?

    # An `Attribution` instance that contains additional information that can be
    # used to satisfy attribution requirements for licenses.  If this is `nil`,
    # then no `<attribution>` element is emitted for the `<playlist>` element.
    @[JSON::Field(converter: RemiAudio::Xspf::AttributionJsonConverter)]
    property attribution : Attribution?

    # An array of `Link` instances that allow a playlist to be extended without
    # extra XML namespaces.  If this is empty, then no `<link>` element is
    # emitted for the `<playlist>` element.
    @[JSON::Field(key: "link", converter: RemiAudio::Xspf::LinksJsonConverter)]
    property links : Array(Link) = [] of Link

    # An array of `Meta` instances that specify metadata for a playlist.  If
    # this is empty, then no `<meta>` element is emitted for the `<playlist>`
    # element.
    @[JSON::Field(converter: RemiAudio::Xspf::MetasJsonConverter)]
    property meta : Array(Meta) = [] of Meta

    # A list of `Track` instances stored in this playlist.  This cannot be empty
    # when calling `#write`.
    @[JSON::Field(key: "track")]
    getter tracks : Array(Track) = [] of Track

    # Creates a new `Playlist` instance.  The new instance has no tracks
    # associated with it, and so at least one must be added before it can be
    # serialized.
    def initialize
    end

    # Adds a new track to this playlist.
    def add(*locationURIs : URI, title : String? = nil, album : String? = nil, creator : String? = nil,
            trackNumber : UInt64? = nil)
      @tracks << Track.new(*locationURIs, title: title, album: album, creator: creator, trackNumber: trackNumber)
    end

    # Loops through all of the tracks in this playlist, yielding a `Track`
    # instance for each one.
    def each(& : Track ->) : Nil
      @tracks.each { |trk| yield trk }
    end

    # Creates a new `Playlist` instance by reading data from `io`.
    def self.read(io : IO, *, format : Format = Format::Xml) : Playlist
      case format
      in .xml? then readXML(io)
      in .json? then Playlist.from_json(io, root: "playlist")
      end
    end

    # Creates a new `Playlist` instance by reading data from `str`.
    def self.read(str : String, *, format : Format = Format::Xml) : Playlist
      read(IO::Memory.new(str), format: format)
    end

    protected def self.readXML(io : IO) : Playlist
      ret = Playlist.new

      doc = XML.parse(io)
      root = doc.first_element_child
      return ret unless root
      ns = root.namespace_definitions.find(&.href.try(&.==(NAMESPACE)))

      root.attributes.each do |attr|
        if attr.namespace == ns
          if attr.name == "version" && !attr.content.try(&.to_i.<=(VERSION))
            raise Error.new("Unsupported version")
          end
        end
      end

      ret.parseRootChildren(root, ns)
      ret
    end

    # Writes this `Playlist` instance to a file at `dest` in the given `format`.
    # The `mode` parameter is the same as for `File#open`.  There must be at
    # least one track in this playlist, or this will raise an `Error`.
    def write(dest : String|Path, *, mode : String = "w", format : Format = Format::Xml) : Nil
      File.open(dest, mode) { |file| write(file, format: format) }
    end

    # Writes this `Playlist` instance to `io` in the given `format`.  There must
    # be at least one track in this playlist, or this will raise an `Error`.
    def write(io : IO, *, format : Format = Format::Xml) : Nil
      io << write(format: format)
    end

    # Writes this `Playlist` instance to a new String in the given `format`.
    # There must be at least one track in this playlist, or this will raise an
    # `Error`.
    def write(*, format : Format = Format::Xml) : String
      # There must be at least one track.
      raise Error.new("Playlist has no tracks") if @tracks.empty?

      if format.json?
        JSON.build(indent: "   ") do |json|
          json.object do
            json.field("playlist") do
              self.to_json(json)
            end
          end
        end
      else
        XML.build(encoding: "UTF-8", indent: "   ") do |xml|
          xml.element(nil, "playlist", NAMESPACE, version: VERSION) do
            if str = @title
              xml.element("title") { xml.text(str) }
            end

            if str = @creator
              xml.element("creator") { xml.text(str) }
            end

            if str = @annotate
              xml.element("annotation") { xml.text(str) }
            end

            @info.try { |val| xml.element("info") { xml.text(val.to_s) } }
            @location.try { |val| xml.element("location") { xml.text(val.to_s) } }
            @identifier.try { |val| xml.element("identifier") { xml.text(val.to_s) } }
            @image.try { |val| xml.element("image") { xml.text(val.to_s) } }
            @license.try { |val| xml.element("license") { xml.text(val.to_s) } }
            @attribution.try &.write(xml)
            @links.each &.write(xml)
            @meta.each &.write(xml)
            @date.try { |d| xml.element("date") { xml.text(d.to_rfc3339) } }
            xml.element("trackList") { @tracks.each &.write(xml) }
          end
        end
      end
    end

    protected def parseRootChildren(root : XML::Node, ns : XML::Namespace?) : Nil
      foundTrackList = false

      root.children.each do |child|
        if child.namespace == ns
          case child.name
          when "title"
            if @title.nil?
              @title = child.content
            else
              raise Error.new("More than one <title> element in root <playlist> node")
            end

          when "creator"
            if @creator.nil?
              @creator = child.content
            else
              raise Error.new("More than one <creator> element in root <playlist> node")
            end

          when "annotation"
            if @annotate.nil?
              @annotate = child.content
            else
              raise Error.new("More than one <annotation> element in root <playlist> node")
            end

          when "info"
            if @info.nil?
              child.content.try { |cont| @info = URI.parse(cont) }
            else
              raise Error.new("More than one <info> element in root <playlist> node")
            end

          when "location"
            if @location.nil?
              child.content.try { |cont| @location = URI.parse(cont) }
            else
              raise Error.new("More than one <location> element in root <playlist> node")
            end

          when "identifier"
            if @identifier.nil?
              child.content.try { |cont| @identifier = URI.parse(cont) }
            else
              raise Error.new("More than one <identifier> element in root <playlist> node")
            end

          when "date"
            if @date.nil?
              child.content.try { |cont| @date = Time.parse_rfc3339(cont) }
            else
              raise Error.new("More than one <date> element in root <playlist> node")
            end

          when "image"
            if @image.nil?
              child.content.try { |cont| @image = URI.parse(cont) }
            else
              raise Error.new("More than one <image> element in root <playlist> node")
            end

          when "license"
            if @license.nil?
              child.content.try { |cont| @license = URI.parse(cont) }
            else
              raise Error.new("More than one <license> element in root <playlist> node")
            end

          when "attribution"
            if @attribution.nil?
              @attribution = Attribution.fromXML(child, ns)
            else
              raise Error.new("More than one <attribution> element in root <playlist> node")
            end

          when "trackList"
            if child.namespace == ns
              if foundTrackList
                raise Error.new("More than one <trackList> element in root <playlist> node")
              else
                parseTrackList(child, ns)
                foundTrackList = true
              end
            end

          when "link" then @links << Link.fromXML(child, ns)
          when "meta" then @meta << Meta.fromXML(child, ns)
          end
        end
      end

      raise Error.new("No <trackList> found in <playlist> node") unless foundTrackList
    end

    protected def parseTrackList(root : XML::Node, ns : XML::Namespace?) : Nil
      root.children.each do |child|
        if child.namespace == ns
          @tracks << Track.fromXML(child, ns)
        end
      end
    end
  end

  # An `Attribution` contains additional information that can be used to satisfy
  # attribution requirements for licenses.
  class Attribution
    include JSON::Serializable

    # A list of URIs pointing to locations of attributions.
    @[JSON::Field(key: "location")]
    property locations : Array(URI) = [] of URI

    # A list of identifiers of the attributions.
    @[JSON::Field(key: "identifier")]
    property identifiers : Array(URI) = [] of URI

    # Creates a new, empty `Attribution` instance.
    def initialize
    end

    # Creates a new `Attribution` instance by parsing XML starting at `parent`.
    # The `parent` node should be the `<attribution>` element itself.
    def self.fromXML(parent : XML::Node, ns : XML::Namespace?) : Attribution
      ret = Attribution.new

      parent.children.each do |child|
        next unless child.namespace == ns
        if child.name == "location"
          child.content.try { |cont| ret.locations << URI.parse(cont) }
        elsif child.name == "identifier"
          child.content.try { |cont| ret.identifiers << URI.parse(cont) }
        end
      end

      ret
    end

    # Writes this instance to an `XML::Builder` for serialization.
    def write(xml : XML::Builder) : Nil
      xml.element("attribution") do
        @locations.each { |loc| xml.element("location") { xml.text(loc.to_s) } }
        @identifiers.each { |loc| xml.element("identifier") { xml.text(loc.to_s) } }
      end
    end
  end

  # Represents a `<link>` element.
  class Link
    include JSON::Serializable

    # The relation for this link as a URI that points to a resource type.
    property rel : URI

    # The actual link as a URI.
    property content : URI

    # Creates a new `Link` instance.
    def initialize(@rel : URI, @content : URI)
    end

    # Creates a new `Link` instance by parsing XML starting at `parent`.  The
    # `parent` node should be the `<link>` element itself.
    def self.fromXML(parent : XML::Node, ns : XML::Namespace?) : Link
      if (attrib = parent.attributes["rel"]?) && attrib.content &&
         (attrib.namespace == ns || attrib.namespace.nil?)
        if cont = parent.content
          Link.new(URI.parse(attrib.content.not_nil!), URI.parse(cont))
        else
          raise Error.new("<link> is missing its content")
        end
      else
        raise Error.new("<link> has a bad or missing 'rel' attribute")
      end
    end

    # Writes this instance to an `XML::Builder` for serialization.
    def write(xml : XML::Builder) : Nil
      xml.element("link", rel: @rel.to_s) {xml.text(@content.to_s) }
    end
  end

  # Represents a `<meta>` element for embedding metadata.
  class Meta
    include JSON::Serializable

    # A URI to a resource defining this metadata.
    property rel : URI

    # The value of the metadata.  This should be plain text.
    property content : String

    # Creates a new `Meta` instance.
    def initialize(@rel : URI, @content : String)
    end

    # Creates a new `Meta` instance by parsing XML starting at `parent`.  The
    # `parent` node should be the `<meta>` element itself.
    def self.fromXML(parent : XML::Node, ns : XML::Namespace?) : Meta
      if (attrib = parent.attributes["rel"]?) && attrib.content &&
         (attrib.namespace == ns || attrib.namespace.nil?)
        if cont = parent.content
          Meta.new(URI.parse(attrib.content.not_nil!), cont)
        else
          raise Error.new("<meta> is missing its content")
        end
      else
        raise Error.new("<meta> has a bad or missing 'rel' attribute")
      end
    end

    # Writes this instance to an `XML::Builder` for serialization.
    def write(xml : XML::Builder) : Nil
      xml.element("meta", rel: @rel.to_s) {xml.text(@content) }
    end
  end

  # The `Track` class represents a single track within a playlist.
  class Track
    include JSON::Serializable

    # The human-readable name title of this track, if any.  If this is nil, then
    # no `<title>` element is emitted for the `<track>` element.
    property title : String?

    # The human-readable name of the album this track belongs to, if any.  If
    # this is nil, then no `<album>` element is emitted for the `<track>`
    # element.
    property album : String?

    # The human-readable name of the creator of this track, if any.  If this is
    # nil, then no `<creator>` element is emitted for the `<track>` element.
    property creator : String?

    # The ordinal position of this track within the `#album`, if any.  If this is
    # nil, then no `<trackNum>` element is emitted for the `<track>` element.
    @[JSON::Field(key: "trackNum")]
    property trackNumber : UInt64?

    # The approximate length of this track.  This length only serves as a hint
    # and may not be accurate.  If this is nil, then no `<duration>` element is
    # emitted for the `<track>` element.
    property duration : UInt64?

    # A human-readable comment for this track, if any.  If this is nil, then no
    # `<annotation>` element is emitted for the `<track>` element.  This should
    # never contain markup.
    @[JSON::Field(key: "annotation")]
    property annotate : String?

    # A URI of a web page that provides more information about this track.  If
    # this is `nil`, then no `<info>` element is emitted for the `<track>`
    # element.
    property info : URI?

    # A URI pointing to an image that can be displayed while this track is
    # playing.  If this is `nil`, then no `<image>` element is emitted for the
    # `<track>` element.
    property image : URI?

    # An array of `Link` instances that allow track information to be extended
    # without extra XML namespaces.  If this is empty, then no `<link>` element
    # is emitted for the `<track>` element.
    @[JSON::Field(key: "link", converter: RemiAudio::Xspf::LinksJsonConverter)]
    property links : Array(Link) = [] of Link

    # An array of `Meta` instances that specify metadata for a track.  If this
    # is empty, then no `<meta>` element is emitted for the `<track>` element.
    @[JSON::Field(converter: RemiAudio::Xspf::MetasJsonConverter)]
    property meta : Array(Meta) = [] of Meta

    # An ordered list of URIs that point to the actual data for this track, if
    # any.  This technically does not have to be a link to an audio resource.
    @[JSON::Field(key: "location")]
    property locations : Array(URI) = [] of URI

    # An ordered list of URIs describing the cannonical identifier of this
    # track.
    @[JSON::Field(key: "identifier")]
    property identifiers : Array(URI) = [] of URI

    # Creates a new `Track` instance.
    def initialize
    end

    # Creates a new `Track` instance.
    def initialize(*locationURIs : URI, @title : String? = nil, @album : String? = nil, @creator : String? = nil,
                   @trackNumber : UInt64? = nil)
      @locations = locationURIs.to_a
    end

    # Creates a new `Track` instance by parsing XML starting at `parent`.  The
    # `parent` node should be the `<track>` element itself.
    def self.fromXML(parent : XML::Node, ns : XML::Namespace?) : Track
      raise "Bad namespace" if parent.namespace != ns
      ret = Track.new
      ret.parseChildren(parent, ns)
      ret
    end

    protected def parseChildren(root : XML::Node, ns : XML::Namespace?) : Nil
      root.children.each do |child|
        if child.namespace == ns
          case child.name
          when "title"
            if @title.nil?
              @title = child.content
            else
              raise Error.new("More than one <title> element in <track> node")
            end

          when "album"
            if @album.nil?
              @album = child.content
            else
              raise Error.new("More than one <album> element in <track> node")
            end

          when "creator"
            if @creator.nil?
              @creator = child.content
            else
              raise Error.new("More than one <creator> element in <track> node")
            end

          when "annotation"
            if @annotate.nil?
              @annotate = child.content
            else
              raise Error.new("More than one <annotation> element in <track> node")
            end

          when "info"
            if @info.nil?
              child.content.try { |cont| @info = URI.parse(cont) }
            else
              raise Error.new("More than one <info> element in <track> node")
            end

          when "image"
            if @image.nil?
              child.content.try { |cont| @image = URI.parse(cont) }
            else
              raise Error.new("More than one <image> element in <track> node")
            end

          when "trackNum"
            if @trackNumber.nil?
              if cont = child.content
                @trackNumber = cont.to_u64
              else
                raise Error.new("Bad track number in <track> node")
              end
            else
              raise Error.new("More than one <info> element in <track> node")
            end

          when "duration"
            if @duration.nil?
              if cont = child.content
                @duration = cont.to_u64
              else
                raise Error.new("Bad duration in <track> node")
              end
            else
              raise Error.new("More than one <duration> element in root <track> node")
            end

          when "location" then child.content.try { |childCont| @locations << URI.parse(childCont) }
          when "identifier" then child.content.try { |childCont| @identifiers << URI.parse(childCont) }
          when "link" then @links << Link.fromXML(child, ns)
          when "meta" then @meta << Meta.fromXML(child, ns)
          end
        end
      end
    end

    # Writes this instance to an `XML::Builder` for serialization.
    def write(xml : XML::Builder) : Nil
      xml.element("track") do
        if str = @title
          xml.element("title") { xml.text(str) }
        end

        if str = @album
          xml.element("album") { xml.text(str) }
        end

        if str = @creator
          xml.element("creator") { xml.text(str) }
        end

        if str = @annotate
          xml.element("annotation") { xml.text(str) }
        end

        @trackNumber.try { |tn| xml.element("trackNum") { xml.text(tn.to_s) } }
        @duration.try { |dur| xml.element("duration") { xml.text(dur.to_s) } }
        @info.try { |val| xml.element("info") { xml.text(val.to_s) } }
        @image.try { |val| xml.element("image") { xml.text(val.to_s) } }
        @links.each &.write(xml)
        @meta.each &.write(xml)
        @locations.each { |loc| xml.element("location") { xml.text(loc.to_s) } }
        @identifiers.each { |loc| xml.element("identifier") { xml.text(loc.to_s) } }
      end
    end
  end
end