From 4a45e8516d5cc3142867faab289858594fd35dbc Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 7 Oct 2026 21:23:37 +0200 Subject: [PATCH] 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. --- data/settings.gd | 18 +++++ server/http_transport.gd | 15 ++-- server/music_server_client.gd | 38 ++++++++- tests/fakes/autoload_snapshot.gd | 3 +- tests/fakes/fake_http_transport.gd | 23 ++++-- tests/unit/test_http_transport.gd | 2 +- tests/unit/test_music_server_client.gd | 108 ++++++++++++++++++++++++- tests/unit/test_settings_files.gd | 21 +++++ 8 files changed, 212 insertions(+), 16 deletions(-) diff --git a/data/settings.gd b/data/settings.gd index 736e7bf..484d27b 100644 --- a/data/settings.gd +++ b/data/settings.gd @@ -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 diff --git a/server/http_transport.gd b/server/http_transport.gd index dc93a20..9908b15 100644 --- a/server/http_transport.gd +++ b/server/http_transport.gd @@ -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()) diff --git a/server/music_server_client.gd b/server/music_server_client.gd index c52c9e2..c7132bf 100644 --- a/server/music_server_client.gd +++ b/server/music_server_client.gd @@ -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( diff --git a/tests/fakes/autoload_snapshot.gd b/tests/fakes/autoload_snapshot.gd index 341992c..ed9b9ad 100644 --- a/tests/fakes/autoload_snapshot.gd +++ b/tests/fakes/autoload_snapshot.gd @@ -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 diff --git a/tests/fakes/fake_http_transport.gd b/tests/fakes/fake_http_transport.gd index 1ac8964..0178c8a 100644 --- a/tests/fakes/fake_http_transport.gd +++ b/tests/fakes/fake_http_transport.gd @@ -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: diff --git a/tests/unit/test_http_transport.gd b/tests/unit/test_http_transport.gd index 0a31f82..e09ce67 100644 --- a/tests/unit/test_http_transport.gd +++ b/tests/unit/test_http_transport.gd @@ -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() diff --git a/tests/unit/test_music_server_client.gd b/tests/unit/test_music_server_client.gd index db2c7d0..3e50432 100644 --- a/tests/unit/test_music_server_client.gd +++ b/tests/unit/test_music_server_client.gd @@ -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()) diff --git a/tests/unit/test_settings_files.gd b/tests/unit/test_settings_files.gd index d2e6007..fb8f647 100644 --- a/tests/unit/test_settings_files.gd +++ b/tests/unit/test_settings_files.gd @@ -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()