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