From f7619baf34af05eec89a593f87c65c8eb05e9f49 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 6 Oct 2026 22:45:28 +0200 Subject: [PATCH] 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. --- TESTING.md | 22 +- data/settings.gd | 3 + quiz/song_loader.gd | 29 +- server/generated/api_error.gd | 22 + server/generated/api_error.gd.uid | 1 + server/generated/api_json.gd | 36 + server/generated/api_json.gd.uid | 1 + server/generated/progress_response.gd | 24 + server/generated/progress_response.gd.uid | 1 + server/generated/song.gd | 36 + server/generated/song.gd.uid | 1 + server/generated/song_info.gd | 42 + server/generated/song_info.gd.uid | 1 + server/generated/song_info_for_stats.gd | 42 + server/generated/song_info_for_stats.gd.uid | 1 + server/generated/soundtrack.gd | 33 + server/generated/soundtrack.gd.uid | 1 + server/generated/soundtrack_with_song_list.gd | 38 + .../soundtrack_with_song_list.gd.uid | 1 + server/generated/soundtrack_with_songs.gd | 38 + server/generated/soundtrack_with_songs.gd.uid | 1 + server/generated/statistics_summary.gd | 39 + server/generated/statistics_summary.gd.uid | 1 + server/generated/status_message.gd | 21 + server/generated/status_message.gd.uid | 1 + server/generated/sync_response.gd | 41 + server/generated/sync_response.gd.uid | 1 + server/generated/token_request.gd | 26 + server/generated/token_request.gd.uid | 1 + server/generated/token_response.gd | 28 + server/generated/token_response.gd.uid | 1 + server/generated/version_data.gd | 24 + server/generated/version_data.gd.uid | 1 + server/http_transport.gd | 24 +- server/music_server_client.gd | 410 +- server/openapi.json | 3285 +++++++++++++++++ tests/fakes/fake_http_transport.gd | 72 +- tests/fakes/main_window_test_suite.gd | 2 +- tests/fakes/ui_tour.gd | 2 +- tests/unit/test_api_types.gd | 190 + tests/unit/test_api_types.gd.uid | 1 + tests/unit/test_music_server_client.gd | 424 ++- tools/generate_api_types.gd | 262 ++ tools/generate_api_types.gd.uid | 1 + 44 files changed, 5161 insertions(+), 71 deletions(-) create mode 100644 server/generated/api_error.gd create mode 100644 server/generated/api_error.gd.uid create mode 100644 server/generated/api_json.gd create mode 100644 server/generated/api_json.gd.uid create mode 100644 server/generated/progress_response.gd create mode 100644 server/generated/progress_response.gd.uid create mode 100644 server/generated/song.gd create mode 100644 server/generated/song.gd.uid create mode 100644 server/generated/song_info.gd create mode 100644 server/generated/song_info.gd.uid create mode 100644 server/generated/song_info_for_stats.gd create mode 100644 server/generated/song_info_for_stats.gd.uid create mode 100644 server/generated/soundtrack.gd create mode 100644 server/generated/soundtrack.gd.uid create mode 100644 server/generated/soundtrack_with_song_list.gd create mode 100644 server/generated/soundtrack_with_song_list.gd.uid create mode 100644 server/generated/soundtrack_with_songs.gd create mode 100644 server/generated/soundtrack_with_songs.gd.uid create mode 100644 server/generated/statistics_summary.gd create mode 100644 server/generated/statistics_summary.gd.uid create mode 100644 server/generated/status_message.gd create mode 100644 server/generated/status_message.gd.uid create mode 100644 server/generated/sync_response.gd create mode 100644 server/generated/sync_response.gd.uid create mode 100644 server/generated/token_request.gd create mode 100644 server/generated/token_request.gd.uid create mode 100644 server/generated/token_response.gd create mode 100644 server/generated/token_response.gd.uid create mode 100644 server/generated/version_data.gd create mode 100644 server/generated/version_data.gd.uid create mode 100644 server/openapi.json create mode 100644 tests/unit/test_api_types.gd create mode 100644 tests/unit/test_api_types.gd.uid create mode 100644 tools/generate_api_types.gd create mode 100644 tools/generate_api_types.gd.uid diff --git a/TESTING.md b/TESTING.md index 6e50dff..1262ed6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -48,7 +48,27 @@ assert_int(server.count("/music/rand")).is_equal(1) server.uninstall() ``` -Paths without a response get a 404, so the code under test sees a failed request. +Paths are relative to the API (`/music/list` is `/api/v1/music/list`). Paths +without a response get a 404, so the code under test sees a failed request. + +Like the real server, the fake hands out tokens on `POST /token` and answers 401 to +requests without a valid one, so the client's token handling runs in every test. Token +requests are counted in `server.token_requests`, not in `requested_paths`. Use +`server.expire_tokens()` to make the client renew its token, `server.hold("/token")` to +keep it waiting for one, and `server.respond_json("/token", {...}, 401)` for a refused +password. + +## API classes + +The classes in `server/generated/` (`SongInfo`, `StatisticsSummary`, ...) are generated +from the server's OpenAPI spec, committed as `server/openapi.json`. To update them, copy +a new spec there (`just openapi` in MusicServer, or `/openapi.json`) and run: + +```bash +godot --headless --path . -s tools/generate_api_types.gd +``` + +`tests/unit/test_api_types.gd` parses a sample response with each class. ## Writing tests diff --git a/data/settings.gd b/data/settings.gd index cc3266a..3e76b32 100644 --- a/data/settings.gd +++ b/data/settings.gd @@ -6,6 +6,9 @@ extends Node const MAX_NUMBER_OF_PLAYERS: int = 7 var default_path: String = "https://music.sanplex.xyz" +## Password for getting an API token from the server. Any password works when the +## server has none set. +var api_password: String = "" var selected_server: int = 0 var is_local: bool = false var is_debug: bool = false diff --git a/quiz/song_loader.gd b/quiz/song_loader.gd index ce85697..cd829a4 100644 --- a/quiz/song_loader.gd +++ b/quiz/song_loader.gd @@ -22,19 +22,16 @@ var retry_seconds: float = 5.0 ## Preloading waits for the list, because loading the list clears the playlist. func load_song_list() -> void: GameLog.debug("load_song_list") - var song_list_fetched: Callable = func song_list_fetched(data: Variant) -> void: + var song_list_fetched: Callable = func song_list_fetched(songs: Array[SongInfo]) -> void: Playlist.clear_song_list() - if typeof(data) == TYPE_ARRAY: - for d: Dictionary in data: - var new_song_object: SongObject = SongObject.new() - new_song_object.song_number = d.SongNo - new_song_object.soundtrack_title = d.Soundtrack - new_song_object.song_title = d.Song - new_song_object.is_answered = true - new_song_object.has_played = true - Playlist.add_to_playlist(new_song_object) - elif data != null: - GameLog.warning("Unexpected data") + for info: SongInfo in songs: + var new_song_object: SongObject = SongObject.new() + new_song_object.song_number = info.song_no + new_song_object.soundtrack_title = info.soundtrack + new_song_object.song_title = info.song + new_song_object.is_answered = true + new_song_object.has_played = true + Playlist.add_to_playlist(new_song_object) Playlist.update_last_played_song() song_list_loaded.emit() fetch_song_if_idle() @@ -80,11 +77,11 @@ func _add_song_to_queue(new_song_object: SongObject) -> void: func _fetch_song_info(new_song_object: SongObject) -> void: - var song_info_fetched: Callable = func song_info_fetched(data_received: Dictionary) -> void: + var song_info_fetched: Callable = func song_info_fetched(info: SongInfo) -> void: GameLog.debug("song_info_fetched") - new_song_object.song_number = data_received.SongNo - new_song_object.soundtrack_title = data_received.Soundtrack - new_song_object.song_title = data_received.Song + new_song_object.song_number = info.song_no + new_song_object.soundtrack_title = info.soundtrack + new_song_object.song_title = info.song GameLog.debug("new_song_object: ", new_song_object.to_string()) Playlist.add_to_playlist(new_song_object) if Playlist.should_preload_more_songs(): diff --git a/server/generated/api_error.gd b/server/generated/api_error.gd new file mode 100644 index 0000000..3cef70d --- /dev/null +++ b/server/generated/api_error.gd @@ -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, + } diff --git a/server/generated/api_error.gd.uid b/server/generated/api_error.gd.uid new file mode 100644 index 0000000..9a3f435 --- /dev/null +++ b/server/generated/api_error.gd.uid @@ -0,0 +1 @@ +uid://4ak2e86vltp2 diff --git a/server/generated/api_json.gd b/server/generated/api_json.gd new file mode 100644 index 0000000..411c8c1 --- /dev/null +++ b/server/generated/api_json.gd @@ -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 {} diff --git a/server/generated/api_json.gd.uid b/server/generated/api_json.gd.uid new file mode 100644 index 0000000..9593acc --- /dev/null +++ b/server/generated/api_json.gd.uid @@ -0,0 +1 @@ +uid://kpihxs4wsldn diff --git a/server/generated/progress_response.gd b/server/generated/progress_response.gd new file mode 100644 index 0000000..5be6746 --- /dev/null +++ b/server/generated/progress_response.gd @@ -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, + } diff --git a/server/generated/progress_response.gd.uid b/server/generated/progress_response.gd.uid new file mode 100644 index 0000000..536ac8d --- /dev/null +++ b/server/generated/progress_response.gd.uid @@ -0,0 +1 @@ +uid://blka8xmsytbw1 diff --git a/server/generated/song.gd b/server/generated/song.gd new file mode 100644 index 0000000..ccf8aba --- /dev/null +++ b/server/generated/song.gd @@ -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, + } diff --git a/server/generated/song.gd.uid b/server/generated/song.gd.uid new file mode 100644 index 0000000..83c7246 --- /dev/null +++ b/server/generated/song.gd.uid @@ -0,0 +1 @@ +uid://cmsr3kl148gth diff --git a/server/generated/song_info.gd b/server/generated/song_info.gd new file mode 100644 index 0000000..4d7af9a --- /dev/null +++ b/server/generated/song_info.gd @@ -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, + } diff --git a/server/generated/song_info.gd.uid b/server/generated/song_info.gd.uid new file mode 100644 index 0000000..ed33e78 --- /dev/null +++ b/server/generated/song_info.gd.uid @@ -0,0 +1 @@ +uid://blybmr7a2rm6a diff --git a/server/generated/song_info_for_stats.gd b/server/generated/song_info_for_stats.gd new file mode 100644 index 0000000..cb446c6 --- /dev/null +++ b/server/generated/song_info_for_stats.gd @@ -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, + } diff --git a/server/generated/song_info_for_stats.gd.uid b/server/generated/song_info_for_stats.gd.uid new file mode 100644 index 0000000..713be90 --- /dev/null +++ b/server/generated/song_info_for_stats.gd.uid @@ -0,0 +1 @@ +uid://chexehl48yklr diff --git a/server/generated/soundtrack.gd b/server/generated/soundtrack.gd new file mode 100644 index 0000000..a1c4fe3 --- /dev/null +++ b/server/generated/soundtrack.gd @@ -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, + } diff --git a/server/generated/soundtrack.gd.uid b/server/generated/soundtrack.gd.uid new file mode 100644 index 0000000..d52b568 --- /dev/null +++ b/server/generated/soundtrack.gd.uid @@ -0,0 +1 @@ +uid://bdbaxmn66tuv7 diff --git a/server/generated/soundtrack_with_song_list.gd b/server/generated/soundtrack_with_song_list.gd new file mode 100644 index 0000000..6cc8fb5 --- /dev/null +++ b/server/generated/soundtrack_with_song_list.gd @@ -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, + } diff --git a/server/generated/soundtrack_with_song_list.gd.uid b/server/generated/soundtrack_with_song_list.gd.uid new file mode 100644 index 0000000..c789de8 --- /dev/null +++ b/server/generated/soundtrack_with_song_list.gd.uid @@ -0,0 +1 @@ +uid://cj4rx6m53oarm diff --git a/server/generated/soundtrack_with_songs.gd b/server/generated/soundtrack_with_songs.gd new file mode 100644 index 0000000..e3aa5b9 --- /dev/null +++ b/server/generated/soundtrack_with_songs.gd @@ -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, + } diff --git a/server/generated/soundtrack_with_songs.gd.uid b/server/generated/soundtrack_with_songs.gd.uid new file mode 100644 index 0000000..42ff731 --- /dev/null +++ b/server/generated/soundtrack_with_songs.gd.uid @@ -0,0 +1 @@ +uid://yxxvcbbr5ioi diff --git a/server/generated/statistics_summary.gd b/server/generated/statistics_summary.gd new file mode 100644 index 0000000..612edf4 --- /dev/null +++ b/server/generated/statistics_summary.gd @@ -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, + } diff --git a/server/generated/statistics_summary.gd.uid b/server/generated/statistics_summary.gd.uid new file mode 100644 index 0000000..8f94cf1 --- /dev/null +++ b/server/generated/statistics_summary.gd.uid @@ -0,0 +1 @@ +uid://d2acyameyuhv2 diff --git a/server/generated/status_message.gd b/server/generated/status_message.gd new file mode 100644 index 0000000..df1c89a --- /dev/null +++ b/server/generated/status_message.gd @@ -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, + } diff --git a/server/generated/status_message.gd.uid b/server/generated/status_message.gd.uid new file mode 100644 index 0000000..197c3ca --- /dev/null +++ b/server/generated/status_message.gd.uid @@ -0,0 +1 @@ +uid://5inwlixefu0m diff --git a/server/generated/sync_response.gd b/server/generated/sync_response.gd new file mode 100644 index 0000000..0e3ba2b --- /dev/null +++ b/server/generated/sync_response.gd @@ -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, + } diff --git a/server/generated/sync_response.gd.uid b/server/generated/sync_response.gd.uid new file mode 100644 index 0000000..8283e0a --- /dev/null +++ b/server/generated/sync_response.gd.uid @@ -0,0 +1 @@ +uid://d2fcfuusfbhah diff --git a/server/generated/token_request.gd b/server/generated/token_request.gd new file mode 100644 index 0000000..331976d --- /dev/null +++ b/server/generated/token_request.gd @@ -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, + } diff --git a/server/generated/token_request.gd.uid b/server/generated/token_request.gd.uid new file mode 100644 index 0000000..5232314 --- /dev/null +++ b/server/generated/token_request.gd.uid @@ -0,0 +1 @@ +uid://c83b66bi6vpmp diff --git a/server/generated/token_response.gd b/server/generated/token_response.gd new file mode 100644 index 0000000..5cdf980 --- /dev/null +++ b/server/generated/token_response.gd @@ -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 +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, + } diff --git a/server/generated/token_response.gd.uid b/server/generated/token_response.gd.uid new file mode 100644 index 0000000..a8db3f9 --- /dev/null +++ b/server/generated/token_response.gd.uid @@ -0,0 +1 @@ +uid://4a44v85naqw0 diff --git a/server/generated/version_data.gd b/server/generated/version_data.gd new file mode 100644 index 0000000..495789a --- /dev/null +++ b/server/generated/version_data.gd @@ -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, + } diff --git a/server/generated/version_data.gd.uid b/server/generated/version_data.gd.uid new file mode 100644 index 0000000..943cd83 --- /dev/null +++ b/server/generated/version_data.gd.uid @@ -0,0 +1 @@ +uid://no7af2fycku5 diff --git a/server/http_transport.gd b/server/http_transport.gd index 63cc4ad..dc93a20 100644 --- a/server/http_transport.gd +++ b/server/http_transport.gd @@ -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]) diff --git a/server/music_server_client.gd b/server/music_server_client.gd index 3f83292..0399b14 100644 --- a/server/music_server_client.gd +++ b/server/music_server_client.gd @@ -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() diff --git a/server/openapi.json b/server/openapi.json new file mode 100644 index 0000000..b5590a8 --- /dev/null +++ b/server/openapi.json @@ -0,0 +1,3285 @@ +{ + "components": { + "schemas": { + "APIError": { + "additionalProperties": false, + "properties": { + "error": { + "description": "What went wrong", + "examples": [ + "Not Found" + ], + "type": "string" + } + }, + "required": [ + "error" + ], + "type": "object" + }, + "ProgressResponse": { + "additionalProperties": false, + "properties": { + "progress": { + "type": "string" + }, + "time_spent": { + "type": "string" + } + }, + "required": [ + "progress", + "time_spent" + ], + "type": "object" + }, + "Song": { + "additionalProperties": false, + "properties": { + "file_name": { + "type": "string" + }, + "name": { + "type": "string" + }, + "soundtrack_name": { + "type": "string" + }, + "soundtrack_uuid": { + "type": "string" + }, + "times_played": { + "format": "int32", + "type": "integer" + }, + "uuid": { + "type": "string" + } + }, + "required": [ + "uuid", + "name", + "times_played", + "soundtrack_uuid", + "soundtrack_name" + ], + "type": "object" + }, + "SongInfo": { + "additionalProperties": false, + "properties": { + "CurrentlyPlaying": { + "type": "boolean" + }, + "Song": { + "type": "string" + }, + "SongNo": { + "format": "int64", + "type": "integer" + }, + "SongPlayed": { + "format": "int32", + "type": "integer" + }, + "SongUuid": { + "type": "string" + }, + "Soundtrack": { + "type": "string" + }, + "SoundtrackPlayed": { + "format": "int32", + "type": "integer" + }, + "SoundtrackUuid": { + "type": "string" + } + }, + "required": [ + "SoundtrackUuid", + "Soundtrack", + "SoundtrackPlayed", + "SongUuid", + "Song", + "SongPlayed", + "CurrentlyPlaying", + "SongNo" + ], + "type": "object" + }, + "SongInfoForStats": { + "additionalProperties": false, + "properties": { + "file_name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "song_name": { + "type": "string" + }, + "song_uuid": { + "type": "string" + }, + "soundtrack_id": { + "format": "int32", + "type": "integer" + }, + "soundtrack_name": { + "type": "string" + }, + "soundtrack_uuid": { + "type": "string" + }, + "times_played": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "soundtrack_id", + "soundtrack_uuid", + "song_uuid", + "soundtrack_name", + "song_name", + "path", + "times_played" + ], + "type": "object" + }, + "Soundtrack": { + "additionalProperties": false, + "properties": { + "added": { + "format": "date-time", + "type": "string" + }, + "last_played": { + "format": "date-time", + "type": "string" + }, + "name": { + "type": "string" + }, + "times_played": { + "format": "int32", + "type": "integer" + }, + "uuid": { + "type": "string" + } + }, + "required": [ + "uuid", + "name", + "times_played", + "added" + ], + "type": "object" + }, + "SoundtrackWithSongList": { + "additionalProperties": false, + "properties": { + "added": { + "format": "date-time", + "type": "string" + }, + "last_played": { + "format": "date-time", + "type": "string" + }, + "name": { + "type": "string" + }, + "songs": { + "items": { + "$ref": "#/components/schemas/Song" + }, + "type": "array" + }, + "times_played": { + "format": "int32", + "type": "integer" + }, + "uuid": { + "type": "string" + } + }, + "required": [ + "songs", + "uuid", + "name", + "times_played", + "added" + ], + "type": "object" + }, + "SoundtrackWithSongs": { + "additionalProperties": false, + "properties": { + "songs": { + "items": { + "$ref": "#/components/schemas/SongInfoForStats" + }, + "type": "array" + }, + "soundtrack_id": { + "format": "int32", + "type": "integer" + }, + "soundtrack_last_played": { + "format": "date-time", + "type": "string" + }, + "soundtrack_name": { + "type": "string" + }, + "soundtrack_played": { + "format": "int32", + "type": "integer" + }, + "soundtrack_uuid": { + "type": "string" + } + }, + "required": [ + "soundtrack_id", + "soundtrack_uuid", + "soundtrack_name", + "soundtrack_played", + "songs" + ], + "type": "object" + }, + "StatisticsSummary": { + "additionalProperties": false, + "properties": { + "avg_soundtrack_plays": { + "format": "double", + "type": "number" + }, + "max_soundtrack_plays": { + "format": "int64", + "type": "integer" + }, + "min_soundtrack_plays": { + "format": "int64", + "type": "integer" + }, + "never_played_soundtracks": { + "format": "int64", + "type": "integer" + }, + "played_soundtracks": { + "format": "int64", + "type": "integer" + }, + "total_soundtrack_plays": { + "format": "int64", + "type": "integer" + }, + "total_soundtracks": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "total_soundtracks", + "played_soundtracks", + "never_played_soundtracks", + "total_soundtrack_plays", + "avg_soundtrack_plays", + "max_soundtrack_plays", + "min_soundtrack_plays" + ], + "type": "object" + }, + "StatusMessage": { + "additionalProperties": false, + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "SyncResponse": { + "additionalProperties": false, + "properties": { + "catched_errors": { + "items": { + "type": "string" + }, + "type": "array" + }, + "soundtracks_added": { + "items": { + "type": "string" + }, + "type": "array" + }, + "soundtracks_changed_content": { + "items": { + "type": "string" + }, + "type": "array" + }, + "soundtracks_changed_title": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "soundtracks_re_added": { + "items": { + "type": "string" + }, + "type": "array" + }, + "soundtracks_removed": { + "items": { + "type": "string" + }, + "type": "array" + }, + "total_time": { + "type": "string" + } + }, + "required": [ + "soundtracks_added", + "soundtracks_re_added", + "soundtracks_changed_title", + "soundtracks_changed_content", + "soundtracks_removed", + "catched_errors", + "total_time" + ], + "type": "object" + }, + "TokenRequest": { + "additionalProperties": false, + "properties": { + "client_type": { + "default": "web", + "description": "Kind of client, for example web, godot or api", + "type": "string" + }, + "password": { + "description": "API password, required when the server has one configured", + "type": "string" + } + }, + "type": "object" + }, + "TokenResponse": { + "additionalProperties": false, + "properties": { + "client_type": { + "type": "string" + }, + "expires_at": { + "format": "date-time", + "type": "string" + }, + "token": { + "description": "Send as Authorization: Bearer ", + "type": "string" + } + }, + "required": [ + "token", + "expires_at", + "client_type" + ], + "type": "object" + }, + "VersionData": { + "additionalProperties": false, + "properties": { + "changelog": { + "examples": [ + [ + "Initial release", + "Bug fixes" + ] + ], + "items": { + "type": "string" + }, + "type": "array" + }, + "version": { + "examples": [ + "1.0.0" + ], + "type": "string" + } + }, + "required": [ + "version", + "changelog" + ], + "type": "object" + } + }, + "securitySchemes": { + "bearer": { + "description": "Token from POST /api/v1/token", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "description": "API for the MusicServer application.\n\nGet a token with `POST /api/v1/token` and send it as `Authorization: Bearer `.\n\nThe same endpoints without the `/api/v1` prefix (legacy routes) are deprecated, need no token and are not documented here. They will be removed.", + "title": "MusicServer API", + "version": "5.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/v1/character": { + "get": { + "description": "Returns a character image.", + "operationId": "get-character", + "parameters": [ + { + "description": "File name of the character image, from the character list", + "explode": false, + "in": "query", + "name": "name", + "required": true, + "schema": { + "description": "File name of the character image, from the character list", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "image/jpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + }, + "image/png": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "The image" + }, + "206": { + "content": { + "image/jpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + }, + "image/png": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Part of the file, for a Range request" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Character image", + "tags": [ + "characters" + ] + } + }, + "/api/v1/character/list": { + "get": { + "description": "Returns the file names of all character images.", + "operationId": "list-characters", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "List characters", + "tags": [ + "characters" + ] + } + }, + "/api/v1/download": { + "get": { + "description": "Returns the name of the latest MusicPlayer release.", + "operationId": "get-latest-client-version", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Bad Gateway" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Latest client version", + "tags": [ + "download" + ] + } + }, + "/api/v1/download/linux": { + "get": { + "description": "Redirects to the download URL of the latest MusicPlayer release for Linux.", + "operationId": "download-client-linux", + "responses": { + "302": { + "description": "Found", + "headers": { + "Location": { + "schema": { + "description": "Download URL of the asset", + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Bad Gateway" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Download the latest client for Linux", + "tags": [ + "download" + ] + } + }, + "/api/v1/download/list": { + "get": { + "description": "Returns the file names of the latest MusicPlayer release.", + "operationId": "list-client-assets", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Bad Gateway" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Latest client assets", + "tags": [ + "download" + ] + } + }, + "/api/v1/download/windows": { + "get": { + "description": "Redirects to the download URL of the latest MusicPlayer release for Windows.", + "operationId": "download-client-windows", + "responses": { + "302": { + "description": "Found", + "headers": { + "Location": { + "schema": { + "description": "Download URL of the asset", + "type": "string" + } + } + } + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Bad Gateway" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Download the latest client for Windows", + "tags": [ + "download" + ] + } + }, + "/api/v1/health": { + "get": { + "description": "Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error.", + "operationId": "get-health", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Error" + } + }, + "summary": "Health check", + "tags": [ + "server" + ] + } + }, + "/api/v1/music": { + "get": { + "description": "Jumps to the song at the given queue position (clamped to the queue) and returns it.", + "operationId": "get-queue-song", + "parameters": [ + { + "description": "Position of the song in the queue (SongNo)", + "explode": false, + "in": "query", + "name": "song", + "required": true, + "schema": { + "description": "Position of the song in the queue (SongNo)", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "The song (MP3)" + }, + "206": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Part of the file, for a Range request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Play a song from the queue", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/addPlayed": { + "get": { + "description": "Increments the play counters of the current song and its soundtrack.", + "operationId": "mark-current-played", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Mark the current song as played", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/addQue": { + "get": { + "description": "Adds the song returned by the last random song request to the end of the queue.", + "operationId": "add-latest-to-queue", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Add the last random song to the queue", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/all": { + "get": { + "description": "Returns the names of all soundtracks in random order.", + "operationId": "get-soundtrack-names-random", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Soundtrack names, random order", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/all/order": { + "get": { + "description": "Returns the names of all soundtracks in alphabetical order.", + "operationId": "get-soundtrack-names", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Soundtrack names, sorted", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/all/random": { + "get": { + "description": "Same as /music/all.", + "operationId": "get-soundtrack-names-random-alias", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Soundtrack names, random order", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/info": { + "get": { + "description": "Returns the current song in the queue.", + "operationId": "get-song-info", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SongInfo" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Current song", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/list": { + "get": { + "description": "Returns all songs in the queue.", + "operationId": "get-played-songs", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SongInfo" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Queue", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/next": { + "get": { + "description": "Moves to the next song in the queue and returns it. Stays on the last song at the end.", + "operationId": "get-next-song", + "responses": { + "200": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "The song (MP3)" + }, + "206": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Part of the file, for a Range request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Next song in the queue", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/played": { + "put": { + "description": "Increments the play counters of the song at the given queue position. Positions outside the queue are ignored.", + "operationId": "mark-song-played", + "parameters": [ + { + "description": "Position of the song in the queue (SongNo)", + "explode": false, + "in": "query", + "name": "song", + "required": true, + "schema": { + "description": "Position of the song in the queue (SongNo)", + "format": "int64", + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Mark a queued song as played", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/previous": { + "get": { + "description": "Moves to the previous song in the queue and returns it. Stays on the first song at the start.", + "operationId": "get-previous-song", + "responses": { + "200": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "The song (MP3)" + }, + "206": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Part of the file, for a Range request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Previous song in the queue", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/rand": { + "get": { + "description": "Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", + "operationId": "get-random-song", + "responses": { + "200": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "The song (MP3)" + }, + "206": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Part of the file, for a Range request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Random song", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/rand/classic": { + "get": { + "description": "Picks a random song where every song has the same chance, regardless of soundtrack.", + "operationId": "get-random-song-classic", + "responses": { + "200": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "The song (MP3)" + }, + "206": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Part of the file, for a Range request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Random song, every song equal", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/rand/low": { + "get": { + "description": "Picks a random song, favouring soundtracks that have been played less than average.", + "operationId": "get-random-song-low-chance", + "responses": { + "200": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "The song (MP3)" + }, + "206": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Part of the file, for a Range request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Random song, favouring rarely played", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/reset": { + "get": { + "description": "Clears the queue and reloads the soundtrack list.", + "operationId": "reset-queue", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Reset the queue", + "tags": [ + "music" + ] + } + }, + "/api/v1/music/soundTest": { + "get": { + "description": "Returns a random song for checking the volume.", + "operationId": "get-sound-test-song", + "responses": { + "200": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "The song (MP3)" + }, + "206": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Part of the file, for a Range request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Sound check song", + "tags": [ + "music" + ] + } + }, + "/api/v1/songs/{uuid}": { + "get": { + "description": "Returns information about a song.", + "operationId": "get-song", + "parameters": [ + { + "description": "UUID of the soundtrack or song", + "in": "path", + "name": "uuid", + "required": true, + "schema": { + "description": "UUID of the soundtrack or song", + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Song" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Get song", + "tags": [ + "library" + ] + } + }, + "/api/v1/songs/{uuid}/file": { + "get": { + "description": "Returns the audio file of a song.", + "operationId": "get-song-file", + "parameters": [ + { + "description": "UUID of the soundtrack or song", + "in": "path", + "name": "uuid", + "required": true, + "schema": { + "description": "UUID of the soundtrack or song", + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "The song (MP3)" + }, + "206": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "description": "Part of the file, for a Range request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Stream song", + "tags": [ + "library" + ] + } + }, + "/api/v1/soundtracks": { + "get": { + "description": "Returns all soundtracks, sorted by name.", + "operationId": "list-soundtracks", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Soundtrack" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "List soundtracks", + "tags": [ + "library" + ] + } + }, + "/api/v1/soundtracks/{uuid}": { + "get": { + "description": "Returns a soundtrack with its songs, sorted by name.", + "operationId": "get-soundtrack", + "parameters": [ + { + "description": "UUID of the soundtrack or song", + "in": "path", + "name": "uuid", + "required": true, + "schema": { + "description": "UUID of the soundtrack or song", + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SoundtrackWithSongList" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Get soundtrack", + "tags": [ + "library" + ] + } + }, + "/api/v1/statistics/songs/least-played": { + "get": { + "description": "Returns the least played songs with their soundtrack.", + "operationId": "get-least-played-songs", + "parameters": [ + { + "description": "Number of results. Values above 100 are capped to 100.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 10, + "description": "Number of results. Values above 100 are capped to 100.", + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SongInfoForStats" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Least played songs", + "tags": [ + "statistics" + ] + } + }, + "/api/v1/statistics/songs/most-played": { + "get": { + "description": "Returns the most played songs with their soundtrack.", + "operationId": "get-most-played-songs", + "parameters": [ + { + "description": "Number of results. Values above 100 are capped to 100.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 10, + "description": "Number of results. Values above 100 are capped to 100.", + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SongInfoForStats" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Most played songs", + "tags": [ + "statistics" + ] + } + }, + "/api/v1/statistics/soundtracks/last-played": { + "get": { + "description": "Returns the most recently played soundtracks with their songs.", + "operationId": "get-last-played-soundtracks", + "parameters": [ + { + "description": "Number of results. Values above 100 are capped to 100.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 10, + "description": "Number of results. Values above 100 are capped to 100.", + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SoundtrackWithSongs" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Recently played soundtracks", + "tags": [ + "statistics" + ] + } + }, + "/api/v1/statistics/soundtracks/least-played": { + "get": { + "description": "Returns the least played soundtracks with their songs.", + "operationId": "get-least-played-soundtracks", + "parameters": [ + { + "description": "Number of results. Values above 100 are capped to 100.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 10, + "description": "Number of results. Values above 100 are capped to 100.", + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SoundtrackWithSongs" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Least played soundtracks", + "tags": [ + "statistics" + ] + } + }, + "/api/v1/statistics/soundtracks/most-played": { + "get": { + "description": "Returns the most played soundtracks with their songs.", + "operationId": "get-most-played-soundtracks", + "parameters": [ + { + "description": "Number of results. Values above 100 are capped to 100.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 10, + "description": "Number of results. Values above 100 are capped to 100.", + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SoundtrackWithSongs" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Most played soundtracks", + "tags": [ + "statistics" + ] + } + }, + "/api/v1/statistics/soundtracks/never-played": { + "get": { + "description": "Returns all soundtracks that have never been played, with their songs.", + "operationId": "get-never-played-soundtracks", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SoundtrackWithSongs" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Never played soundtracks", + "tags": [ + "statistics" + ] + } + }, + "/api/v1/statistics/soundtracks/oldest-played": { + "get": { + "description": "Returns the soundtracks played longest ago (played at least once) with their songs.", + "operationId": "get-oldest-played-soundtracks", + "parameters": [ + { + "description": "Number of results. Values above 100 are capped to 100.", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 10, + "description": "Number of results. Values above 100 are capped to 100.", + "format": "int64", + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/SoundtrackWithSongs" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Least recently played soundtracks", + "tags": [ + "statistics" + ] + } + }, + "/api/v1/statistics/summary": { + "get": { + "description": "Returns overall play statistics.", + "operationId": "get-statistics-summary", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatisticsSummary" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Statistics summary", + "tags": [ + "statistics" + ] + } + }, + "/api/v1/sync": { + "post": { + "description": "Starts syncing the music folder in the background, skipping soundtracks that have not changed. Poll /sync/progress for the result.", + "operationId": "start-sync", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Sync changed soundtracks", + "tags": [ + "sync" + ] + } + }, + "/api/v1/sync/full": { + "post": { + "description": "Starts a full sync in the background that re-reads every soundtrack and removes songs whose files are gone. Poll /sync/progress for the result.", + "operationId": "start-full-sync", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Sync all soundtracks", + "tags": [ + "sync" + ] + } + }, + "/api/v1/sync/progress": { + "get": { + "description": "While a sync is running, returns its progress. Otherwise returns the result of the last sync.", + "operationId": "get-sync-progress", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProgressResponse" + }, + { + "$ref": "#/components/schemas/SyncResponse" + } + ] + } + } + }, + "description": "Progress of the running sync, or the result of the last sync" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Sync progress or result", + "tags": [ + "sync" + ] + } + }, + "/api/v1/token": { + "delete": { + "description": "Deletes the token used for this request.", + "operationId": "delete-token", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusMessage" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Invalidate session token", + "tags": [ + "auth" + ] + }, + "post": { + "description": "Returns a token for the other /api/v1 endpoints, valid for 24 hours. Requires the API password when the server has one. Limited to 10 requests per minute per client.", + "operationId": "create-token", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenResponse" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Too Many Requests" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Create session token", + "tags": [ + "auth" + ] + } + }, + "/api/v1/token/cleanup": { + "post": { + "description": "Deletes all expired session tokens.", + "operationId": "cleanup-tokens", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusMessage" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Remove expired tokens", + "tags": [ + "auth" + ] + } + }, + "/api/v1/version": { + "get": { + "description": "Returns the server version and its changelog.", + "operationId": "get-version", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionData" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Latest server version", + "tags": [ + "server" + ] + } + }, + "/api/v1/version/history": { + "get": { + "description": "Returns all server versions with their changelogs, newest first.", + "operationId": "get-version-history", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/VersionData" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Version history", + "tags": [ + "server" + ] + } + } + } +} diff --git a/tests/fakes/fake_http_transport.gd b/tests/fakes/fake_http_transport.gd index 0686c43..1ac8964 100644 --- a/tests/fakes/fake_http_transport.gd +++ b/tests/fakes/fake_http_transport.gd @@ -5,16 +5,33 @@ extends HttpTransport ## 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]/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]. -## Paths requested so far, relative to [code]Settings.default_path[/code]. +## 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] = [] +## 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 = {} var _responses: Dictionary = {} var _successes_left: Dictionary = {} var _held_paths: Dictionary = {} var _previous_transport: HttpTransport +var _valid_tokens: Array[String] = [] func _init() -> void: @@ -25,12 +42,14 @@ func _init() -> void: func install() -> FakeHttpTransport: _previous_transport = MusicServerClient.transport MusicServerClient.transport = self + MusicServerClient.clear_token() return self ## Restores the transport that was active before [method install]. func uninstall() -> void: MusicServerClient.transport = _previous_transport + MusicServerClient.clear_token() ## Answers requests to [param path] with [param data] encoded as JSON. @@ -69,10 +88,32 @@ func count(path: String) -> int: return requested_paths.count(path) -func request(url: String, method: int, on_complete: Callable) -> void: - var path: String = url.trim_prefix(Settings.default_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: String = "", +) -> 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, on_complete)) + return + _answer_token_request(body, on_complete) + return requested_paths.append(path) 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()) + return if _held_paths.has(path): _held_paths[path].append(_answer.bind(path, on_complete)) return @@ -89,3 +130,28 @@ func _answer(path: String, on_complete: Callable) -> void: on_complete.call(_responses[path].code, _responses[path].body) else: on_complete.call(404, PackedByteArray()) + + +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()) + + +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 diff --git a/tests/fakes/main_window_test_suite.gd b/tests/fakes/main_window_test_suite.gd index 0c81848..33c786b 100644 --- a/tests/fakes/main_window_test_suite.gd +++ b/tests/fakes/main_window_test_suite.gd @@ -30,7 +30,7 @@ func before_test() -> void: _server.respond_json("/music/all", ["Chrono Trigger", "Final Fantasy VI"]) _server.respond_json("/music/all/order", ["Chrono Trigger", "Final Fantasy VI"]) _server.respond_json("/music/list", []) - _server.respond_json("/characters", []) + _server.respond_json("/character/list", []) _server.respond_bytes("/music/rand", mp3) _server.respond_bytes("/music/addQue", PackedByteArray()) _server.respond_json("/music/info", SONG_INFO[0]) diff --git a/tests/fakes/ui_tour.gd b/tests/fakes/ui_tour.gd index bd9627f..7a26f7c 100644 --- a/tests/fakes/ui_tour.gd +++ b/tests/fakes/ui_tour.gd @@ -57,7 +57,7 @@ static func make_server() -> FakeHttpTransport: server.respond_json("/music/all/order", SOUNDTRACKS) server.respond_json("/music/list", []) var characters: Array[String] = character_names() - server.respond_json("/characters", characters) + server.respond_json("/character/list", characters) for character_name: String in characters: server.respond_bytes("/character?name=" + character_name.uri_encode(), FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_name))) diff --git a/tests/unit/test_api_types.gd b/tests/unit/test_api_types.gd new file mode 100644 index 0000000..44eb1f0 --- /dev/null +++ b/tests/unit/test_api_types.gd @@ -0,0 +1,190 @@ +extends GdUnitTestSuite +## The classes generated from the MusicServer OpenAPI spec read the server's JSON. +## +## Each sample is a response the way the server sends it, parsed from text like the +## client does, so numbers arrive as floats. + + +func _parse(json: String) -> Dictionary: + return JSON.parse_string(json) + + +func test_song_info() -> void: + var info: SongInfo = SongInfo.from_dict(_parse("""{ + "SoundtrackUuid": "st-1", "Soundtrack": "Chrono Trigger", "SoundtrackPlayed": 4, + "SongUuid": "so-1", "Song": "Corridors of Time", "SongPlayed": 2, + "CurrentlyPlaying": true, "SongNo": 3}""")) + + assert_str(info.soundtrack_uuid).is_equal("st-1") + assert_str(info.soundtrack).is_equal("Chrono Trigger") + assert_int(info.soundtrack_played).is_equal(4) + assert_str(info.song_uuid).is_equal("so-1") + assert_str(info.song).is_equal("Corridors of Time") + assert_int(info.song_played).is_equal(2) + assert_bool(info.currently_playing).is_true() + assert_int(info.song_no).is_equal(3) + + +func test_song() -> void: + var song: Song = Song.from_dict(_parse("""{"uuid": "so-1", "name": "Dancing Mad", + "times_played": 7, "soundtrack_uuid": "st-2", "soundtrack_name": "Final Fantasy VI", + "file_name": "dancing_mad.mp3"}""")) + + assert_str(song.uuid).is_equal("so-1") + assert_str(song.name).is_equal("Dancing Mad") + assert_int(song.times_played).is_equal(7) + assert_str(song.soundtrack_uuid).is_equal("st-2") + assert_str(song.soundtrack_name).is_equal("Final Fantasy VI") + assert_str(song.file_name).is_equal("dancing_mad.mp3") + + +func test_soundtrack() -> void: + var soundtrack: Soundtrack = Soundtrack.from_dict(_parse("""{"uuid": "st-1", + "name": "Terranigma", "times_played": 3, "added": "2026-01-02T10:00:00Z", + "last_played": "2026-10-01T20:00:00Z"}""")) + + assert_str(soundtrack.uuid).is_equal("st-1") + assert_str(soundtrack.name).is_equal("Terranigma") + assert_int(soundtrack.times_played).is_equal(3) + assert_str(soundtrack.added).is_equal("2026-01-02T10:00:00Z") + assert_str(soundtrack.last_played).is_equal("2026-10-01T20:00:00Z") + + +func test_soundtrack_with_song_list() -> void: + var soundtrack: SoundtrackWithSongList = SoundtrackWithSongList.from_dict(_parse("""{ + "uuid": "st-1", "name": "Lufia II", "times_played": 1, "added": "2026-01-02T10:00:00Z", + "songs": [{"uuid": "so-1", "name": "Battle #1", "times_played": 1, + "soundtrack_uuid": "st-1", "soundtrack_name": "Lufia II"}]}""")) + + assert_str(soundtrack.name).is_equal("Lufia II") + assert_str(soundtrack.last_played).is_empty() + assert_int(soundtrack.songs.size()).is_equal(1) + assert_str(soundtrack.songs[0].name).is_equal("Battle #1") + + +func test_soundtrack_with_songs() -> void: + var soundtrack: SoundtrackWithSongs = SoundtrackWithSongs.from_dict(_parse("""{ + "soundtrack_id": 5, "soundtrack_uuid": "st-1", "soundtrack_name": "Earthbound", + "soundtrack_played": 6, "soundtrack_last_played": "2026-10-01T20:00:00Z", + "songs": [{"soundtrack_id": 5, "soundtrack_uuid": "st-1", "song_uuid": "so-1", + "soundtrack_name": "Earthbound", "song_name": "Onett", "path": "/music/onett.mp3", + "times_played": 2, "file_name": "onett.mp3"}]}""")) + + assert_str(soundtrack.soundtrack_uuid).is_equal("st-1") + assert_str(soundtrack.soundtrack_name).is_equal("Earthbound") + assert_int(soundtrack.soundtrack_played).is_equal(6) + assert_str(soundtrack.soundtrack_last_played).is_equal("2026-10-01T20:00:00Z") + assert_str(soundtrack.songs[0].song_name).is_equal("Onett") + + +func test_soundtrack_with_songs_tolerates_null_songs() -> void: + var soundtrack: SoundtrackWithSongs = SoundtrackWithSongs.from_dict(_parse("""{ + "soundtrack_uuid": "st-1", "songs": null}""")) + + assert_array(soundtrack.songs).is_empty() + + +func test_song_info_for_stats() -> void: + var song: SongInfoForStats = SongInfoForStats.from_dict(_parse("""{"soundtrack_id": 5, + "soundtrack_uuid": "st-1", "song_uuid": "so-1", "soundtrack_name": "Super Metroid", + "song_name": "Brinstar", "path": "/music/brinstar.mp3", "times_played": 9, + "file_name": "brinstar.mp3"}""")) + + assert_str(song.song_uuid).is_equal("so-1") + assert_str(song.soundtrack_name).is_equal("Super Metroid") + assert_str(song.song_name).is_equal("Brinstar") + assert_str(song.path).is_equal("/music/brinstar.mp3") + assert_int(song.times_played).is_equal(9) + assert_str(song.file_name).is_equal("brinstar.mp3") + + +func test_statistics_summary() -> void: + var summary: StatisticsSummary = StatisticsSummary.from_dict(_parse("""{ + "total_soundtracks": 120, "played_soundtracks": 100, "never_played_soundtracks": 20, + "total_soundtrack_plays": 250, "avg_soundtrack_plays": 2.5, + "max_soundtrack_plays": 12, "min_soundtrack_plays": 1}""")) + + assert_int(summary.total_soundtracks).is_equal(120) + assert_int(summary.played_soundtracks).is_equal(100) + assert_int(summary.never_played_soundtracks).is_equal(20) + assert_int(summary.total_soundtrack_plays).is_equal(250) + assert_float(summary.avg_soundtrack_plays).is_equal(2.5) + assert_int(summary.max_soundtrack_plays).is_equal(12) + assert_int(summary.min_soundtrack_plays).is_equal(1) + + +func test_sync_response() -> void: + var result: SyncResponse = SyncResponse.from_dict(_parse("""{ + "soundtracks_added": ["Terranigma"], "soundtracks_re_added": [], + "soundtracks_changed_title": {"FF6": "Final Fantasy VI"}, + "soundtracks_changed_content": ["Chrono Trigger"], "soundtracks_removed": ["Old"], + "catched_errors": ["Could not read x.mp3"], "total_time": "00:00:03"}""")) + + assert_array(result.soundtracks_added).contains_exactly(["Terranigma"]) + assert_array(result.soundtracks_re_added).is_empty() + assert_dict(result.soundtracks_changed_title).is_equal({"FF6": "Final Fantasy VI"}) + assert_array(result.soundtracks_changed_content).contains_exactly(["Chrono Trigger"]) + assert_array(result.soundtracks_removed).contains_exactly(["Old"]) + assert_array(result.catched_errors).contains_exactly(["Could not read x.mp3"]) + assert_str(result.total_time).is_equal("00:00:03") + + +func test_progress_response() -> void: + var progress: ProgressResponse = ProgressResponse.from_dict(_parse("""{ + "progress": "40", "time_spent": "00:00:02"}""")) + + assert_str(progress.progress).is_equal("40") + assert_str(progress.time_spent).is_equal("00:00:02") + + +func test_version_data() -> void: + var version: VersionData = VersionData.from_dict(_parse("""{"version": "5.0.0", + "changelog": ["Initial release", "Bug fixes"]}""")) + + assert_str(version.version).is_equal("5.0.0") + assert_array(version.changelog).contains_exactly(["Initial release", "Bug fixes"]) + + +func test_token_response() -> void: + var response: TokenResponse = TokenResponse.from_dict(_parse("""{"token": "abc", + "expires_at": "2026-10-07T12:00:00Z", "client_type": "godot"}""")) + + assert_str(response.token).is_equal("abc") + assert_str(response.expires_at).is_equal("2026-10-07T12:00:00Z") + assert_str(response.client_type).is_equal("godot") + + +func test_token_request_defaults_to_web_client() -> void: + var request: TokenRequest = TokenRequest.from_dict({}) + + assert_str(request.client_type).is_equal("web") + assert_str(request.password).is_empty() + + +func test_status_message() -> void: + assert_str(StatusMessage.from_dict(_parse("""{"status": "ok"}""")).status).is_equal("ok") + + +func test_api_error() -> void: + assert_str(ApiError.from_dict(_parse("""{"error": "Not Found"}""")).error) \ + .is_equal("Not Found") + + +func test_missing_and_wrong_fields_give_defaults() -> void: + var info: SongInfo = SongInfo.from_dict({"SongNo": "three", "Song": null}) + + assert_int(info.song_no).is_equal(0) + assert_str(info.song).is_empty() + assert_bool(info.currently_playing).is_false() + + +func test_to_dict_gives_back_the_json() -> void: + var json: Dictionary = { + "soundtrack_id": 5, "soundtrack_uuid": "st-1", "soundtrack_name": "Earthbound", + "soundtrack_played": 6, "soundtrack_last_played": "2026-10-01T20:00:00Z", + "songs": [{"soundtrack_id": 5, "soundtrack_uuid": "st-1", "song_uuid": "so-1", + "soundtrack_name": "Earthbound", "song_name": "Onett", "path": "/onett.mp3", + "times_played": 2, "file_name": "onett.mp3"}], + } + + assert_dict(SoundtrackWithSongs.from_dict(json).to_dict()).is_equal(json) diff --git a/tests/unit/test_api_types.gd.uid b/tests/unit/test_api_types.gd.uid new file mode 100644 index 0000000..1d767e5 --- /dev/null +++ b/tests/unit/test_api_types.gd.uid @@ -0,0 +1 @@ +uid://bbuyu23x8rxbv diff --git a/tests/unit/test_music_server_client.gd b/tests/unit/test_music_server_client.gd index d4e8e92..6cff5cc 100644 --- a/tests/unit/test_music_server_client.gd +++ b/tests/unit/test_music_server_client.gd @@ -1,5 +1,6 @@ extends GdUnitTestSuite -## MusicServerClient: which paths it asks for and how it handles the answers. +## MusicServerClient: which paths it asks for, how it handles the answers and how it +## gets and renews its API token. var _server: FakeHttpTransport @@ -12,16 +13,50 @@ func after_test() -> void: _server.uninstall() -func test_fetch_song_list_passes_parsed_json() -> void: +func test_fetch_song_list_passes_song_infos() -> void: var songs: Array = [{"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"}] _server.respond_json("/music/list", songs) - var received: Array = [] + var received: Array[SongInfo] = [] - MusicServerClient.fetch_song_list(func(data: Array) -> void: received.append_array(data)) + MusicServerClient.fetch_song_list( + func(data: Array[SongInfo]) -> void: received.append_array(data)) assert_int(received.size()).is_equal(1) - assert_int(int(received[0].SongNo)).is_equal(0) - assert_str(received[0].Soundtrack).is_equal("Chrono Trigger") + assert_int(received[0].song_no).is_equal(0) + assert_str(received[0].soundtrack).is_equal("Chrono Trigger") + assert_str(received[0].song).is_equal("Corridors of Time") + + +func test_fetch_song_info_passes_song_info() -> void: + _server.respond_json("/music/info", {"SongNo": 7, "Soundtrack": "Terranigma", + "Song": "Underworld", "SoundtrackUuid": "st-1", "SongUuid": "so-1"}) + var received: Array[SongInfo] = [] + + MusicServerClient.fetch_song_info(func(info: SongInfo) -> void: received.append(info)) + + assert_int(received[0].song_no).is_equal(7) + assert_str(received[0].song_uuid).is_equal("so-1") + + +func test_empty_song_list_sent_as_null_gives_empty_array() -> void: + _server.respond_bytes("/music/list", "null".to_utf8_buffer()) + var received: Array = [] + + MusicServerClient.fetch_song_list(func(data: Array[SongInfo]) -> void: received.append(data)) + + assert_int(received.size()).is_equal(1) + assert_array(received[0]).is_empty() + + +func test_json_list_that_is_not_an_array_calls_on_failed() -> void: + _server.respond_json("/music/list", {"error": "not a list"}) + var outcome: Array[String] = [] + + MusicServerClient.fetch_song_list( + func(_data: Array[SongInfo]) -> void: outcome.append("done"), + func() -> void: outcome.append("failed")) + + assert_array(outcome).contains_exactly(["failed"]) func test_fetch_random_song_uses_normal_endpoint() -> void: @@ -59,7 +94,7 @@ func test_invalid_json_does_not_call_back() -> void: _server.respond_bytes("/music/info", "not json".to_utf8_buffer()) var called: Array[bool] = [false] - MusicServerClient.fetch_song_info(func(_data: Variant) -> void: called[0] = true) + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: called[0] = true) assert_bool(called[0]).is_false() @@ -80,7 +115,7 @@ func test_invalid_json_calls_on_failed() -> void: var outcome: Array[String] = [] MusicServerClient.fetch_song_info( - func(_data: Variant) -> void: outcome.append("done"), + func(_data: SongInfo) -> void: outcome.append("done"), func() -> void: outcome.append("failed")) assert_array(outcome).contains_exactly(["failed"]) @@ -122,17 +157,370 @@ func test_character_name_is_url_encoded() -> void: assert_array(_server.requested_paths).contains_exactly(["/character?name=Paper%20Mario.png"]) -func test_requests_go_to_selected_server() -> void: +func test_start_sync_sends_post() -> void: + _server.respond_json("/sync", "Sync started") + var called: Array[bool] = [false] + + MusicServerClient.start_sync(func() -> void: called[0] = true) + + assert_array(_server.requested_paths).contains_exactly(["/sync"]) + assert_array(_server.requested_methods).contains_exactly([HTTPClient.METHOD_POST]) + assert_bool(called[0]).is_true() + + +func test_start_full_sync_sends_post() -> void: + MusicServerClient.start_full_sync(func() -> void: pass) + + assert_array(_server.requested_paths).contains_exactly(["/sync/full"]) + assert_array(_server.requested_methods).contains_exactly([HTTPClient.METHOD_POST]) + + +func test_sync_already_running_calls_on_failed() -> void: + _server.respond_json("/sync", {"error": "Sync is already running"}, 423) + var outcome: Array[String] = [] + + MusicServerClient.start_sync( + func() -> void: outcome.append("done"), + func() -> void: outcome.append("failed")) + + assert_array(outcome).contains_exactly(["failed"]) + + +func test_requests_go_to_selected_server_under_api_path() -> void: var previous_path: String = Settings.default_path Settings.default_path = "http://localhost:8080" var requested_urls: Array[String] = [] - var recording_transport: HttpTransport = _RecordingTransport.new(requested_urls) - MusicServerClient.transport = recording_transport + MusicServerClient.transport = _RecordingTransport.new(requested_urls) - MusicServerClient.fetch_song_info(func(_data: Variant) -> void: pass) + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass) Settings.default_path = previous_path - assert_array(requested_urls).contains_exactly(["http://localhost:8080/music/info"]) + assert_array(requested_urls).contains_exactly([ + "http://localhost:8080/api/v1/token", + "http://localhost:8080/api/v1/music/info", + ]) + + +# Tokens + +func test_request_sends_token_in_authorization_header() -> void: + _server.respond_json("/music/info", {}) + + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass) + + assert_int(_server.token_requests).is_equal(1) + assert_array(_server.requested_headers[0]) \ + .contains_exactly(["Authorization: Bearer fake-token-1"]) + + +func test_token_request_sends_password_and_client_type() -> void: + var previous_password: String = Settings.api_password + Settings.api_password = "secret" + + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass) + + Settings.api_password = previous_password + assert_dict(_server.last_token_request) \ + .is_equal({"password": "secret", "client_type": "godot"}) + + +func test_token_is_reused() -> void: + _server.respond_json("/music/info", {}) + _server.respond_json("/music/list", []) + + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass) + MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: pass) + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass) + + assert_int(_server.token_requests).is_equal(1) + assert_array(_server.requested_paths) \ + .contains_exactly(["/music/info", "/music/list", "/music/info"]) + + +func test_requests_while_getting_a_token_share_it() -> void: + _server.respond_json("/music/info", {}) + _server.respond_json("/music/list", []) + _server.hold("/token") + var done: Array[String] = [] + + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: done.append("info")) + MusicServerClient.fetch_song_list(func(_data: Array[SongInfo]) -> void: done.append("list")) + assert_array(_server.requested_paths).is_empty() + _server.release("/token") + + assert_int(_server.token_requests).is_equal(1) + assert_array(done).contains_exactly(["info", "list"]) + + +func test_expired_token_is_renewed_and_request_sent_again() -> void: + _server.respond_json("/music/info", {"SongNo": 3}) + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass) + _server.expire_tokens() + var received: Array[SongInfo] = [] + + MusicServerClient.fetch_song_info(func(info: SongInfo) -> void: received.append(info)) + + assert_int(_server.token_requests).is_equal(2) + assert_array(_server.requested_paths) \ + .contains_exactly(["/music/info", "/music/info", "/music/info"]) + assert_array(_server.requested_headers[2]) \ + .contains_exactly(["Authorization: Bearer fake-token-2"]) + assert_int(received.size()).is_equal(1) + assert_int(received[0].song_no).is_equal(3) + + +func test_request_is_sent_again_only_once() -> void: + _server.respond_json("/music/info", {"error": "Unauthorized"}, 401) + var outcome: Array[String] = [] + + MusicServerClient.fetch_song_info( + func(_data: SongInfo) -> void: outcome.append("done"), + func() -> void: outcome.append("failed")) + + assert_int(_server.token_requests).is_equal(2) + assert_int(_server.count("/music/info")).is_equal(2) + assert_array(outcome).contains_exactly(["failed"]) + + +func test_refused_password_calls_on_failed_without_sending_request() -> void: + _server.respond_json("/token", {"error": "Invalid password"}, 401) + var outcome: Array[String] = [] + + MusicServerClient.fetch_song_info( + func(_data: SongInfo) -> void: outcome.append("done"), + func() -> void: outcome.append("failed")) + + assert_array(_server.requested_paths).is_empty() + assert_array(outcome).contains_exactly(["failed"]) + + +func test_rate_limited_token_request_calls_on_failed() -> void: + _server.respond_json("/token", {"error": "Too many requests"}, 429) + var outcome: Array[String] = [] + + MusicServerClient.add_to_queue( + func() -> void: outcome.append("done"), + func() -> void: outcome.append("failed")) + + assert_array(outcome).contains_exactly(["failed"]) + + +func test_new_token_after_a_failed_token_request() -> void: + _server.respond_json("/token", {"error": "Too many requests"}, 429) + MusicServerClient.add_to_queue(func() -> void: pass) + _server.respond_json("/token", {"token": "second", "expires_at": "", "client_type": "godot"}) + _server.respond_bytes("/music/addQue", PackedByteArray()) + _server.expire_tokens() + + MusicServerClient.add_to_queue(func() -> void: pass) + + assert_int(_server.token_requests).is_equal(2) + assert_array(_server.requested_headers.back()) \ + .contains_exactly(["Authorization: Bearer second"]) + + +func test_changing_server_gets_a_new_token() -> void: + var previous_path: String = Settings.default_path + _server.respond_json("/music/info", {}) + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass) + + Settings.default_path = "http://localhost:8080" + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass) + Settings.default_path = previous_path + + assert_int(_server.token_requests).is_equal(2) + + +func test_health_needs_no_token() -> void: + _server.respond_json("/health", {"status": "ok"}) + var received: Array = [] + + MusicServerClient.fetch_health(func(data: Dictionary) -> void: received.append(data)) + + assert_int(_server.token_requests).is_equal(0) + assert_array(_server.requested_headers[0]).is_empty() + assert_array(received).contains_exactly([{"status": "ok"}]) + + +func test_delete_token_sends_token_and_forgets_it() -> void: + _server.respond_json("/music/info", {}) + MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass) + _server.respond_json("/token", {"status": "deleted"}) + var called: Array[bool] = [false] + + MusicServerClient.delete_token(func() -> void: called[0] = true) + + assert_bool(called[0]).is_true() + assert_int(_server.requested_methods.back()).is_equal(HTTPClient.METHOD_DELETE) + assert_array(_server.requested_headers.back()) \ + .contains_exactly(["Authorization: Bearer fake-token-1"]) + + +func test_delete_token_without_token_sends_nothing() -> void: + var called: Array[bool] = [false] + + MusicServerClient.delete_token(func() -> void: called[0] = true) + + assert_bool(called[0]).is_true() + assert_array(_server.requested_paths).is_empty() + + +# Other endpoints + +func test_endpoint_paths_and_methods() -> void: + var noop: Callable = func(_data: Variant = null) -> void: pass + var calls: Array[Array] = [ + [func() -> void: MusicServerClient.fetch_random_song_classic(noop), + "/music/rand/classic", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_next_song(noop), + "/music/next", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_previous_song(noop), + "/music/previous", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_sound_test_song(noop), + "/music/soundTest", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.mark_current_song_played(noop), + "/music/addPlayed", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_all_soundtracks(noop), + "/music/all", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_all_soundtracks_ordered(noop), + "/music/all/order", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_soundtracks(noop), + "/soundtracks", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_soundtrack("a b", noop), + "/soundtracks/a%20b", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_song_by_uuid("so-1", noop), + "/songs/so-1", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_song_file("so-1", noop), + "/songs/so-1/file", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_character_list(noop), + "/character/list", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_sync_progress(noop), + "/sync/progress", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_version(noop), + "/version", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_version_history(noop), + "/version/history", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_latest_client_version(noop), + "/download", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_client_assets(noop), + "/download/list", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.download_client_windows(noop), + "/download/windows", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.download_client_linux(noop), + "/download/linux", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.cleanup_tokens(noop), + "/token/cleanup", HTTPClient.METHOD_POST], + [func() -> void: MusicServerClient.fetch_statistics_summary(noop), + "/statistics/summary", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_most_played_soundtracks(5, noop), + "/statistics/soundtracks/most-played?limit=5", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_least_played_soundtracks(5, noop), + "/statistics/soundtracks/least-played?limit=5", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_last_played_soundtracks(5, noop), + "/statistics/soundtracks/last-played?limit=5", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_oldest_played_soundtracks(5, noop), + "/statistics/soundtracks/oldest-played?limit=5", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_never_played_soundtracks(noop), + "/statistics/soundtracks/never-played", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_most_played_songs(5, noop), + "/statistics/songs/most-played?limit=5", HTTPClient.METHOD_GET], + [func() -> void: MusicServerClient.fetch_least_played_songs(5, noop), + "/statistics/songs/least-played?limit=5", HTTPClient.METHOD_GET], + ] + for entry: Array in calls: + _server.requested_paths.clear() + _server.requested_methods.clear() + entry[0].call() + assert_array(_server.requested_paths).contains_exactly([entry[1]]) + assert_array(_server.requested_methods).contains_exactly([entry[2]]) + + +func test_statistics_summary_is_typed() -> void: + _server.respond_json("/statistics/summary", {"total_soundtracks": 120, + "played_soundtracks": 100, "avg_soundtrack_plays": 2.5}) + var received: Array[StatisticsSummary] = [] + + MusicServerClient.fetch_statistics_summary( + func(summary: StatisticsSummary) -> void: received.append(summary)) + + assert_int(received[0].total_soundtracks).is_equal(120) + assert_int(received[0].played_soundtracks).is_equal(100) + assert_float(received[0].avg_soundtrack_plays).is_equal(2.5) + + +func test_most_played_soundtracks_are_typed_with_their_songs() -> void: + _server.respond_json("/statistics/soundtracks/most-played?limit=2", [ + {"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", + "soundtrack_played": 9, "songs": [{"song_name": "Frog's Theme"}]}, + {"soundtrack_uuid": "st-2", "soundtrack_name": "Lufia II", "soundtrack_played": 4, + "songs": []}, + ]) + var received: Array[SoundtrackWithSongs] = [] + + MusicServerClient.fetch_most_played_soundtracks(2, + func(list: Array[SoundtrackWithSongs]) -> void: received.append_array(list)) + + assert_int(received.size()).is_equal(2) + assert_str(received[0].soundtrack_name).is_equal("Chrono Trigger") + assert_int(received[0].soundtrack_played).is_equal(9) + assert_str(received[0].songs[0].song_name).is_equal("Frog's Theme") + assert_array(received[1].songs).is_empty() + + +func test_most_played_songs_are_typed() -> void: + _server.respond_json("/statistics/songs/most-played?limit=1", [ + {"song_uuid": "so-1", "song_name": "Dancing Mad", "times_played": 12}, + ]) + var received: Array[SongInfoForStats] = [] + + MusicServerClient.fetch_most_played_songs(1, + func(list: Array[SongInfoForStats]) -> void: received.append_array(list)) + + assert_str(received[0].song_name).is_equal("Dancing Mad") + assert_int(received[0].times_played).is_equal(12) + + +func test_statistics_calls_on_failed_on_server_error() -> void: + var outcome: Array[String] = [] + var on_done: Callable = func(_data: Variant) -> void: outcome.append("done") + var on_failed: Callable = func() -> void: outcome.append("failed") + for path: String in ["/statistics/summary", + "/statistics/soundtracks/most-played?limit=3", + "/statistics/soundtracks/least-played?limit=3", + "/statistics/soundtracks/last-played?limit=3", + "/statistics/soundtracks/oldest-played?limit=3", + "/statistics/soundtracks/never-played", + "/statistics/songs/most-played?limit=3", + "/statistics/songs/least-played?limit=3"]: + _server.respond_json(path, {"error": "Internal Server Error"}, 500) + + _call_every_statistics_endpoint(on_done, on_failed) + + assert_array(outcome).contains_exactly(["failed", "failed", "failed", "failed", "failed", + "failed", "failed", "failed"]) + + +func test_statistics_calls_on_failed_when_token_is_refused() -> void: + _server.respond_json("/token", {"error": "Invalid password"}, 401) + var outcome: Array[String] = [] + + _call_every_statistics_endpoint(func(_data: Variant) -> void: outcome.append("done"), + func() -> void: outcome.append("failed")) + + assert_array(_server.requested_paths).is_empty() + assert_array(outcome).contains_exactly(["failed", "failed", "failed", "failed", "failed", + "failed", "failed", "failed"]) + + +func _call_every_statistics_endpoint(on_done: Callable, on_failed: Callable) -> void: + MusicServerClient.fetch_statistics_summary(on_done, on_failed) + MusicServerClient.fetch_most_played_soundtracks(3, on_done, on_failed) + MusicServerClient.fetch_least_played_soundtracks(3, on_done, on_failed) + MusicServerClient.fetch_last_played_soundtracks(3, on_done, on_failed) + MusicServerClient.fetch_oldest_played_soundtracks(3, on_done, on_failed) + MusicServerClient.fetch_never_played_soundtracks(on_done, on_failed) + MusicServerClient.fetch_most_played_songs(3, on_done, on_failed) + MusicServerClient.fetch_least_played_songs(3, on_done, on_failed) class _RecordingTransport: @@ -144,5 +532,13 @@ class _RecordingTransport: super(null) _urls = urls - func request(url: String, _method: int, _on_complete: Callable) -> void: + func request( + url: String, + _method: int, + on_complete: Callable, + _headers: PackedStringArray = PackedStringArray(), + _body: String = "", + ) -> void: _urls.append(url) + if url.ends_with("/token"): + on_complete.call(200, JSON.stringify({"token": "t"}).to_utf8_buffer()) diff --git a/tools/generate_api_types.gd b/tools/generate_api_types.gd new file mode 100644 index 0000000..24a25f6 --- /dev/null +++ b/tools/generate_api_types.gd @@ -0,0 +1,262 @@ +extends SceneTree +## Generates a typed class for every schema in the MusicServer OpenAPI spec. +## +## Usage: +## godot --headless --path . -s tools/generate_api_types.gd [-- ] +## +## [code][/code] defaults to [code]server/openapi.json[/code]. To update it, run +## [code]just openapi[/code] in MusicServer, or save [code]/openapi.json[/code] +## from a running server. Each schema becomes [code]server/generated/.gd[/code] +## with typed properties, [code]from_dict()[/code] and [code]to_dict()[/code]. Files +## for schemas that no longer exist are removed. The output is sorted, so running it +## again without API changes gives no diff. + +const DEFAULT_SPEC_PATH: String = "res://server/openapi.json" +const OUTPUT_DIR: String = "res://server/generated" +const HELPER_CLASS: String = "ApiJson" +const MAX_LINE_LENGTH: int = 100 +const HEADER: String = "# Generated by tools/generate_api_types.gd from server/openapi.json.\n" \ + + "# Do not edit; change the server and generate again.\n" + + +func _initialize() -> void: + var args: PackedStringArray = OS.get_cmdline_user_args() + var spec_path: String = args[0] if !args.is_empty() else DEFAULT_SPEC_PATH + var spec: Variant = JSON.parse_string(FileAccess.get_file_as_string(spec_path)) + if !(spec is Dictionary) || !spec.has("components"): + push_error("Could not read an OpenAPI spec from " + spec_path) + quit(1) + return + var schemas: Dictionary = spec.components.get("schemas", {}) + DirAccess.make_dir_recursive_absolute(OUTPUT_DIR) + + var written: Array[String] = [_file_name(HELPER_CLASS)] + _write(_file_name(HELPER_CLASS), _helper_source()) + var schema_names: Array = schemas.keys() + schema_names.sort() + for schema_name: String in schema_names: + var file_name: String = _file_name(schema_name) + _write(file_name, _class_source(schema_name, schemas[schema_name])) + written.append(file_name) + _remove_stale_files(written) + print("Wrote %d classes to %s" % [written.size(), OUTPUT_DIR]) + quit() + + +func _class_source(schema_name: String, schema: Dictionary) -> String: + var class_id: String = _class_name(schema_name) + var properties: Dictionary = schema.get("properties", {}) + var keys: Array = properties.keys() + keys.sort() + + var lines: PackedStringArray = [ + HEADER + "class_name " + class_id, + "extends RefCounted", + "## The [code]%s[/code] schema of the MusicServer API." % schema_name, + "", + ] + for key: String in keys: + var property: Dictionary = properties[key] + if property.has("description"): + lines.append("## " + property.description) + lines.append("var %s: %s%s" % [_property_name(key), _gd_type(property), + _initial_value(property)]) + + lines.append_array([ + "", + "", + "## Reads the fields from parsed JSON. Missing fields keep their defaults.", + "static func from_dict(data: Dictionary) -> %s:" % class_id, + "\tvar result: %s = %s.new()" % [class_id, class_id], + ]) + for key: String in keys: + lines.append_array(_read_lines(key, properties[key])) + lines.append("\treturn result") + + lines.append_array([ + "", + "", + "## Returns the fields under their JSON names, ready for [method JSON.stringify].", + "func to_dict() -> Dictionary:", + ]) + if keys.is_empty(): + lines.append("\treturn {}") + else: + lines.append("\treturn {") + for key: String in keys: + lines.append("\t\t\"%s\": %s," % [key, _write_value(key, properties[key])]) + lines.append("\t}") + return "\n".join(lines) + "\n" + + +func _read_lines(key: String, property: Dictionary) -> PackedStringArray: + var target: String = "result." + _property_name(key) + var quoted_key: String = "\"%s\"" % key + if property.has("$ref"): + return ["\t%s = %s.from_dict(%s.get_dictionary(data, %s))" + % [target, _ref_class(property), HELPER_CLASS, quoted_key]] + match property.get("type"): + "array": + var items: Dictionary = property.get("items", {}) + if items.has("$ref"): + return [ + "\tfor item: Variant in %s.get_array(data, %s):" % [HELPER_CLASS, quoted_key], + "\t\tif item is Dictionary:", + "\t\t\t%s.append(%s.from_dict(item))" % [target, _ref_class(items)], + ] + return ["\t%s.assign(%s.get_array(data, %s))" % [target, HELPER_CLASS, quoted_key]] + "object": + return ["\t%s.assign(%s.get_dictionary(data, %s))" + % [target, HELPER_CLASS, quoted_key]] + "integer": + return ["\t%s = %s.get_int(data, %s, %s)" + % [target, HELPER_CLASS, quoted_key, _default(property)]] + "number": + return ["\t%s = %s.get_float(data, %s, %s)" + % [target, HELPER_CLASS, quoted_key, _default(property)]] + "boolean": + return ["\t%s = %s.get_bool(data, %s, %s)" + % [target, HELPER_CLASS, quoted_key, _default(property)]] + _: + return ["\t%s = %s.get_string(data, %s, %s)" + % [target, HELPER_CLASS, quoted_key, _default(property)]] + + +func _write_value(key: String, property: Dictionary) -> String: + var value: String = _property_name(key) + if property.has("$ref"): + return value + ".to_dict()" + if property.get("type") == "array" && property.get("items", {}).has("$ref"): + return "%s.map(func(item: %s) -> Dictionary: return item.to_dict())" \ + % [value, _ref_class(property.items)] + return value + + +func _gd_type(property: Dictionary) -> String: + if property.has("$ref"): + return _ref_class(property) + match property.get("type"): + "array": + return "Array[%s]" % _gd_type(property.get("items", {})) + "object": + var values: Variant = property.get("additionalProperties") + if values is Dictionary && !values.is_empty(): + return "Dictionary[String, %s]" % _gd_type(values) + return "Dictionary" + "integer": + return "int" + "number": + return "float" + "boolean": + return "bool" + "string": + return "String" + return "Variant" + + +func _initial_value(property: Dictionary) -> String: + if property.has("$ref"): + return " = %s.new()" % _ref_class(property) + if property.has("default"): + return " = " + _default(property) + return "" + + +# The default value as GDScript source +func _default(property: Dictionary) -> String: + var type: String = _gd_type(property) + if property.has("default"): + var value: Variant = property.default + if type == "int": + return str(int(value)) + return JSON.stringify(value) + match type: + "int": + return "0" + "float": + return "0.0" + "bool": + return "false" + return "\"\"" + + +func _ref_class(property: Dictionary) -> String: + return _class_name(String(property["$ref"]).get_file()) + + +func _class_name(schema_name: String) -> String: + return schema_name.to_snake_case().to_pascal_case() + + +func _property_name(key: String) -> String: + return key.to_snake_case() + + +func _file_name(schema_name: String) -> String: + return schema_name.to_snake_case() + ".gd" + + +func _helper_source() -> String: + return HEADER + """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 {} +""" + + +func _write(file_name: String, source: String) -> void: + var lines: PackedStringArray = [] + for line: String in source.split("\n"): + lines.append(_wrap(line)) + var file: FileAccess = FileAccess.open(OUTPUT_DIR.path_join(file_name), FileAccess.WRITE) + file.store_string("\n".join(lines)) + file.close() + + +# Breaks a statement longer than the style guide's 100 characters after its first +# opening parenthesis, with the continuation two levels deeper +func _wrap(line: String) -> String: + var indent: int = line.length() - line.lstrip("\t").length() + var split_at: int = line.find("(") + if line.replace("\t", " ").length() <= MAX_LINE_LENGTH || split_at < 0 \ + || line.strip_edges().begins_with("#"): + return line + return line.substr(0, split_at + 1) + "\n" + "\t".repeat(indent + 2) \ + + line.substr(split_at + 1) + + +func _remove_stale_files(written: Array[String]) -> void: + for file_name: String in DirAccess.get_files_at(OUTPUT_DIR): + var script_name: String = file_name.trim_suffix(".uid") + if script_name.ends_with(".gd") && !written.has(script_name): + DirAccess.remove_absolute(OUTPUT_DIR.path_join(file_name)) diff --git a/tools/generate_api_types.gd.uid b/tools/generate_api_types.gd.uid new file mode 100644 index 0000000..c3eee3e --- /dev/null +++ b/tools/generate_api_types.gd.uid @@ -0,0 +1 @@ +uid://cummnobkmb3le