let settings.playlist.mime_types =
  settings.make.void(
    "Mime-types used for guessing playlist formats."
  )

let playlist.parse.cue = ()

# Parse a cue file
# @category Liquidsoap
# @param ~pwd Path to use for relative path resolution
def playlist.parse.cue.full(~pwd=null(), content) =
  content = string.split(separator="[\r\n]+", content)

  def parse_file(s) =
    matches = r/^FILE (.+)$/.exec(string.trim(s))
    try
      match = list.assoc(1, matches)
      match_chars = string.chars(match)

      def rec get_last(~char, cur, chars) =
        let [...chars, last] = chars
        if
          last == char
        then
          filename = string.concat(separator="", chars)
          file_type = string.trim(string.concat(separator="", cur))
          file_type =
            if
              file_type == ""
            then
              null()
            else
              string.case(lower=true, file_type)
            end
          (filename, file_type)
        else
          get_last(char=char, [last, ...cur], chars)
        end
      end

      let (filename, file_type) =
        if
          list.hd(match_chars) == '"'
        then
          let [_, ...chars] = match_chars
          let (filename, file_type) = get_last(char='"', [], chars)
          (string.unquote('"#{filename}"'), file_type)
        else
          get_last(
            char=
              " ",
            [],
            match_chars
          )
        end

      filename = playlist.parse.get_file(pwd=pwd, filename)
      {filename=filename, file_type=file_type}
    catch _ do
      null()
    end
  end

  def parse_track_attribute(s) =
    matches = r/^TRACK ([^\s]+)\s([^\s]+)?$/.exec(string.trim(s))
    try
      position = int_of_string(list.assoc(1, matches))
      type = list.assoc(2, matches)

      {position=position, track_type=string.case(lower=true, type)}
    catch _ : [error.not_found] do
      null()
    end
  end

  def parse_rem(s) =
    matches = r/^REM ([^\s]+) (.+)$/.exec(string.trim(s))
    try
      name = string.case(lower=true, list.assoc(1, matches))
      value = string.unquote(list.assoc(2, matches))
      (name, value)
    catch _ : [error.not_found] do
      null()
    end
  end

  def parse_timecode(s) =
    matches = r/^([\d]+):([\d]+):([\d]+)/.exec(string.trim(s))
    try
      minutes = int_of_string(list.assoc(1, matches))
      seconds = int_of_string(list.assoc(2, matches))
      frames = int_of_string(list.assoc(3, matches))

      {minutes=minutes, seconds=seconds, frames=frames}
    catch _ : [error.not_found] do
      null()
    end
  end

  def parse_index(s) =
    matches = r/^INDEX ([\d]+) ([\d:]+)/.exec(string.trim(s))
    try
      index = int_of_string(list.assoc(1, matches))
      timecode = list.assoc(2, matches)

      (index, null.get(parse_timecode(timecode)))
    catch _ do
      null()
    end
  end

  def parse_optional(~label, s) =
    matches =
      regexp(
        "^#{string.case(lower=false, label)} (.+)$"
      ).exec(string.trim(s))
    null.map(string.unquote, list.assoc.nullable(1, matches))
  end

  def end_parse_track(content) =
    content == [] or null.defined(parse_track_attribute(list.hd(content)))
  end

  def rec parse_track(~file, ~track, content) =
    track =
      (track :
        {
          position: int,
          track_type?: string,
          performer?: string,
          title?: string,
          album?: string,
          isrc?: string,
          postgap?: {minutes: int, seconds: int, frames: int},
          pregap?: {minutes: int, seconds: int, frames: int},
          indexes: [
            (
              int
              *
              {
                filename?: string,
                file_type?: string,
                minutes: int,
                seconds: int,
                frames: int
              }
            )
          ]
        }
      )

    if
      end_parse_track(content)
    then
      (file, track, content)
    else
      let [s, ...content] = content

      index = parse_index(s)
      new_file = parse_file(s)

      let (file, track) =
        if
          null.defined(index)
        then
          let (idx, timecode) = null.get(index)
          (
            file,
            {
              ...track,
              indexes=[...track.indexes, (idx, {...file, ...timecode})]
            }
          )
        elsif null.defined(new_file) then (new_file, track)
        else
          track_attributes =
            [
              ("title", fun (title) -> {...track, title=title}),
              ("performer", fun (performer) -> {...track, performer=performer}),
              (
                "pregap",
                fun (pregap) ->
                  {...track, pregap=null.get(parse_timecode(pregap))}
              ),
              (
                "postgap",
                fun (postgap) ->
                  {...track, postgap=null.get(parse_timecode(postgap))}
              ),
              (
                "rem",
                fun (rem) ->
                  begin
                    parsed =
                      parse_rem(
                        "REM #{rem}"
                      )
                    if
                      null.defined(parsed)
                    then
                      let (label, value) = null.get(parsed)
                      if
                        label == "album"
                      then
                        {...track, album=value}
                      else
                        log.important(
                          label="playlist.parse.cue.full",
                          "Unknown track attribute REM #{rem}, please file a bug \
                           report!"
                        )
                        track
                      end
                    end
                  end
              ),
              ("isrc", fun (isrc) -> {...track, isrc=isrc})
            ]
          attribute_names = list.map(fst, track_attributes)

          def rec check_attribute(attribute_names) =
            if
              attribute_names == []
            then
              log.important(
                label="playlist.parse.cue.full",
                "Could not parse track attribute: #{s}"
              )
              track
            else
              let [name, ...attribute_names] = attribute_names
              let fn = list.assoc(name, track_attributes)
              parsed = parse_optional(label=name, s)

              if
                null.defined(parsed)
              then
                fn(null.get(parsed))
              else
                check_attribute(attribute_names)
              end
            end
          end

          (file, check_attribute(attribute_names))
        end
      parse_track(file=file, track=track, content)
    end
  end

  def rec parse_content(~file, ~sheet, content) =
    sheet =
      (sheet :
        {
          catalog?: string,
          performer?: string,
          title?: string,
          rem: [(string * string)],
          tracks: [
            {
              position: int,
              track_type?: string,
              performer?: string,
              title?: string,
              album?: string,
              isrc?: string,
              postgap?: {minutes: int, seconds: int, frames: int},
              pregap?: {minutes: int, seconds: int, frames: int},
              indexes: [
                (
                  int
                  *
                  {
                    filename?: string,
                    file_type?: string,
                    minutes: int,
                    seconds: int,
                    frames: int
                  }
                )
              ]
            }
          ]
        }
      )

    if
      content == []
    then
      sheet
    else
      let [s, ...content] = content

      new_file = parse_file(s)
      new_rem = parse_rem(s)
      track = parse_track_attribute(s)

      if
        null.defined(new_file)
      then
        parse_content(file=new_file, sheet=sheet, content)
      elsif
        null.defined(new_rem)
      then
        parse_content(
          file=file,
          sheet={...sheet, rem=[null.get(new_rem), ...sheet.rem]},
          content
        )
      elsif
        null.defined(track)
      then
        let (file, track, content) =
          parse_track(
            file=file, track={...null.get(track), indexes=[]}, content
          )
        parse_content(
          file=file, sheet={...sheet, tracks=[...sheet.tracks, track]}, content
        )
      else
        sheet_attributes =
          [
            ("catalog", fun (catalog) -> {...sheet, catalog=catalog}),
            ("performer", fun (performer) -> {...sheet, performer=performer}),
            ("title", fun (title) -> {...sheet, title=title})
          ]

        attribute_names = list.map(fst, sheet_attributes)

        def rec check_attribute(attribute_names) =
          if
            attribute_names == []
          then
            log.important(
              label="playlist.parse.cue.full",
              "Could not parse attribute: #{string.quote(s)}"
            )
            sheet
          else
            let [name, ...attribute_names] = attribute_names
            let fn = list.assoc(name, sheet_attributes)
            let parsed = parse_optional(label=name, s)

            if
              null.defined(parsed)
            then
              fn(null.get(parsed))
            else
              check_attribute(attribute_names)
            end
          end
        end

        parse_content(
          file=file, sheet=check_attribute(attribute_names), content
        )
      end
    end
  end

  parse_content(file=null(), sheet={tracks=[], rem=[]}, content)
