diff --git a/CHANGES.md b/CHANGES.md index 3fe09d0f5c..0a67b1ccd6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -23,6 +23,7 @@ New: - Optimized runtime (#3927, #3928, #3919) - Added `finally` to execute code regardless of whether or not an exception is raised (see: #3895 for more details). +- Added support for Spinitron submission API (#4158) - Removed gstreamer support. Gstreamer's architecture was never a good fit for us and created a huge maintenance and debugging burden and it had been marked as deprecated for a while. Most, if not all of its features should be available using diff --git a/src/libs/extra/spinitron.liq b/src/libs/extra/spinitron.liq new file mode 100644 index 0000000000..3dc050ce7f --- /dev/null +++ b/src/libs/extra/spinitron.liq @@ -0,0 +1,277 @@ +let spinitron = {submit=()} + +# Submit a track to the spinitron track system +# and return the raw response. +# @category Interaction +# @flag extra +# @param ~api_key API key +def spinitron.submit.raw( + ~host="https://spinitron.com/api", + ~api_key, + ~live=false, + ~start=null(), + ~duration=null(), + ~artist, + ~release=null(), + ~label=null(), + ~genre=null(), + ~song, + ~composer=null(), + ~isrc=null() +) = + params = [("song", song), ("artist", artist)] + + def fold_optional_string_params(params, param) = + let (label, param) = param + if + null.defined(param) + then + [(label, null.get(param)), ...params] + else + params + end + end + + params = + list.fold( + fold_optional_string_params, + params, + [ + ("live", null.map(fun (b) -> b ? "1" : "0" , (live : bool?))), + ("start", start), + ("duration", null.map(string, (duration : int?))), + ("release", release), + ("label", label), + ("genre", genre), + ("composer", composer), + ("isrc", isrc) + ] + ) + + def encode_param(param) = + let (label, param) = param + "#{label}=#{url.encode(param)}" + end + + params = string.concat(separator="&", list.map(encode_param, params)) + + http.post( + data=params, + headers= + [ + ("Accept", "application/json"), + ("Content-Type", "application/x-www-form-urlencoded"), + ( + "Authorization", + "Bearer #{(api_key : string)}" + ) + ], + "#{host}/spins" + ) +end + +# Submit a track to the spinitron track system +# and return the parsed response +# @category Interaction +# @flag extra +# @param ~api_key API key +def replaces spinitron.submit(%argsof(spinitron.submit.raw)) = + resp = spinitron.submit.raw(%argsof(spinitron.submit.raw)) + + if + resp.status_code == 201 + then + let json.parse (resp : + { + id: int, + playlist_id: int, + "start" as spin_start: string, + "end" as spin_end: string?, + duration: int?, + timezone: string?, + image: string?, + classical: bool?, + artist: string, + "artist-custom" as artist_custom: string?, + composer: string?, + release: string?, + "release-custom" as release_custom: string?, + va: bool?, + label: string?, + "label-custom" as label_custom: string?, + released: int?, + medium: string?, + genre: string?, + song: string, + note: string?, + request: bool?, + local: bool?, + new: bool?, + work: string?, + conductor: string?, + performers: string?, + ensemble: string?, + "catalog-number" as catalog_number: string?, + isrc: string?, + upc: string?, + iswc: string?, + "_links" as links: {self: {href: string}?, playlist: {href: string}?}? + } + ) = resp + + resp + elsif + resp.status_code == 422 + then + let json.parse (errors : [{field: string, message: string}]) = resp + + errors = + list.map( + fun (p) -> + begin + let {field, message} = p + "#{field}: #{message}" + end, + errors + ) + + errors = + string.concat( + separator= + ", ", + errors + ) + + error.raise( + error.raise( + error.http, + "Invalid fields: #{errors}" + ) + ) + else + let json.parse ({name, message, code, status, type} : + {name: string, message: string, code: int, status: int, type: string?} + ) = resp + + type = type ?? "undefined" + + error.raise( + error.raise( + error.http, + "#{name}: #{message} (code: #{code}, status: #{status}, type: #{type})" + ) + ) + end +end + +# Submit a spin using the given metadata to the spinitron track system +# and return the parsed response. `artist` and `song` (or `title`) must +# be present either as metadata or as optional argument. +# @category Interaction +# @flag extra +# @param m Metadata to submit. Overrides optional arguments when present. +# @param ~mapper Metadata mapper that can be used to map metadata fields to spinitron's expected. \ +# Returned metadata are added to the submitted metadata. By default, `title` is \ +# mapped to `song` and `album` to `release` if neither of those passed otherwise. +# @param ~api_key API key +def spinitron.submit.metadata( + %argsof(spinitron.submit[!artist,!song]), + ~mapper=( + fun (m) -> + [ + ...(m["song"] != "" or m["title"] == "" ? [] : [("song", m["title"])] ), + ...( + m["release"] != "" or m["album"] == "" + ? [] : [("release", m["album"])] + ) + ] + ), + ~artist=null(), + ~song=null(), + m +) = + m = [...m, ...mapper(m)] + + def conv_opt_arg(convert, label, default) = + list.assoc.mem(label, m) ? convert(m[label]) : default + end + + opt_arg = + fun (label, default) -> conv_opt_arg(fun (x) -> null(x), label, default) + + live = conv_opt_arg(bool_of_string, "live", live) + start = opt_arg("start", start) + duration = conv_opt_arg(int_of_string, "duration", duration) + artist = opt_arg("artist", artist) + release = opt_arg("release", release) + label = opt_arg("label", label) + genre = opt_arg("genre", genre) + song = opt_arg("song", song) + composer = opt_arg("composer", composer) + isrc = opt_arg("isrc", isrc) + + if + artist == null() or song == null() + then + error.raise( + error.invalid, + "Both \"artist\" and \"song\" (or \"title\" metadata) must be provided!" + ) + end + + artist = null.get(artist) + song = null.get(song) + + res = spinitron.submit(%argsof(spinitron.submit)) + + print(res) + + res +end + +# Specialized version of `source.on_metadata` that submits spins using +# the source's metadata to the spinitron track system. `artist` and `song` +# (or `title`) must be present either as metadata or as optional argument. +# @category Interaction +# @flag extra +# @param m Metadata to submit. Overrides optional arguments when present. +# @param ~api_key API key +def spinitron.submit.on_metadata( + ~id=null(), + %argsof(spinitron.submit.metadata), + s +) = + def on_metadata(m) = + if + m["title"] == "" and m["song"] == "" + then + log.severe( + label=source.id(s), + "Field \"song\" or \"title\" missing, skipping metadata spinitron \ + submission." + ) + elsif + m["artist"] == "" + then + log.severe( + label=source.id(s), + "Field \"artist\" missing, skipping metadata spinitron submission." + ) + else + try + ignore(spinitron.submit.metadata(%argsof(spinitron.submit.metadata), m)) + log.important( + label=source.id(s), + "Successfully submitted spin from metadata" + ) + catch err do + log.severe( + label=source.id(s), + "Error while submitting spin from metadata: #{err}" + ) + end + end + end + + source.on_metadata(id=id, s, on_metadata) +end diff --git a/src/libs/stdlib.liq b/src/libs/stdlib.liq index c58ae30c42..dacb255f90 100644 --- a/src/libs/stdlib.liq +++ b/src/libs/stdlib.liq @@ -54,6 +54,7 @@ %include_extra "extra/interactive.liq" %include_extra "extra/visualization.liq" %include_extra "extra/openai.liq" +%include_extra "extra/spinitron.liq" %include_extra "extra/metadata.liq" %include_extra "extra/fades.liq" %include_extra "extra/video.liq"