Send the play queue ID to the server
The server keeps a play queue per client, identified by the X-Queue-Id header. The client sends the ID it has for the selected server with every request and keeps the one in each response, which is new after a reset or a sync. The IDs are saved per server in the [queues] section of settings.cfg, so the game goes on with the same queue after a restart. HttpTransport now passes the response headers on to the client.
This commit is contained in:
1 parent
d21b3e2ce0
commit
4a45e8516d
8 files changed
+212
-16
No files matched your search
@@ -79,6 +79,10 @@ var player_array: Array[PlayerObject]
|
||||
var edit_players: bool = false
|
||||
var currently_syncing: bool = false
|
||||
var character_select_open: bool = false
|
||||
## The ID of the game's play queue on each server, by server URL, set by
|
||||
## [code]MusicServerClient[/code]. Saved in the [code][queues][/code] section of
|
||||
## [method saved_settings_path], so the game goes on with the same queue after a restart.
|
||||
var queue_ids: Dictionary[String, String] = {}
|
||||
|
||||
## The game version, set in Project Settings > Application > Config > Version.
|
||||
## Releases are made from it, see RELEASING.md.
|
||||
@@ -153,6 +157,12 @@ func load_saved_settings() -> void:
|
||||
for section: String in SAVED_SETTINGS:
|
||||
for setting: StringName in SAVED_SETTINGS[section]:
|
||||
set(setting, _saved_value(file, section, setting, get(setting)))
|
||||
if file.has_section("queues"):
|
||||
queue_ids.clear()
|
||||
for server: String in file.get_section_keys("queues"):
|
||||
var id: Variant = file.get_value("queues", server)
|
||||
if id is String:
|
||||
queue_ids[server] = id
|
||||
|
||||
|
||||
## Saves the settings in [constant SAVED_SETTINGS], so they are the same the next time
|
||||
@@ -164,11 +174,19 @@ func save_settings() -> void:
|
||||
for section: String in SAVED_SETTINGS:
|
||||
for setting: StringName in SAVED_SETTINGS[section]:
|
||||
file.set_value(section, setting, get(setting))
|
||||
for server: String in queue_ids:
|
||||
file.set_value("queues", server, queue_ids[server])
|
||||
var error: Error = file.save(path)
|
||||
if error != OK:
|
||||
GameLog.warning("Could not save the settings to %s: %s" % [path, error_string(error)])
|
||||
|
||||
|
||||
## Sets the play queue ID for [param server] and saves it.
|
||||
func set_queue_id(server: String, id: String) -> void:
|
||||
queue_ids[server] = id
|
||||
save_settings()
|
||||
|
||||
|
||||
## Turns fullscreen on or off and saves it.
|
||||
func set_fullscreen(on: bool) -> void:
|
||||
fullscreen = on
|
||||
|
||||
@@ -16,8 +16,9 @@ func _init(owner: Node) -> void:
|
||||
|
||||
|
||||
## Requests [param url] and calls [param on_complete] with
|
||||
## [code](response_code: int, body: PackedByteArray)[/code]. Connection errors and
|
||||
## timeouts are reported with response code 0. [param headers] are sent as-is; a
|
||||
## [code](response_code: int, body: PackedByteArray, headers: PackedStringArray)[/code],
|
||||
## the headers as [code]"Name: value"[/code] lines. Connection errors and timeouts are
|
||||
## reported with response code 0 and no headers. [param headers] are sent as-is; a
|
||||
## [param body] is sent as JSON.
|
||||
func request(
|
||||
url: String,
|
||||
@@ -33,12 +34,14 @@ func request(
|
||||
func(
|
||||
result: int,
|
||||
response_code: int,
|
||||
_headers: PackedStringArray,
|
||||
response_headers: PackedStringArray,
|
||||
response_body: PackedByteArray,
|
||||
) -> void:
|
||||
http_request.queue_free()
|
||||
on_complete.call(response_code if result == HTTPRequest.RESULT_SUCCESS else 0,
|
||||
response_body)
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
on_complete.call(0, response_body, PackedStringArray())
|
||||
return
|
||||
on_complete.call(response_code, response_body, response_headers)
|
||||
)
|
||||
var all_headers: PackedStringArray = headers.duplicate()
|
||||
if method == HTTPClient.METHOD_PUT || !body.is_empty():
|
||||
@@ -47,4 +50,4 @@ func request(
|
||||
if error != OK:
|
||||
http_request.queue_free()
|
||||
push_error("Could not send request to %s (error %d)" % [url, error])
|
||||
on_complete.call(0, PackedByteArray())
|
||||
on_complete.call(0, PackedByteArray(), PackedStringArray())
|
||||
@@ -6,6 +6,11 @@ extends Node
|
||||
## 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
|
||||
@@ -21,6 +26,8 @@ extends Node
|
||||
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
|
||||
@@ -301,6 +308,25 @@ func clear_token() -> void:
|
||||
_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.
|
||||
@@ -415,11 +441,19 @@ func _send_once(
|
||||
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)
|
||||
transport.request(Settings.default_path + API_PATH + path, method, on_complete, headers,
|
||||
body)
|
||||
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(
|
||||
|
||||
@@ -14,7 +14,8 @@ static func take(autoloads: Array[Node]) -> AutoloadSnapshot:
|
||||
for property: Dictionary in autoload.get_property_list():
|
||||
if property.usage & PROPERTY_USAGE_SCRIPT_VARIABLE:
|
||||
var value: Variant = autoload.get(property.name)
|
||||
values[property.name] = value.duplicate() if value is Array else value
|
||||
values[property.name] = (value.duplicate()
|
||||
if value is Array || value is Dictionary else value)
|
||||
snapshot._values[autoload] = values
|
||||
return snapshot
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ extends HttpTransport
|
||||
## Like the real server it hands out tokens on [code]POST /token[/code] and answers 401
|
||||
## to requests without a valid one. Token requests are counted in
|
||||
## [member token_requests], not in [member requested_paths].
|
||||
##
|
||||
## Set [member queue_id] to answer with an [code]X-Queue-Id[/code] header, like the
|
||||
## server's music endpoints.
|
||||
|
||||
## Paths that need no token, like on the real server.
|
||||
const OPEN_PATHS: Array[String] = ["/token", "/health"]
|
||||
@@ -26,11 +29,14 @@ var requested_headers: Array[PackedStringArray] = []
|
||||
var token_requests: int = 0
|
||||
## The JSON body of the last token request.
|
||||
var last_token_request: Dictionary = {}
|
||||
## Sent in an X-Queue-Id header with every answer that is not about a token, unless empty.
|
||||
var queue_id: String = ""
|
||||
|
||||
var _responses: Dictionary = {}
|
||||
var _successes_left: Dictionary = {}
|
||||
var _held_paths: Dictionary = {}
|
||||
var _previous_transport: HttpTransport
|
||||
var _previous_queue_ids: Dictionary[String, String]
|
||||
var _valid_tokens: Array[String] = []
|
||||
|
||||
|
||||
@@ -43,6 +49,8 @@ func install() -> FakeHttpTransport:
|
||||
_previous_transport = MusicServerClient.transport
|
||||
MusicServerClient.transport = self
|
||||
MusicServerClient.clear_token()
|
||||
_previous_queue_ids = Settings.queue_ids.duplicate()
|
||||
Settings.queue_ids.clear()
|
||||
return self
|
||||
|
||||
|
||||
@@ -50,6 +58,7 @@ func install() -> FakeHttpTransport:
|
||||
func uninstall() -> void:
|
||||
MusicServerClient.transport = _previous_transport
|
||||
MusicServerClient.clear_token()
|
||||
Settings.queue_ids = _previous_queue_ids
|
||||
|
||||
|
||||
## Answers requests to [param path] with [param data] encoded as JSON.
|
||||
@@ -112,7 +121,8 @@ func request(
|
||||
requested_methods.append(method)
|
||||
requested_headers.append(headers)
|
||||
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
||||
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer())
|
||||
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
|
||||
PackedStringArray())
|
||||
return
|
||||
if _held_paths.has(path):
|
||||
_held_paths[path].append(_answer.bind(path, on_complete))
|
||||
@@ -121,15 +131,18 @@ func request(
|
||||
|
||||
|
||||
func _answer(path: String, on_complete: Callable) -> void:
|
||||
var headers: PackedStringArray = []
|
||||
if !queue_id.is_empty() && path != "/token":
|
||||
headers.append("X-Queue-Id: " + queue_id)
|
||||
if _successes_left.has(path):
|
||||
if _successes_left[path] == 0:
|
||||
on_complete.call(503, PackedByteArray())
|
||||
on_complete.call(503, PackedByteArray(), headers)
|
||||
return
|
||||
_successes_left[path] -= 1
|
||||
if _responses.has(path):
|
||||
on_complete.call(_responses[path].code, _responses[path].body)
|
||||
on_complete.call(_responses[path].code, _responses[path].body, headers)
|
||||
else:
|
||||
on_complete.call(404, PackedByteArray())
|
||||
on_complete.call(404, PackedByteArray(), headers)
|
||||
|
||||
|
||||
func _answer_token_request(body: String, on_complete: Callable) -> void:
|
||||
@@ -147,7 +160,7 @@ func _answer_token_request(body: String, on_complete: Callable) -> void:
|
||||
_valid_tokens.append(token)
|
||||
on_complete.call(200, JSON.stringify({
|
||||
"token": token, "expires_at": "2026-10-07T12:00:00Z", "client_type": "godot",
|
||||
}).to_utf8_buffer())
|
||||
}).to_utf8_buffer(), PackedStringArray())
|
||||
|
||||
|
||||
func _has_valid_token(headers: PackedStringArray) -> bool:
|
||||
|
||||
@@ -32,7 +32,7 @@ func test_request_to_silent_server_times_out_as_failed() -> void:
|
||||
|
||||
transport.request("http://127.0.0.1:%d/music/rand" % _server.get_local_port(),
|
||||
HTTPClient.METHOD_GET,
|
||||
func(response_code: int, _body: PackedByteArray) -> void:
|
||||
func(response_code: int, _body: PackedByteArray, _headers: PackedStringArray) -> void:
|
||||
response_codes.append(response_code))
|
||||
for i: int in 40:
|
||||
_accept_connections()
|
||||
|
||||
@@ -542,6 +542,111 @@ func _call_every_statistics_endpoint(on_done: Callable, on_failed: Callable) ->
|
||||
MusicServerClient.fetch_least_played_songs(3, on_done, on_failed)
|
||||
|
||||
|
||||
# Queue IDs
|
||||
|
||||
func test_no_queue_id_is_sent_before_the_server_sends_one() -> void:
|
||||
_server.respond_json("/music/list", [])
|
||||
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
|
||||
assert_array(_server.requested_headers[0]) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-1"])
|
||||
|
||||
|
||||
func test_queue_id_from_a_response_is_sent_with_the_next_requests() -> void:
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.respond_bytes("/music/addQue", PackedByteArray())
|
||||
_server.queue_id = "queue-1"
|
||||
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
MusicServerClient.add_to_queue(func() -> void: pass)
|
||||
|
||||
assert_str(MusicServerClient.queue_id()).is_equal("queue-1")
|
||||
assert_array(_server.requested_headers[1]) \
|
||||
.contains_exactly(["Authorization: Bearer fake-token-1", "X-Queue-Id: queue-1"])
|
||||
DirAccess.remove_absolute(Settings.saved_settings_path())
|
||||
|
||||
|
||||
func test_new_queue_id_replaces_the_old_one() -> void:
|
||||
_server.respond_bytes("/music/reset", PackedByteArray())
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.queue_id = "queue-1"
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
|
||||
_server.queue_id = "queue-2"
|
||||
MusicServerClient.reset_playlist(func() -> void: pass)
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
|
||||
assert_array(_server.requested_headers[1]).contains(["X-Queue-Id: queue-1"])
|
||||
assert_array(_server.requested_headers[2]).contains(["X-Queue-Id: queue-2"])
|
||||
DirAccess.remove_absolute(Settings.saved_settings_path())
|
||||
|
||||
|
||||
func test_queue_id_is_kept_per_server() -> void:
|
||||
var previous_path: String = Settings.default_path
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.queue_id = "queue-1"
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
|
||||
_server.queue_id = ""
|
||||
Settings.default_path = "http://another-server:8080"
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
var other_server_id: String = MusicServerClient.queue_id()
|
||||
Settings.default_path = previous_path
|
||||
|
||||
assert_str(other_server_id).is_empty()
|
||||
assert_array(_server.requested_headers[1]) \
|
||||
.not_contains(["X-Queue-Id: queue-1"])
|
||||
assert_str(MusicServerClient.queue_id()).is_equal("queue-1")
|
||||
DirAccess.remove_absolute(Settings.saved_settings_path())
|
||||
|
||||
|
||||
func test_queue_id_is_saved_for_the_next_start() -> void:
|
||||
_server.respond_json("/music/list", [])
|
||||
_server.queue_id = "queue-1"
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
|
||||
Settings.queue_ids.clear()
|
||||
assert_str(MusicServerClient.queue_id()).is_empty()
|
||||
Settings.load_saved_settings()
|
||||
|
||||
assert_str(MusicServerClient.queue_id()).is_equal("queue-1")
|
||||
DirAccess.remove_absolute(Settings.saved_settings_path())
|
||||
|
||||
|
||||
func test_queue_id_header_name_is_case_insensitive() -> void:
|
||||
var transport: _HeaderTransport = _HeaderTransport.new(["x-queue-id: lower-case"])
|
||||
var previous_transport: HttpTransport = MusicServerClient.transport
|
||||
MusicServerClient.transport = transport
|
||||
|
||||
MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass)
|
||||
MusicServerClient.transport = previous_transport
|
||||
|
||||
assert_str(MusicServerClient.queue_id()).is_equal("lower-case")
|
||||
DirAccess.remove_absolute(Settings.saved_settings_path())
|
||||
|
||||
|
||||
# Answers every request with 200, an empty JSON list and the given headers
|
||||
class _HeaderTransport:
|
||||
extends HttpTransport
|
||||
|
||||
var _headers: PackedStringArray
|
||||
|
||||
func _init(headers: PackedStringArray) -> void:
|
||||
super(null)
|
||||
_headers = headers
|
||||
|
||||
func request(
|
||||
_url: String,
|
||||
_method: int,
|
||||
on_complete: Callable,
|
||||
_request_headers: PackedStringArray = PackedStringArray(),
|
||||
_body: String = "",
|
||||
) -> void:
|
||||
on_complete.call(200, JSON.stringify({"token": "t"} if _url.ends_with("/token") else [])
|
||||
.to_utf8_buffer(), _headers)
|
||||
|
||||
|
||||
class _RecordingTransport:
|
||||
extends HttpTransport
|
||||
|
||||
@@ -560,4 +665,5 @@ class _RecordingTransport:
|
||||
) -> void:
|
||||
_urls.append(url)
|
||||
if url.ends_with("/token"):
|
||||
on_complete.call(200, JSON.stringify({"token": "t"}).to_utf8_buffer())
|
||||
on_complete.call(200, JSON.stringify({"token": "t"}).to_utf8_buffer(),
|
||||
PackedStringArray())
|
||||
@@ -95,6 +95,27 @@ func test_statistics_settings_are_not_saved() -> void:
|
||||
assert_bool(file.has_section_key("quiz", "use_low_played_mode")).is_false()
|
||||
|
||||
|
||||
func test_queue_ids_are_saved_and_load_back() -> void:
|
||||
Settings.set_queue_id("https://music.example.com", "queue-1")
|
||||
Settings.winning_score = 12
|
||||
Settings.save_settings()
|
||||
Settings.queue_ids.clear()
|
||||
|
||||
Settings.load_saved_settings()
|
||||
|
||||
assert_str(_saved_file().get_value("queues", "https://music.example.com")).is_equal("queue-1")
|
||||
assert_dict(Settings.queue_ids).is_equal({"https://music.example.com": "queue-1"})
|
||||
|
||||
|
||||
func test_file_without_queues_keeps_the_current_queue_ids() -> void:
|
||||
Settings.save_settings()
|
||||
Settings.queue_ids["https://music.example.com"] = "queue-1"
|
||||
|
||||
Settings.load_saved_settings()
|
||||
|
||||
assert_dict(Settings.queue_ids).is_equal({"https://music.example.com": "queue-1"})
|
||||
|
||||
|
||||
func test_fullscreen_is_saved_when_it_changes() -> void:
|
||||
Settings.set_fullscreen(true)
|
||||
assert_bool(_saved_file().get_value("window", "fullscreen")).is_true()
|
||||
|
||||
Reference in new issue
Block a user