diff --git a/spotify_proxy/.gitignore b/spotify_proxy/.gitignore new file mode 100644 index 0000000..599be4e --- /dev/null +++ b/spotify_proxy/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +/build +erl_crash.dump diff --git a/spotify_proxy/README.md b/spotify_proxy/README.md new file mode 100644 index 0000000..b1c7c23 --- /dev/null +++ b/spotify_proxy/README.md @@ -0,0 +1,24 @@ +# spotify_proxy + +[![Package Version](https://img.shields.io/hexpm/v/spotify_proxy)](https://hex.pm/packages/spotify_proxy) +[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/spotify_proxy/) + +```sh +gleam add spotify_proxy@1 +``` +```gleam +import spotify_proxy + +pub fn main() -> Nil { + // TODO: An example of the project in use +} +``` + +Further documentation can be found at . + +## Development + +```sh +gleam run # Run the project +gleam test # Run the tests +``` diff --git a/spotify_proxy/gleam.toml b/spotify_proxy/gleam.toml new file mode 100644 index 0000000..5f0dded --- /dev/null +++ b/spotify_proxy/gleam.toml @@ -0,0 +1,20 @@ +name = "spotify_proxy" +version = "1.0.0" + +[erlang] +application_start_module = "spotify_proxy" + +[dependencies] +gleam_stdlib = ">= 0.44.0 and < 2.0.0" +gleam_erlang = ">= 1.3.0 and < 2.0.0" +gleam_otp = ">= 1.1.0 and < 2.0.0" +wisp = ">= 2.0.0 and < 3.0.0" +mist = ">= 5.0.3 and < 6.0.0" +envoy = ">= 1.0.2 and < 2.0.0" +gleam_http = ">= 4.2.0 and < 5.0.0" +gleam_httpc = ">= 5.0.0 and < 6.0.0" +gleam_json = ">= 3.0.2 and < 4.0.0" +logging = ">= 1.3.0 and < 2.0.0" + +[dev-dependencies] +gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/spotify_proxy/manifest.toml b/spotify_proxy/manifest.toml new file mode 100644 index 0000000..dac8e4d --- /dev/null +++ b/spotify_proxy/manifest.toml @@ -0,0 +1,42 @@ +# This file was generated by Gleam +# You typically do not need to edit this file + +packages = [ + { name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" }, + { name = "envoy", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, + { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, + { name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" }, + { name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" }, + { name = "gleam_http", version = "4.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "FFE29C3832698AC3EF6202922EC534EE19540152D01A7C2D22CB97482E4AF211" }, + { name = "gleam_httpc", version = "5.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gleam_httpc", source = "hex", outer_checksum = "C545172618D07811494E97AAA4A0FB34DA6F6D0061FDC8041C2F8E3BE2B2E48F" }, + { name = "gleam_json", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "874FA3C3BB6E22DD2BB111966BD40B3759E9094E05257899A7C08F5DE77EC049" }, + { name = "gleam_otp", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7987CBEBC8060B88F14575DEF546253F3116EBE2A5DA6FD82F38243FCE97C54B" }, + { name = "gleam_stdlib", version = "0.63.2", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "962B25C667DA07F4CAB32001F44D3C41C1A89E58E3BBA54F183B482CF6122150" }, + { name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" }, + { name = "gleeunit", version = "1.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "FDC68A8C492B1E9B429249062CD9BAC9B5538C6FBF584817205D0998C42E1DAC" }, + { name = "glisten", version = "8.0.1", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "534BB27C71FB9E506345A767C0D76B17A9E9199934340C975DC003C710E3692D" }, + { name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" }, + { name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" }, + { name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" }, + { name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" }, + { name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" }, + { name = "mist", version = "5.0.3", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7C4BE717A81305323C47C8A591E6B9BA4AC7F56354BF70B4D3DF08CC01192668" }, + { name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" }, + { name = "simplifile", version = "2.3.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "0A868DAC6063D9E983477981839810DC2E553285AB4588B87E3E9C96A7FB4CB4" }, + { name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" }, + { name = "wisp", version = "2.0.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "E9E4CEE4A5ACB41E3D9CFEF7AB07BF7DF670AB32E1CB434A61C6DA6859C66168" }, +] + +[requirements] +envoy = { version = ">= 1.0.2 and < 2.0.0" } +gleam_erlang = { version = ">= 1.3.0 and < 2.0.0" } +gleam_http = { version = ">= 4.2.0 and < 5.0.0" } +gleam_httpc = { version = ">= 5.0.0 and < 6.0.0" } +gleam_json = { version = ">= 3.0.2 and < 4.0.0" } +gleam_otp = { version = ">= 1.1.0 and < 2.0.0" } +gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" } +gleeunit = { version = ">= 1.0.0 and < 2.0.0" } +logging = { version = ">= 1.3.0 and < 2.0.0" } +mist = { version = ">= 5.0.3 and < 6.0.0" } +wisp = { version = ">= 2.0.0 and < 3.0.0" } diff --git a/spotify_proxy/src/spotify_proxy.gleam b/spotify_proxy/src/spotify_proxy.gleam new file mode 100644 index 0000000..e5b8d12 --- /dev/null +++ b/spotify_proxy/src/spotify_proxy.gleam @@ -0,0 +1,81 @@ +import envoy +import gleam/erlang/process +import gleam/otp/actor +import gleam/otp/static_supervisor as sup +import gleam/otp/supervision +import gleam/result +import logging +import spotify_proxy/api +import spotify_proxy/spotify +import spotify_proxy/status +import spotify_proxy/util +import wisp + +pub fn start(_app, _type) -> Result(process.Pid, actor.StartError) { + logging.configure() + logging.set_level(logging.Debug) + + let spotify_id = get_env("SPOTIFY_ID") + let spotify_secret = get_env("SPOTIFY_SECRET") + let spotify_refresh = get_env("SPOTIFY_REFRESH") + + // There are three actors here - one to query the Spotify API, one to hold the current status, + // and one just to handle API requests. + // I'm not entirely sure this is the best way to break these out, but the Spotify actor is *very* + // fallible and I didn't really want to lose statuses if for whatever reason someone's request + // errors or whatever + // Flow is Spotify actor requests status -> Saves to status actor + // Web actor requests status -> Status actor sends to web actor -> Web actor serializes and replies + // + // gg? + + let status_name = process.new_name("spotify_status") + let status_subject = process.named_subject(status_name) + + let spotify_child = + supervision.worker(fn() { + spotify.spawn_link( + spotify_id, + spotify_secret, + spotify_refresh, + status_subject, + ) + }) + + let status_child = + supervision.worker(fn() { + status.spawn_link(status_name) |> result.map(actor.Started(_, Nil)) + }) + + let api_child = api.supervised(status_subject) + + let res = + sup.new(sup.OneForOne) + |> sup.add(spotify_child) + |> sup.add(status_child) + |> sup.add(api_child) + |> sup.start + + case res { + Ok(actor.Started(pid, _data)) -> { + let sup_name = process.new_name("main_supervisor") + let _ = process.register(pid, sup_name) + Ok(pid) + } + Error(why) -> Error(why) + } +} + +fn get_env(key: String) -> String { + case envoy.get(key) { + Ok(v) -> v + Error(Nil) -> { + wisp.log_critical("Missing env var " <> key <> ". Bye :(") + util.stop() + } + } +} + +pub fn main() { + process.sleep_forever() +} diff --git a/spotify_proxy/src/spotify_proxy/api.gleam b/spotify_proxy/src/spotify_proxy/api.gleam new file mode 100644 index 0000000..d6db3d9 --- /dev/null +++ b/spotify_proxy/src/spotify_proxy/api.gleam @@ -0,0 +1,79 @@ +import gleam/erlang/process +import gleam/int +import gleam/json +import gleam/option.{type Option, None, Some} +import gleam/otp/actor +import mist +import spotify_proxy/status +import spotify_proxy/util +import wisp +import wisp/wisp_mist + +type Context { + Context(status_subject: process.Subject(status.Msg)) +} + +pub fn supervised(status_subject: process.Subject(status.Msg)) { + let secret_key_base = wisp.random_string(64) + + let ctx = Context(status_subject:) + + wisp_mist.handler(fn(req) { handle_request(req, ctx) }, secret_key_base) + |> mist.new + |> mist.port(8000) + |> mist.supervised +} + +fn handle_request(req: wisp.Request, ctx: Context) -> wisp.Response { + case wisp.path_segments(req) { + ["now-playing"] -> now_playing(req, ctx) + _ -> wisp.not_found() + } +} + +fn now_playing(_req: wisp.Request, ctx: Context) -> wisp.Response { + let now_playing = actor.call(ctx.status_subject, 250, status.Get) + let resp = status_to_json(now_playing) + wisp.json_response(json.to_string(resp), 200) +} + +fn status_to_json( + currently_playing: Option(status.CurrentlyPlaying), +) -> json.Json { + case currently_playing { + None -> { + json.object([#("playing", json.bool(False))]) + } + Some(currently_playing) -> { + let status.CurrentlyPlaying(song:, set_at:) = currently_playing + let status.Song(artists:, duration_ms:, progress_ms:, url:, name:) = song + + // add the time since progress was recorded to the progress bar :3 + // this doesn't make a huge difference actually unless i increase the refresh timeout thing + // 0 <= progress <= duration_ms + // NOTE: set_at is in SECONDS! + let interpolated_progress = + int.min( + duration_ms, + progress_ms + { int.max(0, util.now() - set_at) * 1000 }, + ) + + json.object([ + #("playing", json.bool(True)), + #("artists", json.array(artists, artist_to_json)), + #("duration_ms", json.int(duration_ms)), + #("progress_ms", json.int(interpolated_progress)), + #("url", json.string(url)), + #("name", json.string(name)), + ]) + } + } +} + +fn artist_to_json(artist: status.Artist) -> json.Json { + let status.Artist(url:, name:) = artist + json.object([ + #("url", json.string(url)), + #("name", json.string(name)), + ]) +} diff --git a/spotify_proxy/src/spotify_proxy/spotify.gleam b/spotify_proxy/src/spotify_proxy/spotify.gleam new file mode 100644 index 0000000..fb16b3a --- /dev/null +++ b/spotify_proxy/src/spotify_proxy/spotify.gleam @@ -0,0 +1,246 @@ +import gleam/bit_array +import gleam/dynamic/decode +import gleam/erlang/process +import gleam/http +import gleam/http/request +import gleam/http/response +import gleam/httpc +import gleam/int +import gleam/json +import gleam/option.{type Option, None, Some} +import gleam/otp/actor +import gleam/result +import gleam/string +import gleam/uri +import logging +import spotify_proxy/status +import spotify_proxy/util + +type State { + State( + self_subject: process.Subject(Nil), + status_subject: process.Subject(status.Msg), + id: String, + secret: String, + refresh: String, + access: Option(Token), + ) +} + +type Token { + Token(key: String, expires: Int) +} + +pub fn spawn_link( + id: String, + secret: String, + refresh: String, + status_subject: process.Subject(status.Msg), +) { + let init = fn(subject: process.Subject(Nil)) { + let selector = process.new_selector() |> process.select(subject) + let state = + State( + self_subject: subject, + status_subject:, + id:, + secret:, + refresh:, + access: None, + ) + process.send(subject, Nil) + actor.initialised(state) + |> actor.selecting(selector) + |> actor.returning(subject) + } + + actor.new_with_initialiser(1000, fn(sub) { Ok(init(sub)) }) + |> actor.on_message(handle_message) + |> actor.start() +} + +// only supports one message - refresh +fn handle_message(state: State, _msg) -> actor.Next(State, Nil) { + let assert Ok(req) = + request.to("https://api.spotify.com/v1/me/player/currently-playing") + use #(state, resp) <- try_or_abnormal(make_request(state, req, 0)) + case resp { + None -> { + logging.log(logging.Info, "No status, not playing") + actor.send(state.status_subject, status.Set(None)) + queue_next(state) + } + Some(resp) -> { + use song <- try_or_abnormal(json.parse( + resp.body, + currently_playing_resp_decoder(), + )) + actor.send(state.status_subject, status.Set(song)) + queue_next(state) + } + } +} + +fn queue_next(state: State) -> actor.Next(State, Nil) { + logging.log( + logging.Debug, + "successfully refreshed status, queueing refresh for 10 secs from now...", + ) + process.send_after(state.self_subject, 10_000, Nil) + actor.continue(state) +} + +fn try_or_abnormal( + res: Result(value, error), + cb: fn(value) -> actor.Next(State, Nil), +) -> actor.Next(State, Nil) { + case res { + Ok(v) -> cb(v) + Error(why) -> { + logging.log( + logging.Error, + "Error, shutting down: " <> string.inspect(why), + ) + actor.stop_abnormal(string.inspect(why)) + } + } +} + +fn make_request( + state: State, + req: request.Request(String), + try: Int, +) -> Result(#(State, Option(response.Response(String))), FetchError) { + case try { + 2 -> { + logging.log(logging.Error, "hit 2 tries for making request, bai...") + Error(RepeatedFailure) + } + try -> { + logging.log(logging.Debug, "try #" <> int.to_string(try)) + use token <- result.try(access_token(state)) + let state = State(..state, access: Some(token)) + let req = request.set_header(req, "Authorization", "Bearer " <> token.key) + use resp <- result.try(httpc.send(req) |> result.map_error(Http)) + case resp.status { + 200 -> Ok(#(state, Some(resp))) + 204 -> Ok(#(state, None)) + 401 -> { + logging.log( + logging.Warning, + "Got 401 fetching status, force refreshing token", + ) + use token <- result.try(refresh_token(state)) + make_request(State(..state, access: Some(token)), req, try + 1) + } + c -> { + logging.log( + logging.Error, + "Got unhandled status code " <> int.to_string(c), + ) + Error(BadStatus(c)) + } + } + } + } +} + +fn access_token(state: State) -> Result(Token, FetchError) { + let now = util.now() + case state.access { + Some(access) if now < access.expires -> Ok(access) + _ -> { + logging.log(logging.Debug, "Token not loaded or expired, refreshing") + let token = refresh_token(state) + logging.log(logging.Debug, "Done!") + token + } + } +} + +fn refresh_token(state: State) -> Result(Token, FetchError) { + let assert Ok(req) = request.to("https://accounts.spotify.com/api/token") + let req = + req + |> request.set_header("Content-Type", "application/x-www-form-urlencoded") + |> request.set_header("Authorization", basic_auth(state.id, state.secret)) + |> request.set_method(http.Post) + |> request.set_body( + uri.query_to_string([ + #("grant_type", "refresh_token"), + #("refresh_token", state.refresh), + ]), + ) + use resp <- result.try(httpc.send(req) |> result.map_error(Http)) + case resp.status { + 200 -> { + resp.body + |> json.parse(refresh_token_resp_decoder()) + |> result.map_error(Json) + } + c -> { + logging.log( + logging.Error, + "Got bad response code " <> int.to_string(c) <> " when refreshing token", + ) + Error(BadStatus(c)) + } + } +} + +fn basic_auth(user: String, pass: String) -> String { + "Basic " + <> { + { user <> ":" <> pass } + |> bit_array.from_string + |> bit_array.base64_encode(True) + } +} + +fn refresh_token_resp_decoder() -> decode.Decoder(Token) { + use access_token <- decode.field("access_token", decode.string) + use expires_in <- decode.field("expires_in", decode.int) + decode.success(Token(key: access_token, expires: util.now() + expires_in)) +} + +type FetchError { + Http(httpc.HttpError) + Json(json.DecodeError) + BadStatus(Int) + RepeatedFailure +} + +fn currently_playing_resp_decoder() -> decode.Decoder(Option(status.Song)) { + use currently_playing <- decode.field("is_playing", decode.bool) + case currently_playing { + False -> decode.success(None) + True -> { + use currently_playing_type <- decode.field( + "currently_playing_type", + decode.string, + ) + case currently_playing_type { + "track" -> { + use progress_ms <- decode.field("progress_ms", decode.int) + use song <- decode.field("item", song_decoder(progress_ms)) + decode.success(Some(song)) + } + _ -> decode.success(None) + } + } + } +} + +fn song_decoder(progress_ms: Int) -> decode.Decoder(status.Song) { + use artists <- decode.field("artists", decode.list(artist_decoder())) + use duration_ms <- decode.field("duration_ms", decode.int) + use url <- decode.subfield(["external_urls", "spotify"], decode.string) + use name <- decode.field("name", decode.string) + decode.success(status.Song(artists:, duration_ms:, progress_ms:, url:, name:)) +} + +fn artist_decoder() -> decode.Decoder(status.Artist) { + use url <- decode.subfield(["external_urls", "spotify"], decode.string) + use name <- decode.field("name", decode.string) + decode.success(status.Artist(url:, name:)) +} diff --git a/spotify_proxy/src/spotify_proxy/spotify_proxy_ffi.erl b/spotify_proxy/src/spotify_proxy/spotify_proxy_ffi.erl new file mode 100644 index 0000000..5a36c5f --- /dev/null +++ b/spotify_proxy/src/spotify_proxy/spotify_proxy_ffi.erl @@ -0,0 +1,6 @@ +-module(spotify_proxy_ffi). +-export([now/0]). + +now() -> + {MegaSecs, Secs, _} = os:timestamp(), + 1000000 * MegaSecs + Secs. diff --git a/spotify_proxy/src/spotify_proxy/status.gleam b/spotify_proxy/src/spotify_proxy/status.gleam new file mode 100644 index 0000000..689febd --- /dev/null +++ b/spotify_proxy/src/spotify_proxy/status.gleam @@ -0,0 +1,73 @@ +import gleam/erlang/process +import gleam/option.{type Option, None} +import gleam/otp/actor +import gleam/result +import gleam/string +import logging +import spotify_proxy/util + +type State = + Option(CurrentlyPlaying) + +pub type Msg { + Set(Option(Song)) + Get(reply_to: process.Subject(Option(CurrentlyPlaying))) +} + +pub fn spawn_link(name: process.Name(Msg)) { + let subject = process.named_subject(name) + + let init = fn() { + let selector = process.new_selector() |> process.select(subject) + let state = None + actor.initialised(state) + |> actor.selecting(selector) + |> actor.returning(subject) + } + + use actor.Started(pid, _data) <- result.try( + actor.new_with_initialiser(1000, fn(_) { Ok(init()) }) + |> actor.on_message(handle_message) + |> actor.start(), + ) + + let _ = process.register(pid, name) + Ok(pid) +} + +fn handle_message(state: State, msg: Msg) -> actor.Next(State, Msg) { + case msg { + Set(currently_playing) -> { + logging.log( + logging.Debug, + "setting new status: " <> string.inspect(currently_playing), + ) + + currently_playing + |> option.map(CurrentlyPlaying(song: _, set_at: util.now())) + |> actor.continue() + } + Get(reply_subject) -> { + process.send(reply_subject, state) + actor.continue(state) + } + } +} + +pub type CurrentlyPlaying { + CurrentlyPlaying(song: Song, set_at: Int) +} + +pub type Song { + Song( + artists: List(Artist), + duration_ms: Int, + progress_ms: Int, + url: String, + name: String, + ) +} + +pub type Artist { + Artist(url: String, name: String) +} diff --git a/spotify_proxy/src/spotify_proxy/util.gleam b/spotify_proxy/src/spotify_proxy/util.gleam new file mode 100644 index 0000000..65c52c5 --- /dev/null +++ b/spotify_proxy/src/spotify_proxy/util.gleam @@ -0,0 +1,13 @@ +import gleam/erlang/process + +@external(erlang, "init", "stop") +fn init_stop() -> a + +pub fn stop() -> a { + let v = init_stop() + process.sleep_forever() + v +} + +@external(erlang, "spotify_proxy_ffi", "now") +pub fn now() -> Int diff --git a/spotify_proxy/test/spotify_proxy_test.gleam b/spotify_proxy/test/spotify_proxy_test.gleam new file mode 100644 index 0000000..fba3c88 --- /dev/null +++ b/spotify_proxy/test/spotify_proxy_test.gleam @@ -0,0 +1,13 @@ +import gleeunit + +pub fn main() -> Nil { + gleeunit.main() +} + +// gleeunit test functions end in `_test` +pub fn hello_world_test() { + let name = "Joe" + let greeting = "Hello, " <> name <> "!" + + assert greeting == "Hello, Joe!" +}