"Add soundtrack…" in the statistics toolbar, or a folder dropped anywhere on the window, opens a popup that lists the folder's songs and uploads them to the server, which adds the soundtrack at once (Sansan/MusicServer#33). - The name comes from the folder and can be changed; characters the server refuses are replaced. - Only MP3s are uploaded, as the server takes no other; other song files are listed greyed out as left out. Hidden files are skipped. - Disc folders are flattened with the disc's number first ("1-01 Title.mp3"), as the server wants one folder. With one disc folder, names stay as they are. - Songs are sent one at a time with a progress bar, reading one file at a time. A failed song is sent again twice, then the upload is cancelled and the server's reason shown. Closing the popup cancels it too. - Not offered while the server syncs, offline, or without MP3s. - Afterwards search, the inspiration list and the statistics reload. The API types come from the server's spec (Upload, UploadFile, UploadRequest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
7.7 KiB
GDScript
209 lines
7.7 KiB
GDScript
class_name FakeHttpTransport
|
|
extends HttpTransport
|
|
## Stands in for the MusicServer in tests.
|
|
##
|
|
## Register canned responses per path with [method respond_json] or
|
|
## [method respond_bytes], then install it with [method install]. Requests to paths
|
|
## without a response get a 404. Responses are delivered immediately.
|
|
##
|
|
## Paths are relative to the API, e.g. [code]/music/list[/code] for
|
|
## [code]<Settings.default_path>/api/v1/music/list[/code].
|
|
##
|
|
## 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"]
|
|
|
|
## Paths requested so far, relative to the API.
|
|
var requested_paths: Array[String] = []
|
|
## HTTP method of each request, in the same order as [member requested_paths].
|
|
var requested_methods: Array[int] = []
|
|
## Headers of each request, in the same order as [member requested_paths].
|
|
var requested_headers: Array[PackedStringArray] = []
|
|
## The body of each request as text, e.g. its JSON, empty for none, in the same order as
|
|
## [member requested_paths].
|
|
var requested_bodies: Array[String] = []
|
|
## The body of each request as bytes, e.g. an uploaded picture, in the same order as
|
|
## [member requested_paths].
|
|
var requested_raw_bodies: Array[PackedByteArray] = []
|
|
## Whether each request followed redirects, in the same order as
|
|
## [member requested_paths].
|
|
var requested_follow_redirects: Array[bool] = []
|
|
## How many tokens have been asked for.
|
|
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 _failures_left: Dictionary = {}
|
|
var _held_paths: Dictionary = {}
|
|
var _previous_transport: HttpTransport
|
|
var _previous_queue_ids: Dictionary[String, String]
|
|
var _valid_tokens: Array[String] = []
|
|
|
|
|
|
func _init() -> void:
|
|
super(null)
|
|
|
|
|
|
## Replaces the MusicServerClient transport with this fake.
|
|
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
|
|
|
|
|
|
## Restores the transport that was active before [method install].
|
|
func uninstall() -> void:
|
|
MusicServerClient.transport = _previous_transport
|
|
MusicServerClient.clear_token()
|
|
Settings.queue_ids = _previous_queue_ids
|
|
|
|
|
|
## Answers requests to [param path] with a redirect to [param location], like the
|
|
## server's download routes. Requests that follow redirects get an empty 200 instead, as
|
|
## if they had followed it.
|
|
func respond_redirect(path: String, location: String) -> void:
|
|
_responses[path] = {"code": 302, "body": PackedByteArray(), "location": location}
|
|
_successes_left.erase(path)
|
|
|
|
|
|
## Answers requests to [param path] with [param data] encoded as JSON.
|
|
func respond_json(path: String, data: Variant, response_code: int = 200) -> void:
|
|
respond_bytes(path, JSON.stringify(data).to_utf8_buffer(), response_code)
|
|
|
|
|
|
## Answers requests to [param path] with [param body] as-is.
|
|
func respond_bytes(path: String, body: PackedByteArray, response_code: int = 200) -> void:
|
|
_responses[path] = {"code": response_code, "body": body}
|
|
_successes_left.erase(path)
|
|
|
|
|
|
## Answers the next [param failures] requests to [param path] with a 503, then as usual.
|
|
func fail_first(path: String, failures: int) -> void:
|
|
_failures_left[path] = failures
|
|
|
|
|
|
## Lets [param successes] more requests to [param path] succeed, then answers 503 until
|
|
## a new response is registered for the path.
|
|
func fail_after(path: String, successes: int) -> void:
|
|
_successes_left[path] = successes
|
|
|
|
|
|
## Holds back responses to [param path] until [method release] is called, like a slow
|
|
## server.
|
|
func hold(path: String) -> void:
|
|
_held_paths[path] = []
|
|
|
|
|
|
## Delivers the responses held back for [param path] and stops holding it.
|
|
func release(path: String) -> void:
|
|
var waiting: Array = _held_paths.get(path, [])
|
|
_held_paths.erase(path)
|
|
for pending: Callable in waiting:
|
|
pending.call()
|
|
|
|
|
|
## Returns how many times [param path] has been requested.
|
|
func count(path: String) -> int:
|
|
return requested_paths.count(path)
|
|
|
|
|
|
## Makes every token handed out so far invalid, like after 24 hours or a server restart.
|
|
func expire_tokens() -> void:
|
|
_valid_tokens.clear()
|
|
|
|
|
|
func request(
|
|
url: String,
|
|
method: int,
|
|
on_complete: Callable,
|
|
headers: PackedStringArray = PackedStringArray(),
|
|
body: PackedByteArray = PackedByteArray(),
|
|
follow_redirects: bool = true,
|
|
) -> void:
|
|
var path: String = url.trim_prefix(Settings.default_path + MusicServerClient.API_PATH)
|
|
if path == "/token" && method == HTTPClient.METHOD_POST:
|
|
token_requests += 1
|
|
if _held_paths.has(path):
|
|
_held_paths[path].append(
|
|
_answer_token_request.bind(body.get_string_from_utf8(), on_complete))
|
|
return
|
|
_answer_token_request(body.get_string_from_utf8(), on_complete)
|
|
return
|
|
requested_paths.append(path)
|
|
requested_methods.append(method)
|
|
requested_headers.append(headers)
|
|
requested_bodies.append(body.get_string_from_utf8())
|
|
requested_raw_bodies.append(body)
|
|
requested_follow_redirects.append(follow_redirects)
|
|
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
|
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, follow_redirects))
|
|
return
|
|
_answer(path, on_complete, follow_redirects)
|
|
|
|
|
|
func _answer(path: String, on_complete: Callable, follow_redirects: bool = true) -> void:
|
|
var headers: PackedStringArray = []
|
|
if !queue_id.is_empty() && path != "/token":
|
|
headers.append("X-Queue-Id: " + queue_id)
|
|
if _failures_left.get(path, 0) > 0:
|
|
_failures_left[path] -= 1
|
|
on_complete.call(503, PackedByteArray(), headers)
|
|
return
|
|
if _successes_left.has(path):
|
|
if _successes_left[path] == 0:
|
|
on_complete.call(503, PackedByteArray(), headers)
|
|
return
|
|
_successes_left[path] -= 1
|
|
if _responses.has(path) && _responses[path].has("location"):
|
|
if follow_redirects:
|
|
on_complete.call(200, PackedByteArray(), headers)
|
|
else:
|
|
headers.append("Location: " + _responses[path].location)
|
|
on_complete.call(302, PackedByteArray(), headers)
|
|
elif _responses.has(path):
|
|
on_complete.call(_responses[path].code, _responses[path].body, headers)
|
|
else:
|
|
on_complete.call(404, PackedByteArray(), headers)
|
|
|
|
|
|
func _answer_token_request(body: String, on_complete: Callable) -> void:
|
|
var data: Variant = JSON.parse_string(body)
|
|
last_token_request = data if data is Dictionary else {}
|
|
if _responses.has("/token"):
|
|
# A token from a registered response is valid too
|
|
var registered: Dictionary = _responses["/token"]
|
|
var response: Variant = JSON.parse_string(registered.body.get_string_from_utf8())
|
|
if registered.code == 200 && response is Dictionary:
|
|
_valid_tokens.append(str(response.get("token", "")))
|
|
_answer("/token", on_complete)
|
|
return
|
|
var token: String = "fake-token-%d" % token_requests
|
|
_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(), PackedStringArray())
|
|
|
|
|
|
func _has_valid_token(headers: PackedStringArray) -> bool:
|
|
for header: String in headers:
|
|
if header.begins_with("Authorization: Bearer "):
|
|
return _valid_tokens.has(header.trim_prefix("Authorization: Bearer "))
|
|
return false
|