end

let settings.playlist.cue =
  settings.make.void(
    "Settings for parsing cue files"
  )

let settings.playlist.cue.pregap_metadata =
  settings.make(
    description=
      "Metadata used to pass pre-gap cue metadata",
    "liq_pregap"
  )

let settings.playlist.cue.index_zero_metadata =
  settings.make(
    description=
      "Metadata used to pass index 0 cue metadata",
    "liq_index_zero"
  )

let settings.playlist.cue.index_zero_filename_metadata =
  settings.make(
    description=
      "Metadata used to pass index 0 filename metadata",
    "liq_index_zero_filename"
  )

let settings.playlist.cue.postgap_metadata =
  settings.make(
    description=
      "Metadata used to pass pre-gap cue metadata",
    "liq_postgap"
  )

# Parse a cue file and return a value suitable for playlist parser registration.
# @category Liquidsoap
# @param ~pwd Path to use for relative path resolution
def replaces playlist.parse.cue(~pwd=null(), content) =
  # Simple test: playlist should have at least one file..
  if
    r/FILE/.test(content)
  then
    let {catalog?, title?, performer?, rem, tracks} =
      playlist.parse.cue.full(pwd=pwd, content)
    let playlist_album = title
    let album_performer = performer
    rem =
      list.map(
        fun (el) ->
          begin
            let (name, value) = el
            if name == "date" then ("year", value) else (name, value) end
          end,
        rem
      )

    def seconds_of_timecode(timecode) =
      let {minutes, seconds, frames} = timecode
      float(minutes) * 60. + float(seconds) + float(frames) / 75.
    end

    def string_of_timecode(timecode) =
      string(seconds_of_timecode(timecode))
    end

    def add_track(tracks, track) =
      let {
        performer?,
        title?,
        album?,
        isrc?,
        pregap?,
        postgap?,
        position,
        indexes
      } = track
      index_zero = list.assoc.nullable(0, indexes)
      index_zero_filename = null.map(fun (m) -> m?.filename, index_zero)
      index = list.assoc.nullable(1, indexes)
      filename = null.map(fun (m) -> m?.filename, index)
      cue_in_metadata = settings.playlist.cue_in_metadata()
      cue_out_metadata = settings.playlist.cue_out_metadata()
      pregap_metadata = settings.playlist.cue.pregap_metadata()
      index_zero_metadata = settings.playlist.cue.index_zero_metadata()
      index_zero_filename_metadata =
        settings.playlist.cue.index_zero_filename_metadata()
      postgap_metadata = settings.playlist.cue.postgap_metadata()

      if
        not null.defined(filename) or (not null.defined(index))
      then
        log.important(
          label="playlist.cue.parse",
          "Track without filename or index: #{track}"
        )
        tracks
      else
        let timecode = null.get(index)
        let filename = null.get(filename)

        timecode = seconds_of_timecode(timecode)
        cue_in = timecode == 0. ? [] : [(cue_in_metadata, string(timecode))]

        cue_out =
          try
            let (old_meta, old_filename) = list.hd(tracks)
            if
              old_filename == filename
            then
              index_zero = list.assoc(default="", index_zero_metadata, old_meta)

              cue_out =
                if
                  index_zero != ""
                then
                  index_zero
                else
                  list.assoc(cue_in_metadata, old_meta)
                end

              [(cue_out_metadata, cue_out)]
            else
              []
            end
          catch _ do
            []
          end

        meta =
          list.fold(
            fun (meta, el) ->
              begin
                let (name, value) = el
                if
                  null.defined(value)
                then
                  [(name, null.get(value)), ...meta]
                else
                  meta
                end
              end,
            [],
            [
              (pregap_metadata, null.map(string_of_timecode, pregap)),
              (postgap_metadata, null.map(string_of_timecode, postgap)),
              (index_zero_metadata, null.map(string_of_timecode, index_zero)),
              (index_zero_filename_metadata, index_zero_filename),
              ("tracknumber", string(position)),
              ("catalog", catalog),
              ...(
                null.defined(performer)
                ? [("artist", performer), ("albumartist", album_performer)] :
                  [("artist", album_performer)]

              ),

              ("title", title),
              ("isrc", isrc),
              ("album", album ?? playlist_album)
            ]
          )

        [([...cue_in, ...cue_out, ...meta, ...rem], filename), ...tracks]
      end
    end

    list.fold(add_track, [], list.rev(tracks))
  else
    []
  end
