diff --git a/src/libs/extra/audioscrobbler.liq b/src/libs/extra/audioscrobbler.liq new file mode 100644 index 0000000000..6888132945 --- /dev/null +++ b/src/libs/extra/audioscrobbler.liq @@ -0,0 +1,398 @@ +let error.audioscrobbler = error.register("audioscrobbler") + +let settings.audioscrobbler = + settings.make.void( + "Audioscrobbler settings" + ) + +let settings.audioscrobbler.api_key = + settings.make( + description= + "Default API key for audioscrobbler", + "" + ) + +let settings.audioscrobbler.api_secret = + settings.make( + description= + "Default API secret for audioscrobbler", + "" + ) + +audioscrobbler = () + +def audioscrobbler.request( + ~base_url="http://ws.audioscrobbler.com/2.0", + ~api_key=null(), + ~api_secret=null(), + params +) = + api_key = api_key ?? settings.audioscrobbler.api_key() + api_secret = api_secret ?? settings.audioscrobbler.api_secret() + + if + api_key == "" or api_secret == "" + then + error.raise( + error.audioscrobbler, + "`api_key` or `api_secret` missing!" + ) + end + + params = [("api_key", api_key), ...params] + + sig_params = list.sort(fun (v, v') -> string.compare(fst(v), fst(v')), params) + sig_params = list.map(fun (v) -> "#{fst(v)}#{(snd(v) : string)}", sig_params) + sig_params = string.concat(separator="", sig_params) + + api_sig = string.digest("#{sig_params}#{api_secret}") + + http.post( + base_url, + headers=[("Content-Type", "application/x-www-form-urlencoded")], + data=http.www_form_urlencoded([...params, ("api_sig", api_sig)]) + ) +end + +def audioscrobbler.check_response(resp) = + let xml.parse ({lfm = {xml_params = {status}}} : + {lfm: {xml_params: {status: string}}} + ) = resp + if + (status == "failed") + then + error_ref = error + let xml.parse ({lfm = {error = {xml_params = {code}}}} : + {lfm: {error: string.{ xml_params: {code: int} }}} + ) = resp + error_ref.raise( + error_ref.audioscrobbler, + "Error #{code}: #{error}" + ) + end +end + +def audioscrobbler.auth( + ~username, + ~password, + ~api_key=null(), + ~api_secret=null() +) = + resp = + audioscrobbler.request( + api_key=api_key, + api_secret=api_secret, + [ + ("method", "auth.getMobileSession"), + ("username", username), + ("password", password) + ] + ) + + audioscrobbler.check_response(resp) + + try + let xml.parse ({lfm = {session = {name, key}}} : + {lfm: {session: {name: string, key: string}}} + ) = resp + assert(name == username) + key + catch err do + error.raise( + error.invalid, + "Invalid response: #{resp}, error: #{err}" + ) + end +end + +let audioscrobbler.api = {track=()} + +# Submit a track to the audioscrobbler +# `track.updateNowPlaying` API. +# @category Interaction +def audioscrobbler.api.track.updateNowPlaying( + ~username, + ~password, + ~session_key=null(), + ~api_key=null(), + ~api_secret=null(), + ~artist, + ~track, + ~album=null(), + ~context=null(), + ~trackNumber=null(), + ~mbid=null(), + ~albumArtist=null(), + ~duration=null() +) = + session_key = + session_key + ?? + audioscrobbler.auth( + username=username, + password=password, + api_key=api_key, + api_secret=api_secret + ) + + params = + [ + ("track", track), + ("artist", artist), + ...(null.defined(album) ? [("album", null.get(album))] : [] ), + ...(null.defined(context) ? [("context", null.get(context))] : [] ), + ...( + null.defined(trackNumber) + ? [("trackNumber", string((null.get(trackNumber) : int)))] : [] + ), + ...(null.defined(mbid) ? [("mbid", null.get(mbid))] : [] ), + ...( + null.defined(albumArtist) + ? [("albumArtist", null.get(albumArtist))] : [] + ), + ...( + null.defined(duration) + ? [("duration", string((null.get(duration) : int)))] : [] + ) + ] + + log.info( + label="audioscrobbler.api.track.updateNowPlaying", + "Submitting updateNowPlaying with: #{params}" + ) + + resp = + audioscrobbler.request( + api_key=api_key, + api_secret=api_secret, + [...params, ("method", "track.updateNowPlaying"), ("sk", session_key)] + ) + + audioscrobbler.check_response(resp) + + try + let xml.parse (v : + { + lfm: { + nowplaying: { + track: string.{ xml_params: {corrected: string} }, + artist: string.{ xml_params: {corrected: string} }, + album: string?.{ xml_params: {corrected: string} }, + albumArtist: string?.{ xml_params: {corrected: string} }, + timestamp: string, + ignoredMessage: {xml_params: {code: string}} + }, + xml_params: {status: string} + } + } + ) = resp + + log.info( + label="audioscrobbler.api.track.updateNowPlaying", + "Done submitting updateNowPlaying with: #{params}" + ) + + v + catch err do + error.raise( + error.invalid, + "Invalid response: #{resp}, error: #{err}" + ) + end +end + +# Submit a track to the audioscrobbler +# `track.scrobble` API. +# @category Interaction +def audioscrobbler.api.track.scrobble( + ~username, + ~password, + ~session_key=null(), + ~api_key=null(), + ~api_secret=null(), + ~artist, + ~track, + ~timestamp=time(), + ~album=null(), + ~context=null(), + ~streamId=null(), + ~chosenByUser=true, + ~trackNumber=null(), + ~mbid=null(), + ~albumArtist=null(), + ~duration=null() +) = + session_key = + session_key + ?? + audioscrobbler.auth( + username=username, + password=password, + api_key=api_key, + api_secret=api_secret + ) + + params = + [ + ("track", track), + ("artist", artist), + ("timestamp", string(timestamp)), + ...(null.defined(album) ? [("album", null.get(album))] : [] ), + ...(null.defined(context) ? [("context", null.get(context))] : [] ), + ...(null.defined(streamId) ? [("streamId", null.get(streamId))] : [] ), + + ("chosenByUser", chosenByUser ? "1" : "0" ), + ...( + null.defined(trackNumber) + ? [("trackNumber", string((null.get(trackNumber) : int)))] : [] + ), + ...(null.defined(mbid) ? [("mbid", null.get(mbid))] : [] ), + ...( + null.defined(albumArtist) + ? [("albumArtist", null.get(albumArtist))] : [] + ), + ...( + null.defined(duration) + ? [("duration", string((null.get(duration) : int)))] : [] + ) + ] + + log.info( + label="audioscrobbler.api.track.scrobble", + "Submitting updateNowPlaying with: #{params}" + ) + + resp = + audioscrobbler.request( + api_key=api_key, + api_secret=api_secret, + [...params, ("method", "track.scrobble"), ("sk", session_key)] + ) + + audioscrobbler.check_response(resp) + + try + let xml.parse (v : + { + lfm: { + scrobbles: { + scrobble: { + track: string.{ xml_params: {corrected: string} }, + artist: string.{ xml_params: {corrected: string} }, + album: string?.{ xml_params: {corrected: string} }, + albumArtist: string?.{ xml_params: {corrected: string} }, + timestamp: string, + ignoredMessage: {xml_params: {code: string}} + }, + xml_params: {ignored: string, accepted: string} + }, + xml_params: {status: string} + } + } + ) = resp + + log.info( + label="audioscrobbler.api.track.scrobble", + "Done submitting updateNowPlaying with: #{params}" + ) + + v + catch err do + error.raise( + error.invalid, + "Invalid response: #{resp}, error: #{err}" + ) + end +end + +let source_on_end = source.on_end +let source_on_metadata = source.on_metadata + +# Submit songs using audioscrobbler, respecting the full protocol: +# First signal song as now playing when starting, and +# then submit song when it ends. +# @category Interaction +# @flag extra +# @param ~source Source for tracks. Should be one of: "broadcast", "user", "recommendation" or "unknown". Since liquidsoap is intended for radio broadcasting, this is the default. Sources other than user don't need duration to be set. +# @param ~delay Submit song when there is only this delay left, in seconds. +# @param ~force If remaining time is null, the song will be assumed to be skipped or cut, and not submitted. Set this to `true` to prevent this behavior +def audioscrobbler.submit( + ~username, + ~password, + ~api_key=null(), + ~api_secret=null(), + ~delay=10., + ~force=false, + s +) = + session_key = + audioscrobbler.auth( + username=username, + password=password, + api_key=api_key, + api_secret=api_secret + ) + + def apply_meta(fn, m) = + def c(v) = + v == "" ? null() : v + end + track = m["title"] + album = c(m["album"]) + artist = m["artist"] + trackNumber = + try + null.map(int_of_string, c(m["tracknumber"])) + catch _ do + null() + end + albumArtist = c(m["albumartist"]) + ignore( + fn( + username=username, + password=password, + api_key=api_key, + api_secret=api_secret, + session_key=session_key, + track=track, + artist=artist, + album=album, + trackNumber=trackNumber, + albumArtist=albumArtist + ) + ) + end + + def now_playing(m) = + try + apply_meta(audioscrobbler.api.track.updateNowPlaying, m) + catch err do + log.important( + "Error while submitting nowplaying info for #{source.id(s)}: #{err}" + ) + end + end + + s = source_on_metadata(s, now_playing) + f = + fun (rem, m) -> + # Avoid skipped songs + if + rem > 0. or force + then + try + apply_meta(audioscrobbler.api.track.scrobble, m) + catch err do + log.important( + "Error while submitting scrobble info for #{source.id(s)}: #{err}" + ) + end + else + log( + label="audioscrobbler.submit", + level=4, + "Remaining time null: will not submit song (song skipped ?)" + ) + end + source_on_end(s, delay=delay, f) +end diff --git a/src/libs/extra/lastfm.liq b/src/libs/extra/lastfm.liq deleted file mode 100644 index 8e2405c0d4..0000000000 --- a/src/libs/extra/lastfm.liq +++ /dev/null @@ -1,185 +0,0 @@ -%ifdef audioscrobbler.submit -librefm = () -lastfm = () - -# Submit metadata to libre.fm using the audioscrobbler protocol. -# @category Interaction -# @flag extra -# @param ~source Source for tracks. Should be one of: "broadcast", "user", "recommendation" or "unknown". Since liquidsoap is intended for radio broadcasting, this is the default. Sources other than user don't need duration to be set. -# @param ~length Try to submit length information. This operation can be CPU intensive. Value forced to true when used with the "user" source type. -def librefm.submit(~user, ~password, ~source="broadcast", ~length=false, m) = - audioscrobbler.submit( - user=user, - password=password, - source=source, - length=length, - host="turtle.libre.fm", - port=80, - m - ) -end - -# Submit metadata to lastfm.fm using the audioscrobbler protocol. -# @category Interaction -# @flag extra -# @param ~source Source for tracks. Should be one of: "broadcast", "user", "recommendation" or "unknown". Since liquidsoap is intended for radio broadcasting, this is the default. Sources other than user don't need duration to be set. -# @param ~length Try to submit length information. This operation can be CPU intensive. Value forced to true when used with the "user" source type. -def lastfm.submit(~user, ~password, ~source="broadcast", ~length=false, m) = - audioscrobbler.submit( - user=user, - password=password, - source=source, - length=length, - host="post.audioscrobbler.com", - port=80, - m - ) -end - -# Submit metadata to libre.fm using the audioscrobbler protocol (nowplaying mode). -# @category Interaction -# @flag extra -# @param ~length Try to submit length information. This operation can be CPU intensive. Value forced to true when used with the "user" source type. -def librefm.nowplaying(~user, ~password, ~length=false, m) = - audioscrobbler.nowplaying( - user=user, - password=password, - length=length, - host="turtle.libre.fm", - port=80, - m - ) -end - -# Submit metadata to lastfm.fm using the audioscrobbler protocol (nowplaying mode). -# @category Interaction -# @flag extra -# @param ~length Try to submit length information. This operation can be CPU intensive. Value forced to true when used with the "user" source type. -def lastfm.nowplaying(~user, ~password, ~length=false, m) = - audioscrobbler.nowplaying( - user=user, - password=password, - length=length, - host="post.audioscrobbler.com", - port=80, - m - ) -end - -let source_on_end = source.on_end -let source_on_metadata = source.on_metadata - -# Submit songs using audioscrobbler, respecting the full protocol: -# First signal song as now playing when starting, and -# then submit song when it ends. -# @category Interaction -# @flag extra -# @param ~source Source for tracks. Should be one of: "broadcast", "user", "recommendation" or "unknown". Since liquidsoap is intended for radio broadcasting, this is the default. Sources other than user don't need duration to be set. -# @param ~length Try to submit length information. This operation can be CPU intensive. Value forced to true when used with the "user" source type. -# @param ~delay Submit song when there is only this delay left, in seconds. -# @param ~force If remaining time is null, the song will be assumed to be skipped or cut, and not submitted. Set to zero to disable this behaviour. -def audioscrobbler.submit.full( - ~user, - ~password, - ~host="post.audioscrobbler.com", - ~port=80, - ~source="broadcast", - ~length=false, - ~delay=10., - ~force=false, - s -) = - def f(m) = - audioscrobbler.nowplaying( - user=user, password=password, host=host, port=port, length=length, m - ) - end - - s = source_on_metadata(s, f) - f = - fun (rem, m) -> - # Avoid skipped songs - if - rem > 0. or force - then - audioscrobbler.submit( - user=user, - password=password, - host=host, - port=port, - length=length, - source=source, - m - ) - else - log( - label="audioscrobbler.submit.full", - level=4, - "Remaining time null: will not submit song (song skipped ?)" - ) - end - source_on_end(s, delay=delay, f) -end - -# Submit songs to librefm using audioscrobbler, respecting the full protocol: -# First signal song as now playing when starting, and -# then submit song when it ends. -# @category Interaction -# @flag extra -# @param ~source Source for tracks. Should be one of: "broadcast", "user", "recommendation" or "unknown". Since liquidsoap is intended for radio broadcasting, this is the default. Sources other than user don't need duration to be set. -# @param ~length Try to submit length information. This operation can be CPU intensive. Value forced to true when used with the "user" source type. -# @param ~delay Submit song when there is only this delay left, in seconds. If remaining time is less than this value, the song will be assumed to be skipped or cut, and not submitted. Set to zero to disable this behaviour. -# @param ~force If remaining time is null, the song will be assumed to be skipped or cut, and not submitted. Set to zero to disable this behaviour. -def librefm.submit.full( - ~user, - ~password, - ~source="broadcast", - ~length=false, - ~delay=10., - ~force=false, - s -) = - audioscrobbler.submit.full( - user=user, - password=password, - source=source, - length=length, - host="turtle.libre.fm", - port=80, - delay=delay, - force=force, - s - ) -end - -# Submit songs to lastfm using audioscrobbler, respecting the full protocol: -# First signal song as now playing when starting, and -# then submit song when it ends. -# @category Interaction -# @flag extra -# @param ~source Source for tracks. Should be one of: "broadcast", "user", "recommendation" or "unknown". Since liquidsoap is intended for radio broadcasting, this is the default. Sources other than user don't need duration to be set. -# @param ~length Try to submit length information. This operation can be CPU intensive. Value forced to true when used with the "user" source type. -# @param ~delay Submit song when there is only this delay left, in seconds. If remaining time is less than this value, the song will be assumed to be skipped or cut, and not submitted. Set to zero to disable this behaviour. -# @param ~force If remaining time is null, the song will be assumed to be skipped or cut, and not submitted. Set to zero to disable this behaviour. -def lastfm.submit.full( - ~user, - ~password, - ~source="broadcast", - ~length=false, - ~delay=10., - ~force=false, - s -) = - audioscrobbler.submit.full( - user=user, - password=password, - source=source, - length=length, - host="post.audioscrobbler.com", - port=80, - delay=delay, - force=force, - s - ) -end -%endif diff --git a/src/libs/http.liq b/src/libs/http.liq index cece16dd1b..70d550219a 100644 --- a/src/libs/http.liq +++ b/src/libs/http.liq @@ -1,5 +1,22 @@ # Set of HTTP utils. +# Prepare a list of `(string, string)` arguments for +# sending as `"application/x-www-form-urlencoded"` content +# @category Internet +def http.www_form_urlencoded(params) = + params = + list.map( + fun (v) -> + begin + let (key, value) = v + "#{url.encode(key)}=#{url.encode(value)}" + end, + params + ) + + string.concat(separator="&", params) +end + # Prepare a list of data to be sent as multipart form data. # @category Internet # @param ~boundary Specify boundary to use for multipart/form-data. diff --git a/src/libs/stdlib.liq b/src/libs/stdlib.liq index dacb255f90..51f708e55e 100644 --- a/src/libs/stdlib.liq +++ b/src/libs/stdlib.liq @@ -48,7 +48,7 @@ %include_extra "extra/source.liq" %include_extra "extra/http.liq" %include_extra "extra/externals.liq" -%include_extra "extra/lastfm.liq" +%include_extra "extra/audioscrobbler.liq" %include_extra "extra/server.liq" %include_extra "extra/telnet.liq" %include_extra "extra/interactive.liq"