Use /api/v1 with token authentication and typed responses

Closes #38, #39 and #40; adds the client side of #41.

- Every request goes to /api/v1. MusicServerClient gets a token with
  Settings.api_password the first time one is needed, reuses it, and on a
  401 gets a new one and sends the request once more. Switching server gets
  a new token too. Callers never see the token.
- Sync is started with POST, as MusicServer 5.0 requires.
- The client has every endpoint in the server's OpenAPI spec, including
  statistics, soundtracks and songs by UUID, version, downloads and tokens.
- tools/generate_api_types.gd generates a typed class per schema in
  server/openapi.json into server/generated/. Song info, statistics and
  versions are passed to callers as these classes. A list the server sends
  as null is an empty list.
- FakeHttpTransport hands out tokens and answers 401 without one, so every
  test goes through the token flow. New tests cover the token handling,
  every endpoint and each generated class.
This commit is contained in:
Sansan committed 2026-10-06 22:45:28 +02:00
1 parent 8b71723a34
commit f7619baf34
44 files changed
+5161 -71

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name ApiError
extends RefCounted
## The [code]APIError[/code] schema of the MusicServer API.
## What went wrong
var error: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> ApiError:
var result: ApiError = ApiError.new()
result.error = ApiJson.get_string(data, "error", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"error": error,
}
+1
View File
@@ -0,0 +1 @@
uid://4ak2e86vltp2
+36
View File
@@ -0,0 +1,36 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name ApiJson
extends RefCounted
## Reads fields from parsed JSON for the generated API classes. A field that is
## missing, null or of the wrong type gives the default instead.
static func get_string(data: Dictionary, key: String, default: String = "") -> String:
var value: Variant = data.get(key)
return value if value is String else default
static func get_int(data: Dictionary, key: String, default: int = 0) -> int:
var value: Variant = data.get(key)
return int(value) if value is int || value is float else default
static func get_float(data: Dictionary, key: String, default: float = 0.0) -> float:
var value: Variant = data.get(key)
return float(value) if value is int || value is float else default
static func get_bool(data: Dictionary, key: String, default: bool = false) -> bool:
var value: Variant = data.get(key)
return value if value is bool else default
static func get_array(data: Dictionary, key: String) -> Array:
var value: Variant = data.get(key)
return value if value is Array else []
static func get_dictionary(data: Dictionary, key: String) -> Dictionary:
var value: Variant = data.get(key)
return value if value is Dictionary else {}
+1
View File
@@ -0,0 +1 @@
uid://kpihxs4wsldn
+24
View File
@@ -0,0 +1,24 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name ProgressResponse
extends RefCounted
## The [code]ProgressResponse[/code] schema of the MusicServer API.
var progress: String
var time_spent: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> ProgressResponse:
var result: ProgressResponse = ProgressResponse.new()
result.progress = ApiJson.get_string(data, "progress", "")
result.time_spent = ApiJson.get_string(data, "time_spent", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"progress": progress,
"time_spent": time_spent,
}
@@ -0,0 +1 @@
uid://blka8xmsytbw1
+36
View File
@@ -0,0 +1,36 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name Song
extends RefCounted
## The [code]Song[/code] schema of the MusicServer API.
var file_name: String
var name: String
var soundtrack_name: String
var soundtrack_uuid: String
var times_played: int
var uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> Song:
var result: Song = Song.new()
result.file_name = ApiJson.get_string(data, "file_name", "")
result.name = ApiJson.get_string(data, "name", "")
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
result.times_played = ApiJson.get_int(data, "times_played", 0)
result.uuid = ApiJson.get_string(data, "uuid", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"file_name": file_name,
"name": name,
"soundtrack_name": soundtrack_name,
"soundtrack_uuid": soundtrack_uuid,
"times_played": times_played,
"uuid": uuid,
}
+1
View File
@@ -0,0 +1 @@
uid://cmsr3kl148gth
+42
View File
@@ -0,0 +1,42 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name SongInfo
extends RefCounted
## The [code]SongInfo[/code] schema of the MusicServer API.
var currently_playing: bool
var song: String
var song_no: int
var song_played: int
var song_uuid: String
var soundtrack: String
var soundtrack_played: int
var soundtrack_uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SongInfo:
var result: SongInfo = SongInfo.new()
result.currently_playing = ApiJson.get_bool(data, "CurrentlyPlaying", false)
result.song = ApiJson.get_string(data, "Song", "")
result.song_no = ApiJson.get_int(data, "SongNo", 0)
result.song_played = ApiJson.get_int(data, "SongPlayed", 0)
result.song_uuid = ApiJson.get_string(data, "SongUuid", "")
result.soundtrack = ApiJson.get_string(data, "Soundtrack", "")
result.soundtrack_played = ApiJson.get_int(data, "SoundtrackPlayed", 0)
result.soundtrack_uuid = ApiJson.get_string(data, "SoundtrackUuid", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"CurrentlyPlaying": currently_playing,
"Song": song,
"SongNo": song_no,
"SongPlayed": song_played,
"SongUuid": song_uuid,
"Soundtrack": soundtrack,
"SoundtrackPlayed": soundtrack_played,
"SoundtrackUuid": soundtrack_uuid,
}
+1
View File
@@ -0,0 +1 @@
uid://blybmr7a2rm6a
+42
View File
@@ -0,0 +1,42 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name SongInfoForStats
extends RefCounted
## The [code]SongInfoForStats[/code] schema of the MusicServer API.
var file_name: String
var path: String
var song_name: String
var song_uuid: String
var soundtrack_id: int
var soundtrack_name: String
var soundtrack_uuid: String
var times_played: int
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SongInfoForStats:
var result: SongInfoForStats = SongInfoForStats.new()
result.file_name = ApiJson.get_string(data, "file_name", "")
result.path = ApiJson.get_string(data, "path", "")
result.song_name = ApiJson.get_string(data, "song_name", "")
result.song_uuid = ApiJson.get_string(data, "song_uuid", "")
result.soundtrack_id = ApiJson.get_int(data, "soundtrack_id", 0)
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
result.times_played = ApiJson.get_int(data, "times_played", 0)
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"file_name": file_name,
"path": path,
"song_name": song_name,
"song_uuid": song_uuid,
"soundtrack_id": soundtrack_id,
"soundtrack_name": soundtrack_name,
"soundtrack_uuid": soundtrack_uuid,
"times_played": times_played,
}
@@ -0,0 +1 @@
uid://chexehl48yklr
+33
View File
@@ -0,0 +1,33 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name Soundtrack
extends RefCounted
## The [code]Soundtrack[/code] schema of the MusicServer API.
var added: String
var last_played: String
var name: String
var times_played: int
var uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> Soundtrack:
var result: Soundtrack = Soundtrack.new()
result.added = ApiJson.get_string(data, "added", "")
result.last_played = ApiJson.get_string(data, "last_played", "")
result.name = ApiJson.get_string(data, "name", "")
result.times_played = ApiJson.get_int(data, "times_played", 0)
result.uuid = ApiJson.get_string(data, "uuid", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"added": added,
"last_played": last_played,
"name": name,
"times_played": times_played,
"uuid": uuid,
}
+1
View File
@@ -0,0 +1 @@
uid://bdbaxmn66tuv7
@@ -0,0 +1,38 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name SoundtrackWithSongList
extends RefCounted
## The [code]SoundtrackWithSongList[/code] schema of the MusicServer API.
var added: String
var last_played: String
var name: String
var songs: Array[Song]
var times_played: int
var uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
var result: SoundtrackWithSongList = SoundtrackWithSongList.new()
result.added = ApiJson.get_string(data, "added", "")
result.last_played = ApiJson.get_string(data, "last_played", "")
result.name = ApiJson.get_string(data, "name", "")
for item: Variant in ApiJson.get_array(data, "songs"):
if item is Dictionary:
result.songs.append(Song.from_dict(item))
result.times_played = ApiJson.get_int(data, "times_played", 0)
result.uuid = ApiJson.get_string(data, "uuid", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"added": added,
"last_played": last_played,
"name": name,
"songs": songs.map(func(item: Song) -> Dictionary: return item.to_dict()),
"times_played": times_played,
"uuid": uuid,
}
@@ -0,0 +1 @@
uid://cj4rx6m53oarm
+38
View File
@@ -0,0 +1,38 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name SoundtrackWithSongs
extends RefCounted
## The [code]SoundtrackWithSongs[/code] schema of the MusicServer API.
var songs: Array[SongInfoForStats]
var soundtrack_id: int
var soundtrack_last_played: String
var soundtrack_name: String
var soundtrack_played: int
var soundtrack_uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
var result: SoundtrackWithSongs = SoundtrackWithSongs.new()
for item: Variant in ApiJson.get_array(data, "songs"):
if item is Dictionary:
result.songs.append(SongInfoForStats.from_dict(item))
result.soundtrack_id = ApiJson.get_int(data, "soundtrack_id", 0)
result.soundtrack_last_played = ApiJson.get_string(data, "soundtrack_last_played", "")
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
result.soundtrack_played = ApiJson.get_int(data, "soundtrack_played", 0)
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"songs": songs.map(func(item: SongInfoForStats) -> Dictionary: return item.to_dict()),
"soundtrack_id": soundtrack_id,
"soundtrack_last_played": soundtrack_last_played,
"soundtrack_name": soundtrack_name,
"soundtrack_played": soundtrack_played,
"soundtrack_uuid": soundtrack_uuid,
}
@@ -0,0 +1 @@
uid://yxxvcbbr5ioi
+39
View File
@@ -0,0 +1,39 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name StatisticsSummary
extends RefCounted
## The [code]StatisticsSummary[/code] schema of the MusicServer API.
var avg_soundtrack_plays: float
var max_soundtrack_plays: int
var min_soundtrack_plays: int
var never_played_soundtracks: int
var played_soundtracks: int
var total_soundtrack_plays: int
var total_soundtracks: int
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> StatisticsSummary:
var result: StatisticsSummary = StatisticsSummary.new()
result.avg_soundtrack_plays = ApiJson.get_float(data, "avg_soundtrack_plays", 0.0)
result.max_soundtrack_plays = ApiJson.get_int(data, "max_soundtrack_plays", 0)
result.min_soundtrack_plays = ApiJson.get_int(data, "min_soundtrack_plays", 0)
result.never_played_soundtracks = ApiJson.get_int(data, "never_played_soundtracks", 0)
result.played_soundtracks = ApiJson.get_int(data, "played_soundtracks", 0)
result.total_soundtrack_plays = ApiJson.get_int(data, "total_soundtrack_plays", 0)
result.total_soundtracks = ApiJson.get_int(data, "total_soundtracks", 0)
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"avg_soundtrack_plays": avg_soundtrack_plays,
"max_soundtrack_plays": max_soundtrack_plays,
"min_soundtrack_plays": min_soundtrack_plays,
"never_played_soundtracks": never_played_soundtracks,
"played_soundtracks": played_soundtracks,
"total_soundtrack_plays": total_soundtrack_plays,
"total_soundtracks": total_soundtracks,
}
@@ -0,0 +1 @@
uid://d2acyameyuhv2
+21
View File
@@ -0,0 +1,21 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name StatusMessage
extends RefCounted
## The [code]StatusMessage[/code] schema of the MusicServer API.
var status: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> StatusMessage:
var result: StatusMessage = StatusMessage.new()
result.status = ApiJson.get_string(data, "status", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"status": status,
}
+1
View File
@@ -0,0 +1 @@
uid://5inwlixefu0m
+41
View File
@@ -0,0 +1,41 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name SyncResponse
extends RefCounted
## The [code]SyncResponse[/code] schema of the MusicServer API.
var catched_errors: Array[String]
var soundtracks_added: Array[String]
var soundtracks_changed_content: Array[String]
var soundtracks_changed_title: Dictionary[String, String]
var soundtracks_re_added: Array[String]
var soundtracks_removed: Array[String]
var total_time: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SyncResponse:
var result: SyncResponse = SyncResponse.new()
result.catched_errors.assign(ApiJson.get_array(data, "catched_errors"))
result.soundtracks_added.assign(ApiJson.get_array(data, "soundtracks_added"))
result.soundtracks_changed_content.assign(
ApiJson.get_array(data, "soundtracks_changed_content"))
result.soundtracks_changed_title.assign(
ApiJson.get_dictionary(data, "soundtracks_changed_title"))
result.soundtracks_re_added.assign(ApiJson.get_array(data, "soundtracks_re_added"))
result.soundtracks_removed.assign(ApiJson.get_array(data, "soundtracks_removed"))
result.total_time = ApiJson.get_string(data, "total_time", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"catched_errors": catched_errors,
"soundtracks_added": soundtracks_added,
"soundtracks_changed_content": soundtracks_changed_content,
"soundtracks_changed_title": soundtracks_changed_title,
"soundtracks_re_added": soundtracks_re_added,
"soundtracks_removed": soundtracks_removed,
"total_time": total_time,
}
+1
View File
@@ -0,0 +1 @@
uid://d2fcfuusfbhah
+26
View File
@@ -0,0 +1,26 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name TokenRequest
extends RefCounted
## The [code]TokenRequest[/code] schema of the MusicServer API.
## Kind of client, for example web, godot or api
var client_type: String = "web"
## API password, required when the server has one configured
var password: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> TokenRequest:
var result: TokenRequest = TokenRequest.new()
result.client_type = ApiJson.get_string(data, "client_type", "web")
result.password = ApiJson.get_string(data, "password", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"client_type": client_type,
"password": password,
}
+1
View File
@@ -0,0 +1 @@
uid://c83b66bi6vpmp
+28
View File
@@ -0,0 +1,28 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name TokenResponse
extends RefCounted
## The [code]TokenResponse[/code] schema of the MusicServer API.
var client_type: String
var expires_at: String
## Send as Authorization: Bearer <token>
var token: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> TokenResponse:
var result: TokenResponse = TokenResponse.new()
result.client_type = ApiJson.get_string(data, "client_type", "")
result.expires_at = ApiJson.get_string(data, "expires_at", "")
result.token = ApiJson.get_string(data, "token", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"client_type": client_type,
"expires_at": expires_at,
"token": token,
}
+1
View File
@@ -0,0 +1 @@
uid://4a44v85naqw0
+24
View File
@@ -0,0 +1,24 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name VersionData
extends RefCounted
## The [code]VersionData[/code] schema of the MusicServer API.
var changelog: Array[String]
var version: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> VersionData:
var result: VersionData = VersionData.new()
result.changelog.assign(ApiJson.get_array(data, "changelog"))
result.version = ApiJson.get_string(data, "version", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"changelog": changelog,
"version": version,
}
+1
View File
@@ -0,0 +1 @@
uid://no7af2fycku5
+16 -8
View File
@@ -17,8 +17,15 @@ 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.
func request(url: String, method: int, on_complete: Callable) -> void:
## timeouts are reported with response code 0. [param headers] are sent as-is; a
## [param body] is sent as JSON.
func request(
url: String,
method: int,
on_complete: Callable,
headers: PackedStringArray = PackedStringArray(),
body: String = "",
) -> void:
var http_request: HTTPRequest = HTTPRequest.new()
http_request.timeout = timeout_seconds
_owner.add_child(http_request)
@@ -27,15 +34,16 @@ func request(url: String, method: int, on_complete: Callable) -> void:
result: int,
response_code: int,
_headers: PackedStringArray,
body: PackedByteArray,
response_body: PackedByteArray,
) -> void:
http_request.queue_free()
on_complete.call(response_code if result == HTTPRequest.RESULT_SUCCESS else 0, body)
on_complete.call(response_code if result == HTTPRequest.RESULT_SUCCESS else 0,
response_body)
)
var headers: PackedStringArray = []
if method == HTTPClient.METHOD_PUT:
headers.append("Content-Type: application/json")
var error: int = http_request.request(url, headers, method)
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)
if error != OK:
http_request.queue_free()
push_error("Could not send request to %s (error %d)" % [url, error])
+383 -27
View File
@@ -1,25 +1,47 @@
extends Node
## Client for the MusicServer REST API.
## 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.
##
## 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.
## 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"
## Sends the actual HTTP requests. Swap it out in tests.
var transport: HttpTransport
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)
## Fetches the songs already in the server playlist, as an Array of
## [code]{SongNo, Soundtrack, Song}[/code] dictionaries.
# 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("/music/list", on_done, on_failed)
get_json_list("/music/list", SongInfo, on_done, on_failed)
## Downloads a random song as MP3 bytes. With [param low_played] the server prefers
@@ -29,14 +51,20 @@ func fetch_random_song(
get_bytes("/music/rand/low" if low_played else "/music/rand", on_done, on_failed)
## Downloads a random song as MP3 bytes, picked the way the server did before low played
## mode existed: any soundtrack is as likely as any other.
func fetch_random_song_classic(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/music/rand/classic", on_done, on_failed)
## 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 [code]{SongNo, Soundtrack, Song}[/code] for the most recently queued song.
## Fetches the [SongInfo] of the most recently queued song.
func fetch_song_info(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json("/music/info", on_done, on_failed)
get_json_object("/music/info", SongInfo, on_done, on_failed)
## Downloads a song from the server playlist as MP3 bytes.
@@ -44,6 +72,16 @@ func fetch_song(song_no: int, on_done: Callable, on_failed: Callable = Callable(
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)
@@ -54,7 +92,19 @@ func reset_playlist(on_done: Callable, on_failed: Callable = Callable()) -> void
send("/music/reset", on_done, on_failed)
## Fetches every soundtrack name as an Array of Strings.
## 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)
@@ -64,14 +114,93 @@ func fetch_all_soundtracks_ordered(on_done: Callable, on_failed: Callable = Call
get_json("/music/all/order", 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)
## 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)
## 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 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("/characters", on_done, on_failed)
get_json("/character/list", on_done, on_failed)
## Downloads a character image as PNG bytes.
@@ -80,9 +209,17 @@ func fetch_character_image(
get_bytes("/character?name=" + character_name.uri_encode(), on_done, on_failed)
## Starts syncing the server's soundtrack library with its music folder.
# 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)
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
@@ -91,17 +228,115 @@ func fetch_sync_progress(on_done: Callable, on_failed: Callable = Callable()) ->
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)
## Downloads the newest MusicPlayer for Windows.
func download_client_windows(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/download/windows", on_done, on_failed)
## Downloads the newest MusicPlayer for Linux.
func download_client_linux(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/download/linux", on_done, on_failed)
# 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 = ""
# 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, func(body: PackedByteArray) -> 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()])
_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(json.data)
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)
@@ -124,17 +359,138 @@ func send(
, on_failed)
func _request(path: String, method: int, on_success: Callable, on_failed: Callable) -> void:
var url: String = Settings.default_path + path
transport.request(url, method, func(response_code: int, body: PackedByteArray) -> void:
if response_code != 200:
push_warning("Request to %s failed with response code %d" % [url, response_code])
_call_if_valid(on_failed)
return
on_success.call(body)
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
func _request(
path: String,
method: int,
on_success: Callable,
on_failed: Callable,
may_retry: bool = true,
) -> void:
_with_token(func(token: String) -> void:
_send(path, method, token, func(response_code: int, body: 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)
return
_handle_response(path, response_code, body, on_success, on_failed)
)
, 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) -> void:
_handle_response(path, response_code, body, on_success, on_failed)
)
func _send(
path: String, method: int, token: String, on_complete: Callable, body: String = "",
) -> void:
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)
func _handle_response(
path: String,
response_code: int,
body: PackedByteArray,
on_success: Callable,
on_failed: Callable,
) -> void:
if response_code != 200:
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) -> 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()))
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
static func _call_if_valid(callback: Callable) -> void:
if callback.is_valid():
callback.call()
+3285
View File
File diff suppressed because it is too large. Load diff