end

let settings.playlist.mime_types.basic =
  settings.make(
    description=
      "Mime-types used for guessing text-based playlists.",
    [
      {mime="audio/x-scpls", strict=true, parser=playlist.parse.scpls},
      {mime="application/x-cue", strict=true, parser=playlist.parse.cue},
      {mime="audio/x-mpegurl", strict=false, parser=playlist.parse.m3u},
      {mime="audio/mpegurl", strict=false, parser=playlist.parse.m3u},
      {mime="application/x-mpegURL", strict=false, parser=playlist.parse.m3u}
    ]
  )

%ifdef playlist.parse.xml
let settings.playlist.mime_types.xml =
  settings.make(
    description=
      "Mime-types used for guessing xml-based playlists.",
    list.map(
      (fun (mime) -> {mime=mime, strict=true, parser=playlist.parse.xml}),
      [
        "video/x-ms-asf",
        "audio/x-ms-asx",
        "text/xml",
        "application/xml",
        "application/smil",
        "application/smil+xml",
        "application/xspf+xml",
        "application/rss+xml"
      ]
    )
  )
%endif

# @flag hidden
let register_playlist_parsers =
  begin
    registered = ref(false)
    fun () ->
      begin
        if
          not registered()
        then
          parsers = settings.playlist.mime_types.basic()
