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. ## ## The server keeps a play queue for each client, identified by the [code]X-Queue-Id[/code] ## header. The client sends the ID it has for the server with every request and keeps the ## one in each response, which is new when the server started a new queue (after a reset ## or a sync). The IDs are kept in [code]Settings.queue_ids[/code], which saves them. ## ## 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" ## Header with the ID of the client's play queue, in requests and responses. const QUEUE_HEADER: String = "X-Queue-Id" ## 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, picked the way [param mode] (a ## [code]Settings.RandomMode[/code]) says: a random soundtrack and then one of its songs, ## the same but preferring soundtracks played less than average, or every song with the ## same chance. func fetch_random_song(mode: int, on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes(random_song_path(mode), on_done, on_failed) ## The endpoint for random songs picked the way [param mode] says. static func random_song_path(mode: int) -> String: match mode: Settings.RandomMode.LOW_PLAYED: return "/music/rand/low" Settings.RandomMode.CLASSIC: return "/music/rand/classic" return "/music/rand" ## 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) ## Downloads the cover picture of the soundtrack with [param uuid] as bytes (JPEG or ## PNG). The server answers 404 while it has no cover for it. func fetch_soundtrack_cover( uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void: get_bytes("/soundtracks/%s/cover" % uuid.uri_encode(), on_done, on_failed) ## Replaces the alternative names of the soundtrack with [param uuid] with ## [param names]. The server trims them, drops duplicates and the soundtrack's own name, ## and calls [param on_done] with the updated [Soundtrack]. func set_alternative_names(uuid: String, names: Array[String], on_done: Callable, on_failed: Callable = Callable()) -> void: var request: AlternativeNames = AlternativeNames.new() request.names = names var path: String = "/soundtracks/%s/alternative-names" % uuid.uri_encode() var saved: Callable = func saved(data: Variant) -> void: if !(data is Dictionary): push_warning("Expected a JSON object from " + path) _call_if_valid(on_failed) return on_done.call(Soundtrack.from_dict(data)) _request(path, HTTPClient.METHOD_PUT, _parse_json.bind(path, saved, on_failed), on_failed, true, JSON.stringify(request.to_dict())) ## 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 every soundtrack with its songs and play counts, sorted by name, as an ## [code]Array[SoundtrackWithSongs][/code]. func fetch_soundtrack_statistics(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_list("/statistics/soundtracks", SoundtrackWithSongs, 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) ## Fetches every character image with when it was last changed, as an ## [code]Array[CharacterDetails][/code]. Servers older than this endpoint answer 404. func fetch_character_details(on_done: Callable, on_failed: Callable = Callable()) -> void: get_json_list("/character/details", CharacterDetails, 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 = "" # Queue IDs ## The ID of the play queue on the selected server, or "" before the server has sent one. func queue_id() -> String: return Settings.queue_ids.get(Settings.default_path, "") # Keeps the queue ID in [param headers] from [param server] when it is new func _remember_queue_id(server: String, headers: PackedStringArray) -> void: var prefix: String = QUEUE_HEADER.to_lower() + ":" for header: String in headers: if !header.to_lower().begins_with(prefix): continue var id: String = header.substr(prefix.length()).strip_edges() if !id.is_empty() && Settings.queue_ids.get(server, "") != id: Settings.set_queue_id(server, id) return # 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. A [param json_body] is sent as the request's body. func _request( path: String, method: int, on_success: Callable, on_failed: Callable, may_retry: bool = true, json_body: String = "", ) -> 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, json_body) return _handle_response(path, response_code, body, on_success, on_failed) , json_body) , 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 server: String = Settings.default_path var headers: PackedStringArray = [] if !token.is_empty(): headers.append("Authorization: Bearer " + token) var queue: String = Settings.queue_ids.get(server, "") if !queue.is_empty(): headers.append("%s: %s" % [QUEUE_HEADER, queue]) transport.request(server + API_PATH + path, method, func(response_code: int, response_body: PackedByteArray, response_headers: PackedStringArray) -> void: _remember_queue_id(server, response_headers) on_complete.call(response_code, response_body) , 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()