extends Node ## Client for the MusicServer REST API ([code]/api/v1[/code]). ## ## Registered as the [code]MusicServerClient[/code] autoload, so every script can call it ## directly, e.g. [code]MusicServerClient.fetch_song_list(on_done)[/code]. All HTTP traffic ## goes through [member transport]; tests replace it with a fake to answer requests ## without a running server. ## ## Requests need a token, which the client gets with [code]Settings.api_password[/code] ## the first time it is needed and then reuses. When the server answers 401 (the token ## expired, or the server restarted), it gets a new token and sends the request once ## more. Callers never see the token. ## ## JSON objects are handed to [code]on_done[/code] as the classes generated from the ## server's OpenAPI spec (see [code]server/generated/[/code]), lists of them as typed ## Arrays. [code]on_failed[/code] is called with no arguments when a request fails, ## including when no token could be had. ## Path of the API on the server, added between [code]Settings.default_path[/code] and ## each endpoint. const API_PATH: String = "/api/v1" ## Sent when asking for a token, so the server can tell its clients apart. const CLIENT_TYPE: String = "godot" ## Sends the actual HTTP requests. Swap it out in tests. var transport: HttpTransport var _token: String = "" # The server the token is for; switching server in the settings needs a new token var _token_server: String = "" # [on_token, on_failed] pairs waiting for the token request that is under way var _token_waiters: Array[Array] = [] func _ready() -> void: if transport == null: transport = HttpTransport.new(self) # Queue ## Fetches the songs already in the server playlist as an [code]Array[SongInfo][/code]. func fetch_song_list(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_list("/music/list", SongInfo, on_done, on_failed) ## Downloads a random song as MP3 bytes. With [param low_played] the server prefers ## soundtracks that have been played the least. func fetch_random_song( low_played: bool, on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/music/rand/low" if low_played else "/music/rand", on_done, on_failed) ## Downloads a random song as MP3 bytes, picked the way the server did before low played ## mode existed: any soundtrack is as likely as any other. func fetch_random_song_classic(on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/music/rand/classic", on_done, on_failed) ## Adds the most recently fetched random song to the server playlist. func add_to_queue(on_done: Callable, on_failed: Callable = Callable()) -> void: send("/music/addQue", on_done, on_failed) ## Fetches the [SongInfo] of the most recently queued song. func fetch_song_info(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_object("/music/info", SongInfo, on_done, on_failed) ## Downloads a song from the server playlist as MP3 bytes. func fetch_song(song_no: int, on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/music?song=" + str(song_no), on_done, on_failed) ## Downloads the song after the current one in the server playlist as MP3 bytes. func fetch_next_song(on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/music/next", on_done, on_failed) ## Downloads the song before the current one in the server playlist as MP3 bytes. func fetch_previous_song(on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/music/previous", on_done, on_failed) ## Downloads the sound test song as MP3 bytes. func fetch_sound_test_song(on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/music/soundTest", on_done, on_failed) ## Clears the server playlist. func reset_playlist(on_done: Callable, on_failed: Callable = Callable()) -> void: send("/music/reset", on_done, on_failed) ## Records in the server statistics that a song has been played. func mark_song_played(song_no: int) -> void: send("/music/played?song=" + str(song_no), Callable(), Callable(), HTTPClient.METHOD_PUT) ## Records in the server statistics that the current song has been played. func mark_current_song_played(on_done: Callable, on_failed: Callable = Callable()) -> void: send("/music/addPlayed", on_done, on_failed) # Soundtracks and songs ## Fetches every soundtrack name, shuffled, as an Array of Strings. func fetch_all_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json("/music/all", on_done, on_failed) ## Fetches every soundtrack name, sorted, as an Array of Strings. func fetch_all_soundtracks_ordered(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json("/music/all/order", on_done, on_failed) ## Fetches every soundtrack as an [code]Array[Soundtrack][/code]. func fetch_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_list("/soundtracks", Soundtrack, on_done, on_failed) ## Fetches a soundtrack and its songs as a [SoundtrackWithSongList]. func fetch_soundtrack( uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_object("/soundtracks/" + uuid.uri_encode(), SoundtrackWithSongList, on_done, on_failed) ## Fetches a song as a [Song]. func fetch_song_by_uuid( uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_object("/songs/" + uuid.uri_encode(), Song, on_done, on_failed) ## Downloads a song by its UUID as MP3 bytes. func fetch_song_file(uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/songs/%s/file" % uuid.uri_encode(), on_done, on_failed) # Statistics ## Fetches the play statistics of the whole library as a [StatisticsSummary]. func fetch_statistics_summary(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_object("/statistics/summary", StatisticsSummary, on_done, on_failed) ## Fetches the [param limit] most played soundtracks as an ## [code]Array[SoundtrackWithSongs][/code]. The server allows a limit of 1 to 100. func fetch_most_played_soundtracks( limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void: _fetch_soundtrack_statistics("most-played", limit, on_done, on_failed) ## Fetches the [param limit] least played soundtracks as an ## [code]Array[SoundtrackWithSongs][/code]. func fetch_least_played_soundtracks( limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void: _fetch_soundtrack_statistics("least-played", limit, on_done, on_failed) ## Fetches the [param limit] most recently played soundtracks as an ## [code]Array[SoundtrackWithSongs][/code]. func fetch_last_played_soundtracks( limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void: _fetch_soundtrack_statistics("last-played", limit, on_done, on_failed) ## Fetches the [param limit] soundtracks that were played longest ago, leaving out those ## never played, as an [code]Array[SoundtrackWithSongs][/code]. func fetch_oldest_played_soundtracks( limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void: _fetch_soundtrack_statistics("oldest-played", limit, on_done, on_failed) ## Fetches every soundtrack that has never been played as an ## [code]Array[SoundtrackWithSongs][/code]. func fetch_never_played_soundtracks( on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_list("/statistics/soundtracks/never-played", SoundtrackWithSongs, on_done, on_failed) ## Fetches the [param limit] most played songs as an ## [code]Array[SongInfoForStats][/code]. func fetch_most_played_songs( limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_list("/statistics/songs/most-played?limit=%d" % limit, SongInfoForStats, on_done, on_failed) ## Fetches the [param limit] least played songs as an ## [code]Array[SongInfoForStats][/code]. func fetch_least_played_songs( limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_list("/statistics/songs/least-played?limit=%d" % limit, SongInfoForStats, on_done, on_failed) # Characters ## Fetches the names of all character images. func fetch_character_list(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json("/character/list", on_done, on_failed) ## Downloads a character image as PNG bytes. func fetch_character_image( character_name: String, on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/character?name=" + character_name.uri_encode(), on_done, on_failed) # Sync ## Starts syncing the server's soundtrack library with its music folder, looking only ## at what changed. The server answers 423 if a sync is already running. func start_sync(on_done: Callable, on_failed: Callable = Callable()) -> void: send("/sync", on_done, on_failed, HTTPClient.METHOD_POST) ## Starts syncing every soundtrack again, changed or not. func start_full_sync(on_done: Callable, on_failed: Callable = Callable()) -> void: send("/sync/full", on_done, on_failed, HTTPClient.METHOD_POST) ## Fetches the sync state: [code]{progress, time_spent}[/code] while running, or the ## sync result once finished. func fetch_sync_progress(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json("/sync/progress", on_done, on_failed) # Server ## Checks that the server is up, without a token. Calls [param on_done] with its status ## Dictionary. func fetch_health(on_done: Callable, on_failed: Callable = Callable()) -> void: _send_once("/health", HTTPClient.METHOD_GET, "", _parse_json.bind("/health", on_done, on_failed), on_failed) ## Fetches the server version and its changes as a [VersionData]. func fetch_version(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_object("/version", VersionData, on_done, on_failed) ## Fetches every server version as an [code]Array[VersionData][/code]. func fetch_version_history(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_list("/version/history", VersionData, on_done, on_failed) # Game downloads ## Fetches the name of the newest MusicPlayer release. func fetch_latest_client_version(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json("/download", on_done, on_failed) ## Fetches the file names in the newest MusicPlayer release. func fetch_client_assets(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json("/download/list", on_done, on_failed) ## Downloads the newest MusicPlayer for Windows. func download_client_windows(on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/download/windows", on_done, on_failed) ## Downloads the newest MusicPlayer for Linux. func download_client_linux(on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/download/linux", on_done, on_failed) # Tokens ## Tells the server to forget the token, e.g. when quitting. The next request gets a ## new one. func delete_token(on_done: Callable = Callable(), on_failed: Callable = Callable()) -> void: if _token.is_empty(): _call_if_valid(on_done) return var token: String = _token clear_token() _send_once("/token", HTTPClient.METHOD_DELETE, token, func(_body: PackedByteArray) -> void: _call_if_valid(on_done) , on_failed) ## Asks the server to remove expired tokens of all clients. func cleanup_tokens(on_done: Callable, on_failed: Callable = Callable()) -> void: send("/token/cleanup", on_done, on_failed, HTTPClient.METHOD_POST) ## Forgets the token without telling the server, so the next request gets a new one. func clear_token() -> void: _token = "" _token_server = "" # Generic requests ## Requests [param path] and calls [param on_done] with the parsed JSON. ## [param on_failed] is called with no arguments if the request fails or the body is ## not valid JSON. func get_json(path: String, on_done: Callable, on_failed: Callable = Callable()) -> void: _request(path, HTTPClient.METHOD_GET, _parse_json.bind(path, on_done, on_failed), on_failed) ## Requests [param path], which answers with a JSON object, and calls [param on_done] ## with it read into [param type], one of the generated API classes. func get_json_object( path: String, type: GDScript, on_done: Callable, on_failed: Callable = Callable(), ) -> void: get_json(path, func(data: Variant) -> void: if !(data is Dictionary): push_warning("Expected a JSON object from " + path) _call_if_valid(on_failed) return on_done.call(type.from_dict(data)) , on_failed) ## Requests [param path], which answers with a JSON array of objects, and calls ## [param on_done] with a typed Array of [param type], one of the generated API classes. ## The server sends [code]null[/code] for an empty list, which gives an empty Array. func get_json_list( path: String, type: GDScript, on_done: Callable, on_failed: Callable = Callable(), ) -> void: get_json(path, func(data: Variant) -> void: if data == null: data = [] if !(data is Array): push_warning("Expected a JSON array from " + path) _call_if_valid(on_failed) return var items: Array = Array([], TYPE_OBJECT, "RefCounted", type) for item: Variant in data: if item is Dictionary: items.append(type.from_dict(item)) on_done.call(items) , on_failed) ## Requests [param path] and calls [param on_done] with the raw response body, or ## [param on_failed] with no arguments if the request fails. func get_bytes(path: String, on_done: Callable, on_failed: Callable = Callable()) -> void: _request(path, HTTPClient.METHOD_GET, on_done, on_failed) ## Requests [param path] and calls [param on_done] with no arguments when it succeeds, ## or [param on_failed] when it fails. Either may be an empty Callable. func send( path: String, on_done: Callable, on_failed: Callable = Callable(), method: int = HTTPClient.METHOD_GET, ) -> void: _request(path, method, func(_body: PackedByteArray) -> void: _call_if_valid(on_done) , on_failed) func _fetch_soundtrack_statistics( list_name: String, limit: int, on_done: Callable, on_failed: Callable) -> void: get_json_list("/statistics/soundtracks/%s?limit=%d" % [list_name, limit], SoundtrackWithSongs, on_done, on_failed) func _parse_json( body: PackedByteArray, path: String, on_done: Callable, on_failed: Callable) -> void: var json: JSON = JSON.new() if json.parse(body.get_string_from_utf8()) != OK: push_warning("Invalid JSON from %s: %s" % [path, json.get_error_message()]) _call_if_valid(on_failed) return on_done.call(json.data) # Sends the request with the token, and once more with a new token if the server # answers 401 func _request( path: String, method: int, on_success: Callable, on_failed: Callable, may_retry: bool = true, ) -> void: _with_token(func(token: String) -> void: _send(path, method, token, func(response_code: int, body: PackedByteArray) -> void: if response_code == 401 && may_retry: GameLog.debug("Token refused by the server, getting a new one") if _token == token: clear_token() _request(path, method, on_success, on_failed, false) return _handle_response(path, response_code, body, on_success, on_failed) ) , on_failed) # Sends the request once, with [param token] if it is not empty func _send_once( path: String, method: int, token: String, on_success: Callable, on_failed: Callable, ) -> void: _send(path, method, token, func(response_code: int, body: PackedByteArray) -> void: _handle_response(path, response_code, body, on_success, on_failed) ) func _send( path: String, method: int, token: String, on_complete: Callable, body: String = "", ) -> void: var headers: PackedStringArray = [] if !token.is_empty(): headers.append("Authorization: Bearer " + token) transport.request(Settings.default_path + API_PATH + path, method, on_complete, headers, body) func _handle_response( path: String, response_code: int, body: PackedByteArray, on_success: Callable, on_failed: Callable, ) -> void: if response_code != 200: push_warning("Request to %s failed with response code %d%s" % [path, response_code, _error_message(body)]) _call_if_valid(on_failed) return on_success.call(body) # Calls [param on_token] with the token, asking the server for one first if needed. # Requests made while asking wait for the same token instead of asking again. func _with_token(on_token: Callable, on_failed: Callable) -> void: if _token_server != Settings.default_path: clear_token() if !_token.is_empty(): on_token.call(_token) return _token_waiters.append([on_token, on_failed]) if _token_waiters.size() == 1: _request_token() func _request_token() -> void: var token_request: TokenRequest = TokenRequest.new() token_request.password = Settings.api_password token_request.client_type = CLIENT_TYPE var server: String = Settings.default_path _send("/token", HTTPClient.METHOD_POST, "", func(response_code: int, body: PackedByteArray) -> void: var waiters: Array[Array] = _token_waiters.duplicate() _token_waiters.clear() var token: String = _read_token(response_code, body) if token.is_empty(): for waiter: Array in waiters: _call_if_valid(waiter[1]) return _token = token _token_server = server for waiter: Array in waiters: waiter[0].call(token) , JSON.stringify(token_request.to_dict())) func _read_token(response_code: int, body: PackedByteArray) -> String: if response_code != 200: push_warning("Could not get an API token, response code %d%s" % [response_code, _error_message(body)]) return "" var data: Variant = _parse_or_null(body) var token: String = TokenResponse.from_dict(data).token if data is Dictionary else "" if token.is_empty(): push_warning("The server answered without an API token") return token # The server's {"error": "..."} message, for logging static func _error_message(body: PackedByteArray) -> String: var data: Variant = _parse_or_null(body) if data is Dictionary && data.has("error"): return ": " + ApiError.from_dict(data).error return "" # Error pages are not always JSON, so failing to parse is not worth a warning here static func _parse_or_null(body: PackedByteArray) -> Variant: var json: JSON = JSON.new() return json.data if json.parse(body.get_string_from_utf8()) == OK else null static func _call_if_valid(callback: Callable) -> void: if callback.is_valid(): callback.call()