%ifdef playlist.parse.xml
          parsers = [...parsers, ...settings.playlist.mime_types.xml()]
%endif
          list.iter(
            fun (entry) ->
              playlist.parse.register(
                format=entry.mime, strict=entry.strict, entry.parser
              ),
            parsers
          )
        end
        registered := true
      end
  end
on_start(register_playlist_parsers)

# @docof playlist.parse
def replaces playlist.parse(%argsof(playlist.parse), uri) =
  register_playlist_parsers()
  playlist.parse(%argsof(playlist.parse), uri)
end

# Default id assignment for playlists (the identifier is generated from the
# filename).
# @category Liquidsoap
# @flag hidden
# @param ~default Default name pattern when no useful name can be extracted from `uri`
# @param uri Playlist uri
def playlist.id(~default, uri) =
  basename = path.basename(uri)
  basename =
    if
      basename == "."
    then
      let l = r/\//g.split(uri)
      if l == [] then path.dirname(uri) else list.hd(list.rev(l)) end
    else
      basename
    end

  if
    basename == "."
  then
    string.id.default(default=default, null())
  else
    basename
  end
end

# Retrieve the list of files contained in a playlist.
# @category File
# @param ~mime_type Default MIME type for the playlist. `null` means automatic detection.
# @param ~timeout Timeout for resolving the playlist
# @param uri Path to the playlist
def playlist.files(~id=null(), ~mime_type=null(), ~timeout=null(), uri) =
  id = id ?? playlist.id(default="playlist.files", uri)

  if
    file.is_directory(uri)
  then
    log.info(
      label=id,
      "Playlist is a directory."
    )
    files = file.ls(absolute=true, recursive=true, sorted=true, uri)
    files = list.filter(fun (f) -> not (file.is_directory(f)), files)
    files
  else
    pl = request.create(resolve_metadata=false, uri)
    result =
      if
        request.resolve(timeout=timeout, pl)
      then
        pl = request.filename(pl)
        files = playlist.parse(mime=mime_type, pl)

        def file_request(el) =
          let (meta, file) = el
          s =
            string.concat(
              separator=",",
              list.map(fun (el) -> "#{fst(el)}=#{string.quote(snd(el))}", meta)
            )

          if s == "" then file else "annotate:#{s}:#{file}" end
        end

        list.map.right(file_request, files)
      else
        log.important(
          label=id,
          "Couldn't read playlist: request resolution failed."
        )
        request.destroy(pl)

        error.raise(
          error.invalid,
          "Could not resolve uri: #{uri}"
        )
      end

    request.destroy(pl)
    result
  end
