"Version X is available" in the top bar is now a button that opens an Update popup: - Update and restart gets the download link from the server (the redirect is not followed, as the Gitea link needs no token), downloads the new game next to the running one with a progress bar, checks it (complete, and an MZ or ELF file), swaps it in and restarts. The quiz is saved first and continues. On Windows the running .exe is renamed to .old, put back if the swap fails, and deleted at the next start. - Download in browser opens the .exe, the Linux package, or the release page on other platforms. - From the editor, an unwritable folder or another platform, only the browser download is offered, with the reason. The message now fits between the tabs and the buttons in every theme; in Nord and Christmas it ran into Sync. The macOS export preset is removed, and the unused download_client_* functions are replaced by fetch_client_download_url. Closes #13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
651 lines
25 KiB
GDScript
651 lines
25 KiB
GDScript
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; [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.
|
|
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"
|
|
## The type of the JSON bodies sent.
|
|
const JSON_TYPE: String = "application/json"
|
|
## The files of a MusicPlayer release that can be downloaded: the Windows game, the Linux
|
|
## game, and the Linux package with an installer.
|
|
const DOWNLOAD_ASSETS: Array[String] = ["windows", "linux", "linux-package"]
|
|
## 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
|
|
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(_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
|
|
## [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()).to_utf8_buffer())
|
|
|
|
|
|
## 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)
|
|
|
|
|
|
## Calls [param on_done] with the link to download the newest MusicPlayer file
|
|
## [param asset] from, one of [constant DOWNLOAD_ASSETS]. The server answers with a
|
|
## redirect to the release on Gitea, which is not followed: the link needs no token, so
|
|
## the browser can open it.
|
|
func fetch_client_download_url(
|
|
asset: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
|
_request("/download/" + asset, HTTPClient.METHOD_GET, on_done, on_failed, true,
|
|
PackedByteArray(), JSON_TYPE, false)
|
|
|
|
|
|
# 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 id: String = header_value(headers, QUEUE_HEADER)
|
|
if !id.is_empty() && Settings.queue_ids.get(server, "") != id:
|
|
Settings.set_queue_id(server, id)
|
|
|
|
|
|
# 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 body] of the type [param content_type] is sent with it. Without
|
|
# [param follow_redirects], a redirect is a success and [param on_success] gets where it
|
|
# points to.
|
|
func _request(
|
|
path: String,
|
|
method: int,
|
|
on_success: Callable,
|
|
on_failed: Callable,
|
|
may_retry: bool = true,
|
|
body: PackedByteArray = PackedByteArray(),
|
|
content_type: String = JSON_TYPE,
|
|
follow_redirects: bool = true,
|
|
) -> void:
|
|
_with_token(func(token: String) -> void:
|
|
_send(path, method, token, func(response_code: int, response: PackedByteArray,
|
|
headers: PackedStringArray) -> 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, body, content_type,
|
|
follow_redirects)
|
|
return
|
|
var location: String = header_value(headers, "Location")
|
|
if !follow_redirects && response_code >= 300 && response_code < 400 \
|
|
&& !location.is_empty():
|
|
on_success.call(location)
|
|
return
|
|
_handle_response(path, response_code, response, on_success, on_failed)
|
|
, body, content_type, follow_redirects)
|
|
, 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,
|
|
_headers: PackedStringArray) -> void:
|
|
_handle_response(path, response_code, body, on_success, on_failed)
|
|
)
|
|
|
|
|
|
func _send(
|
|
path: String,
|
|
method: int,
|
|
token: String,
|
|
on_complete: Callable,
|
|
body: PackedByteArray = PackedByteArray(),
|
|
content_type: String = JSON_TYPE,
|
|
follow_redirects: bool = true,
|
|
) -> 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])
|
|
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, response_headers)
|
|
, headers, body, follow_redirects)
|
|
|
|
|
|
func _handle_response(
|
|
path: String,
|
|
response_code: int,
|
|
body: PackedByteArray,
|
|
on_success: Callable,
|
|
on_failed: Callable,
|
|
) -> void:
|
|
# 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)
|
|
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, _headers: PackedStringArray) -> 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()).to_utf8_buffer())
|
|
|
|
|
|
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
|
|
|
|
|
|
## The value of the header [param header_name] in [param headers], given as
|
|
## [code]"Name: value"[/code] lines, or empty when it isn't there. Names are matched
|
|
## without regard to case.
|
|
static func header_value(headers: PackedStringArray, header_name: String) -> String:
|
|
for header: String in headers:
|
|
var colon: int = header.find(":")
|
|
if colon > 0 && header.left(colon).strip_edges().to_lower() == header_name.to_lower():
|
|
return header.substr(colon + 1).strip_edges()
|
|
return ""
|
|
|
|
|
|
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()
|