Change soundtrack covers from the app and show the cover with the answer
Build / test (push) Successful in 1m50s
Build / build (push) Successful in 1m14s

Cover menu in the soundtrack popup ("Change cover"):
- Choose picture uploads a JPEG, PNG or WebP picture (up to 10 MB).
- Pick by ID uses a game's cover on SteamGridDB or an anime's on AniList.
- Remove cover deletes it, so the server searches for a new one.
- The server's reason is shown when it refuses a change.
After a change the new cover time is fetched, and the popup, the
statistics card and the quiz show the new cover.

Quiz: once the answer is shown, the soundtrack's cover shows to the left of
the titles. Cover and titles are centered together, left and right and up
and down, and each title keeps to two lines. Cover times come from the
soundtrack list, and songs keep their soundtrack's UUID, also in saves.

Requests can now send any body with its content type, and every 2xx answer
counts as success. Cover frames are 2:3 like the server's covers. Small
copies of real covers are used by the tests and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-14 23:32:09 +02:00
1 parent ef6ac8f1fd
commit 06421bb63e
33 files changed
+3966 -920

No files matched your search

+4 -7
View File
@@ -18,14 +18,14 @@ func _init(owner: Node) -> void:
## Requests [param url] and calls [param on_complete] with
## [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.
## reported with response code 0 and no headers. [param headers], which name the
## [param body]'s type, are sent as-is.
func request(
url: String,
method: int,
on_complete: Callable,
headers: PackedStringArray = PackedStringArray(),
body: String = "",
body: PackedByteArray = PackedByteArray(),
) -> void:
var http_request: HTTPRequest = HTTPRequest.new()
http_request.timeout = timeout_seconds
@@ -43,10 +43,7 @@ func request(
return
on_complete.call(response_code, response_body, response_headers)
)
var all_headers: PackedStringArray = headers.duplicate()
if method == HTTPClient.METHOD_PUT || !body.is_empty():
all_headers.append("Content-Type: application/json")
var error: int = http_request.request(url, all_headers, method, body)
var error: int = http_request.request_raw(url, headers, method, body)
if error != OK:
http_request.queue_free()
push_error("Could not send request to %s (error %d)" % [url, error])
+68 -12
View File
@@ -19,7 +19,7 @@ extends Node
## 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.
## including when no token could be had; [member last_error] then has the server's reason.
## Path of the API on the server, added between [code]Settings.default_path[/code] and
## each endpoint.
@@ -28,9 +28,17 @@ const API_PATH: String = "/api/v1"
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"
## The type of the JSON bodies sent.
const JSON_TYPE: String = "application/json"
## Where the cover of a soundtrack can be picked from, by the game's or anime's ID there.
const COVER_SOURCES: Array[String] = ["steamgriddb", "anilist"]
## Sends the actual HTTP requests. Swap it out in tests.
var transport: HttpTransport
## The server's message for the request that failed last, e.g. [code]"covers are
## disabled"[/code], or empty when it gave none. Read it in an [code]on_failed[/code]
## callback, before another request can fail.
var last_error: String = ""
var _token: String = ""
# The server the token is for; switching server in the settings needs a new token
@@ -135,7 +143,37 @@ func fetch_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> v
## 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)
get_bytes(_cover_path(uuid), on_done, on_failed)
## Stores [param image], a JPEG, PNG or WebP picture of [param content_type] such as
## [code]"image/png"[/code], as the cover of the soundtrack with [param uuid]. The
## server's automatic search never replaces it.
func upload_soundtrack_cover(uuid: String, image: PackedByteArray, content_type: String,
on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(_cover_path(uuid), HTTPClient.METHOD_PUT, _call_ignoring_body.bind(on_done),
on_failed, true, image, content_type)
## Makes the cover of the game or anime with [param id] on [param source], one of
## [constant COVER_SOURCES], the cover of the soundtrack with [param uuid]. The server
## downloads it before answering.
func set_soundtrack_cover_source(uuid: String, source: String, id: String,
on_done: Callable, on_failed: Callable = Callable()) -> void:
var request: CoverSource = CoverSource.new()
request.source = source
request.id = id
_request(_cover_path(uuid) + "/source", HTTPClient.METHOD_PUT,
_call_ignoring_body.bind(on_done), on_failed, true,
JSON.stringify(request.to_dict()).to_utf8_buffer())
## Deletes the cover of the soundtrack with [param uuid], also one set by hand, so the
## server's automatic search looks for a new one.
func delete_soundtrack_cover(
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(_cover_path(uuid), HTTPClient.METHOD_DELETE, _call_ignoring_body.bind(on_done),
on_failed)
## Replaces the alternative names of the soundtrack with [param uuid] with
@@ -153,7 +191,7 @@ func set_alternative_names(uuid: String, names: Array[String], on_done: Callable
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()))
true, JSON.stringify(request.to_dict()).to_utf8_buffer())
## Fetches a soundtrack and its songs as a [SoundtrackWithSongList].
@@ -444,25 +482,26 @@ func _parse_json(
# 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.
# answers 401. A [param body] of the type [param content_type] is sent with it.
func _request(
path: String,
method: int,
on_success: Callable,
on_failed: Callable,
may_retry: bool = true,
json_body: String = "",
body: PackedByteArray = PackedByteArray(),
content_type: String = JSON_TYPE,
) -> void:
_with_token(func(token: String) -> void:
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
_send(path, method, token, func(response_code: int, response: 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)
_request(path, method, on_success, on_failed, false, body, content_type)
return
_handle_response(path, response_code, body, on_success, on_failed)
, json_body)
_handle_response(path, response_code, response, on_success, on_failed)
, body, content_type)
, on_failed)
@@ -476,12 +515,19 @@ func _send_once(
func _send(
path: String, method: int, token: String, on_complete: Callable, body: String = "",
path: String,
method: int,
token: String,
on_complete: Callable,
body: PackedByteArray = PackedByteArray(),
content_type: String = JSON_TYPE,
) -> void:
var server: String = Settings.default_path
var headers: PackedStringArray = []
if !token.is_empty():
headers.append("Authorization: Bearer " + token)
if !body.is_empty() || method == HTTPClient.METHOD_PUT:
headers.append("Content-Type: " + content_type)
var queue: String = Settings.queue_ids.get(server, "")
if !queue.is_empty():
headers.append("%s: %s" % [QUEUE_HEADER, queue])
@@ -500,7 +546,9 @@ func _handle_response(
on_success: Callable,
on_failed: Callable,
) -> void:
if response_code != 200:
# Requests that change something may answer 204 No Content
if response_code < 200 || response_code >= 300:
last_error = _error_message(body).trim_prefix(": ")
push_warning("Request to %s failed with response code %d%s"
% [path, response_code, _error_message(body)])
_call_if_valid(on_failed)
@@ -539,7 +587,7 @@ func _request_token() -> void:
_token_server = server
for waiter: Array in waiters:
waiter[0].call(token)
, JSON.stringify(token_request.to_dict()))
, JSON.stringify(token_request.to_dict()).to_utf8_buffer())
func _read_token(response_code: int, body: PackedByteArray) -> String:
@@ -568,6 +616,14 @@ static func _parse_or_null(body: PackedByteArray) -> Variant:
return json.data if json.parse(body.get_string_from_utf8()) == OK else null
static func _cover_path(uuid: String) -> String:
return "/soundtracks/%s/cover" % uuid.uri_encode()
static func _call_ignoring_body(_body: PackedByteArray, callback: Callable) -> void:
_call_if_valid(callback)
static func _call_if_valid(callback: Callable) -> void:
if callback.is_valid():
callback.call()