end

%ifdef native
let stdlib_native = native
%endif

# Play a list of files.
# @category Source / Input
# @param ~id Force the value of the source ID.
# @param ~check_next Function used to filter next tracks. A candidate track is \
#   only validated if the function returns true on it. The function is called \
#   before resolution, hence metadata will only be available for requests \
#   corresponding to local files. This is typically used to avoid repetitions, \
#   but be careful: if the function rejects all attempts, the playlist will \
#   enter into a consuming loop and stop playing anything.
# @param ~prefetch How many requests should be queued in advance.
# @param ~loop Loop on the playlist.
# @param ~mode Play the files in the playlist either in the order ("normal" mode), \
#  or shuffle the playlist each time it is loaded, and play it in this order for a \
#  whole round ("randomize" mode), or pick a random file in the playlist each time \
#  ("random" mode).
# @param ~native Use native implementation, when available.
# @param ~on_loop Function executed when the playlist is about to loop.
# @param ~on_done Function executed when the playlist is finished.
# @param ~max_fail When this number of requests fail to resolve, the whole playlists is considered as failed and `on_fail` is called.
# @param ~on_fail Function executed when too many requests failed and returning the contents of a fixed playlist.
# @param ~timeout Timeout (in sec.) to resolve the request. Defaults to `settings.request.timeout` when `null`.
# @param ~cue_in_metadata Metadata for cue in points. Disabled if `null`.
# @param ~cue_out_metadata Metadata for cue out points. Disabled if `null`.
# @param playlist Playlist.
# @method reload Reload the playlist with given list of songs.
# @method remaining_files Songs remaining to be played.
def playlist.list(
  ~id=null(),
  ~check_next=null(),
  ~prefetch=null(),
  ~loop=true,
  ~mode="normal",
  ~native=false,
  ~on_loop={()},
  ~on_done={()},
  ~max_fail=10,
  ~on_fail=null(),
  ~timeout=null(),
  ~cue_in_metadata=null("liq_cue_in"),
  ~cue_out_metadata=null("liq_cue_out"),
  playlist
) =
  ignore(native)
  id = string.id.default(default="playlist.list", id)
  mode =
    if
      not list.mem(mode, ["normal", "random", "randomize"])
    then
      log.severe(
        label=id,
        "Invalid mode: #{mode}"
      )
      "randomize"
    else
      mode
    end

  check_next = check_next ?? fun (_) -> true
  should_stop = ref(false)
  on_shutdown({should_stop.set(true)})
  on_fail =
    null.map(
      fun (on_fail) -> {if not should_stop() then on_fail() else [] end},
      on_fail
    )

  # Original playlist when loaded
  playlist_orig = ref(playlist)

  # Randomize the playlist if necessary
  def randomize(p) =
    if mode == "randomize" then list.shuffle(p) else p end
  end

  # Current remaining playlist
  playlist = ref(randomize(playlist))

  # A reference to know if the source has been stopped
  has_stopped = ref(false)

  # Delay the creation of next after the source because we need it to resolve
  # requests at the right content type.
  next_fun = ref(fun () -> null())

  def next() =
    f = next_fun()
    f()
  end

  # Instantiate the source
  default =
    fun () ->
      request.dynamic(
        id=id,
        prefetch=prefetch,
        timeout=timeout,
        retry_delay=1.,
        available={not has_stopped()},
        next
      )

  s =
%ifdef native
    if native then stdlib_native.request.dynamic(id=id, next) else default() end
%else
    default()
%endif

  source.set_id(s, "playlist.list.reloadable")

  # The reload function
  def reload(~empty_queue=true, p) =
    log.debug(
      label=id,
      "Reloading playlist."
    )
    playlist_orig := p
    playlist := randomize(playlist_orig())
    has_stopped := false
    if
      empty_queue
    then
      q = s.queue()
      s.set_queue([])
      list.iter(request.destroy, q)
      ignore(s.fetch())
    end
  end

  # When we have more than max_fail failures in a row, we wait for 1 second
  # before trying again in order to avoid infinite loops.
  failed_count = ref(0)
  failed_time = ref(0.)

  # The (real) next function
  def rec next() =
    if
      loop and list.is_empty(playlist())
    then
      on_loop()

      # The above function might have reloaded the playlist
      if
        list.is_empty(playlist())
      then
        playlist := randomize(playlist_orig())
      end
    end

    file =
      if
        list.length(playlist()) > 0
      then
        if
          mode == "random"
        then
          n = random.int(min=0, max=list.length(playlist()))
          list.nth(default="", playlist(), n)
        else
          ret = list.hd(default="", playlist())
          playlist := list.tl(playlist())
          ret
        end
      else
        # Playlist finished
        if
          not has_stopped()
        then
          has_stopped := true
          log.info(
            label=id,
            "Playlist stopped."
          )
          on_done()
        end

        ""
      end

    if
      file == "" or (failed_count() >= max_fail and time() < failed_time() + 1.)
    then
      # Playlist failed too many times recently, don't try next for now.
      null()
    else
      log.debug(
        label=id,
        "Next song will be \"#{file}\"."
      )

      r =
        request.create(
          cue_in_metadata=cue_in_metadata,
          cue_out_metadata=cue_out_metadata,
          file
        )

      if
        check_next(r)
      then
        if
          not request.resolve(r)
        then
          log.info(
            label=id,
            "Could not resolve request: #{request.uri(r)}."
          )
          request.destroy(r)
          ref.incr(failed_count)

          # Playlist failed, call handler.
          if
            failed_count() < max_fail
          then
            log.info(
              label=id,
              "Playlist failed."
            )
            if
              null.defined(on_fail)
            then
              f = null.get(on_fail)
              reload(f())
            end
          end

          failed_time := time()
          (next() : request?)
        else
          failed_count := 0
          r
        end
      else
        log.info(
          label=id,
          "Request #{request.uri(r)} rejected by check_next."
        )

        request.destroy(r)
        next()
      end
    end
  end

  next_fun := next

  # List of songs remaining to be played
  def remaining_files() =
    playlist()
  end

  # Return
  s.{reload=reload, remaining_files=remaining_files}
end

# Read a playlist or a directory and play all files.
# @category Source / Input
# @param ~id Force the value of the source ID.
# @param ~check_next Function used to filter next tracks. A candidate track is \
#   only validated if the function returns true on it. The function is called \
#   before resolution, hence metadata will only be available for requests \
#   corresponding to local files. This is typically used to avoid repetitions, \
#   but be careful: if the function rejects all attempts, the playlist will \
#   enter into a consuming loop and stop playing anything.
# @param ~prefetch How many requests should be queued in advance.
# @param ~loop Loop on the playlist.
# @param ~mime_type Default MIME type for the playlist. `null` means automatic \
#  detection.
# @param ~mode Play the files in the playlist either in the order ("normal" mode), \
#  or shuffle the playlist each time it is loaded, and play it in this order for a \
#  whole round ("randomize" mode), or pick a random file in the playlist each time \
#  ("random" mode).
# @param ~native Use native implementation.
# @param ~max_fail When this number of requests fail to resolve, the whole playlists is considered as failed and `on_fail` is called.
# @param ~on_done Function executed when the playlist is finished.
# @param ~on_fail Function executed when too many requests failed and returning the contents of a fixed playlist.
# @param ~on_reload Callback called after playlist has reloaded.
# @param ~prefix Add a constant prefix to all requests. Useful for passing extra \
#  information using annotate, or for resolution through a particular protocol, \
#  such as replaygain.
# @param ~reload Amount of time (in seconds or rounds), when applicable, before \
#  which the playlist is reloaded; 0 means never.
# @param ~reload_mode Unit of the reload parameter, either "never" (never reload \
#  the playlist), "rounds", "seconds" or "watch" (reload the file whenever it is \
#  changed).
# @param ~register_server_commands Register corresponding server commands
# @param ~timeout Timeout (in sec.) to resolve the request. Defaults to `settings.request.timeout` when `null`.
# @param ~cue_in_metadata Metadata for cue in points. Disabled if `null`.
# @param ~cue_out_metadata Metadata for cue out points. Disabled if `null`.
# @param uri Playlist URI.
# @method reload Reload the playlist.
# @method length Length of the of the playlist (the number of songs it contains).
# @method remaining_files Songs remaining to be played.
def replaces playlist(
  ~id=null(),
  ~check_next=null(),
  ~prefetch=null(),
  ~loop=true,
  ~max_fail=10,
  ~mime_type=null(),
  ~mode="randomize",
  ~native=false,
  ~on_done={()},
  ~on_fail=null(),
  ~on_reload=(fun (_) -> ()),
  ~prefix="",
  ~reload=0,
  ~reload_mode="seconds",
  ~timeout=null(),
  ~cue_in_metadata=null("liq_cue_in"),
  ~cue_out_metadata=null("liq_cue_out"),
  ~register_server_commands=true,
  uri
) =
  id = id ?? playlist.id(default="playlist", uri)
  reload_mode =
    if
      not list.mem(reload_mode, ["never", "rounds", "seconds", "watch"])
    then
      log.severe(
        label=id,
        "Invalid reload mode: #{mode}"
      )
      "seconds"
    else
      reload_mode
    end

  round = ref(0)

  # URI of the current playlist
  playlist_uri = ref(uri)

  # List of files in the current playlist
  files = ref([])

  # The reload function
  reloader_ref = ref(fun (~empty_queue=true) -> ignore(empty_queue))

  failed_loads = ref(0)

  # The load function
  def load_playlist() =
    playlist_uri = path.home.unrelate(playlist_uri())

    log.info(
      label=id,
      "Reloading playlist."
    )

    files =
      try
        playlist.files(
          id=id, mime_type=mime_type, timeout=timeout, playlist_uri
        )
      catch err do
        log.info(
          label=id,
          "Playlist load failed: #{err}"
        )

        ref.incr(failed_loads)

        if
          failed_loads() < max_fail
        then
          []
        else
          log.info(
            label=id,
            "Maximum failures reached!"
          )
          on_fail = on_fail ?? fun () -> []
          on_fail()
        end
      end

    list.map.right(fun (file) -> prefix ^ file, files)
  end

  # Create the source
  files := load_playlist()

  # Reload when the playlist is done
  def on_loop() =
    reloader = reloader_ref()
    if
      reload_mode == "rounds" and reload > 0
    then
      round := round() + 1
      if
        round() >= reload
      then
        round := 0
        reloader()
      end
    end
  end

  watcher_reload_ref = ref(fun (_) -> ())

  def on_reload(uri) =
    watcher_reload = watcher_reload_ref()
    watcher_reload(uri)
    on_reload(uri)
  end

  s =
    playlist.list(
      id=id,
      check_next=check_next,
      prefetch=prefetch,
      loop=loop,
      max_fail=max_fail,
      mode=mode,
      native=native,
      on_done=on_done,
      on_loop=on_loop,
      on_fail=on_fail,
      timeout=timeout,
      cue_in_metadata=cue_in_metadata,
      cue_out_metadata=cue_out_metadata,
      files()
    )

  # The reload function
  def s.reload(~empty_queue=true, ~uri=null()) =
    if
      failed_loads() < max_fail
    then
      if null.defined(uri) then playlist_uri := null.get(uri) end
      log(
        label=id,
        "Reloading playlist with URI #{playlist_uri()}."
      )
      files := load_playlist()
      s.reload(empty_queue=empty_queue, files())
      on_reload(playlist_uri())
    end
  end

  reloader_ref := s.reload

  def s.length() =
    list.length(files())
  end

  source.set_id(s, "playlist")

  # Set up reloading for seconds and watch
  if
    reload_mode == "seconds" and reload > 0
  then
    n = float_of_int(reload)
    thread.run(delay=n, every=n, s.reload)
  elsif
    reload_mode == "watch"
  then
    watcher =
      if
        file.exists(playlist_uri())
      then
        ref(null(file.watch(playlist_uri(), s.reload)))
      else
        ref(null())
      end

    watched_uri = ref(playlist_uri())

    def watcher_reload(uri) =
      if
        uri != watched_uri()
      then
        w = watcher()
        if null.defined(w) then null.get(w).unwatch() end
        watched_uri := uri
        watcher :=
          if file.exists(uri) then file.watch(uri, s.reload) else null() end
      end
    end

    watcher_reload_ref := watcher_reload

    def watcher_shutdown() =
      w = watcher()
      if null.defined(w) then null.get(w).unwatch() end
    end

    s.on_shutdown(watcher_shutdown)
  end

  if
    register_server_commands
  then
    # Set up telnet commands
    s.register_command(
      description=
        "Skip current song in the playlist.",
      usage="skip",
      "skip",
      fun (_) ->
        begin
          s.skip()
          "OK"
        end
    )

    s.register_command(
      description=
        "Return up to 10 next URIs to be played.",
      usage="next",
      "next",
      fun (n) ->
        begin
          n = max(10, int_of_string(default=10, n))
          requests =
            list.fold(
              (fun (cur, el) -> list.length(cur) < n ? [...cur, el] : cur ),
              [],
              s.queue()
            )

          string.concat(
            separator="\n",
            list.map(
              (
                fun (r) ->
                  begin
                    m = request.metadata(r)
                    get = fun (lbl) -> list.assoc(default="?", lbl, m)
                    status = get("status")
                    uri = get("initial_uri")
                    "[#{status}] #{uri}"
                  end
              ),
              requests
            )
          )
        end
    )

    s.register_command(
      description=
        "Reload the playlist, unless already being loaded.",
      usage="reload",
      "reload",
      fun (_) ->
        begin
          s.reload()
          "OK"
        end
    )

    def uri_cmd(uri') =
      if
        uri' == ""
      then
        playlist_uri()
      else
        if
          reload_mode == "watch"
        then
          log.important(
            label=id,
            "Warning: the watched file is not updated for now when changing the \
             uri!"
          )
        end

        # TODO
        playlist_uri := uri'
        s.reload(uri=uri')
        "OK"
      end
    end

    s.register_command(
      description=
        "Print playlist URI if called without an argument, otherwise set a new \
         one and load it.",
      usage=
        "uri [<uri>]",
      "uri",
      uri_cmd
    )
  end

  s
end
