2 Commits
Author SHA1 Message Date
Sansan a21b6e9b22 Add a Statistics tab with a card for every soundtrack
Build / test (push) Successful in 40s
Build / build (push) Successful in 1m16s
Part of #41. The top bar has Quiz and Statistics tabs instead of the
Statistics button. The Statistics tab replaces the quiz below the top bar
(the song keeps playing) and shows every soundtrack as a card with its
plays, last played date and most and least played songs, loaded from the
new GET /api/v1/statistics/soundtracks. The cards can be searched (with
the Search popup's matching, now in SoundtrackSearch), filtered on played
or never played, and sorted A-Z, Z-A, most or least played. Clicking a
card opens a popup with all its songs.

Screenshots of the other states only differ in the top bar, and the
layout baseline is taken again with the new statistics states.
2026-10-06 23:33:00 +02:00
Sansan f7619baf34 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.
2026-10-06 22:45:28 +02:00
69 changed files with 30537 additions and 2374 deletions

No files matched your search

+23 -3
View File
@@ -26,8 +26,8 @@ pass.
| Folder | Contents | | Folder | Contents |
|---|---| |---|---|
| `tests/unit/` | Logic without scenes: playlist, songs, player scoring, time formatting, search, the server client | | `tests/unit/` | Logic without scenes: playlist, songs, player scoring, time formatting, search, the server client and the API classes |
| `tests/integration/` | Real scenes (`MainWindow.tscn`, `SyncPopup.tscn`) run against a fake server, including the point keybinds | | `tests/integration/` | Real scenes (`MainWindow.tscn`, `SyncPopup.tscn`) run against a fake server, including the point keybinds and the Statistics tab |
| `tests/fakes/` | `FakeHttpTransport`, `AutoloadSnapshot` and the `MainWindowTestSuite` base class | | `tests/fakes/` | `FakeHttpTransport`, `AutoloadSnapshot` and the `MainWindowTestSuite` base class |
| `tests/data/` | Files the fake server sends, e.g. a short MP3 | | `tests/data/` | Files the fake server sends, e.g. a short MP3 |
@@ -48,7 +48,27 @@ assert_int(server.count("/music/rand")).is_equal(1)
server.uninstall() 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 `<server>/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 `<server>/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 ## Writing tests
+68
View File
@@ -0,0 +1,68 @@
class_name SoundtrackSearch
extends RefCounted
## Finds soundtrack names matching a search text. Matches ignore case, spaces and
## special characters, and also find the letters in order with others in between.
##
## Used by the Search popup and the statistics view, so both find the same soundtracks.
## Each name as a Dictionary with its original, lowercase and cleaned form.
var _names: Array[Dictionary] = []
## Compiled patterns for the letters-in-order search, by cleaned search term.
var _regex_cache: Dictionary = {}
## Replaces the names to search in.
func set_names(names: Array) -> void:
_names.clear()
for soundtrack: String in names:
_names.append({
"original": soundtrack,
"lowercase": soundtrack.to_lower(),
"cleaned": clean_term(soundtrack),
})
## Returns the names matching [param search_text], best matches first: plain
## case-insensitive matches, then matches ignoring spaces and special characters,
## then matches with the letters in order but not next to each other.
func find_matches(search_text: String) -> Array[String]:
var seen: Dictionary = {}
var search_text_lower: String = search_text.to_lower()
var clean_search_text: String = clean_term(search_text)
var regex: RegEx = _compile_regex_cached(clean_search_text)
var passes: Array[Callable] = [
func(entry: Dictionary) -> bool: return entry.lowercase.contains(search_text_lower),
func(entry: Dictionary) -> bool: return entry.cleaned.contains(clean_search_text),
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null,
]
var matches: Array[String] = []
for matches_pass: Callable in passes:
for entry: Dictionary in _names:
if !seen.has(entry.original) && matches_pass.call(entry):
seen[entry.original] = true
matches.append(entry.original)
return matches
## Cleans a term by removing spaces and replacing special characters.
## Matches MusicServer's clean_term() function.
static func clean_term(term: String) -> String:
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and") \
.replace("'n", "and").to_lower()
# Compiles the letters-in-order pattern for [param search_term], once per term
func _compile_regex_cached(search_term: String) -> RegEx:
if not search_term in _regex_cache:
var regex: RegEx = RegEx.new()
var regex_text: String = ".*"
for letter: String in search_term:
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
if "\\^$.|?*+()[]{}".contains(letter):
regex_text += "\\"
regex_text += letter + ".*"
regex.compile(regex_text)
_regex_cache[search_term] = regex
return _regex_cache[search_term]
+1
View File
@@ -0,0 +1 @@
uid://df8ox6xddobu2
+3
View File
@@ -6,6 +6,9 @@ extends Node
const MAX_NUMBER_OF_PLAYERS: int = 7 const MAX_NUMBER_OF_PLAYERS: int = 7
var default_path: String = "https://music.sanplex.xyz" 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 selected_server: int = 0
var is_local: bool = false var is_local: bool = false
var is_debug: bool = false var is_debug: bool = false
+14 -23
View File
@@ -9,6 +9,8 @@
[ext_resource type="PackedScene" uid="uid://btupbowehiyyu" path="res://popups/QRPopup.tscn" id="15_qr_window"] [ext_resource type="PackedScene" uid="uid://btupbowehiyyu" path="res://popups/QRPopup.tscn" id="15_qr_window"]
[ext_resource type="PackedScene" uid="uid://cp54idaudb8dp" path="res://popups/AboutPopup.tscn" id="16_about_window"] [ext_resource type="PackedScene" uid="uid://cp54idaudb8dp" path="res://popups/AboutPopup.tscn" id="16_about_window"]
[ext_resource type="PackedScene" uid="uid://bijh5h5yrivm3" path="res://popups/LogPopup.tscn" id="14_26rwn"] [ext_resource type="PackedScene" uid="uid://bijh5h5yrivm3" path="res://popups/LogPopup.tscn" id="14_26rwn"]
[ext_resource type="PackedScene" path="res://statistics/StatisticsView.tscn" id="statistics_view_scene"]
[ext_resource type="PackedScene" path="res://popups/SoundtrackStatsPopup.tscn" id="soundtrack_stats_popup_scene"]
[node name="MainWindow" type="Control" unique_id=407149696] [node name="MainWindow" type="Control" unique_id=407149696]
layout_mode = 3 layout_mode = 3
@@ -33,6 +35,10 @@ theme_type_variation = &"WindowBackground"
[node name="Quiz" parent="." unique_id=1357908642 instance=ExtResource("quiz_scene")] [node name="Quiz" parent="." unique_id=1357908642 instance=ExtResource("quiz_scene")]
[node name="StatisticsView" parent="." unique_id=1212999001 instance=ExtResource("statistics_view_scene")]
visible = false
layout_mode = 1
[node name="SearchPopup" parent="." unique_id=1661250375 instance=ExtResource("9_5ijvr")] [node name="SearchPopup" parent="." unique_id=1661250375 instance=ExtResource("9_5ijvr")]
visible = false visible = false
layout_mode = 1 layout_mode = 1
@@ -68,29 +74,6 @@ offset_top = 160.0
offset_right = 1262.0 offset_right = 1262.0
offset_bottom = 860.0 offset_bottom = 860.0
[node name="StatisticsPopupPanel" type="PopupPanel" parent="." unique_id=298311407]
oversampling_override = 1.0
initial_position = 2
size = Vector2i(450, 100)
[node name="Label" type="Label" parent="StatisticsPopupPanel" unique_id=548316493]
offset_left = 4.0
offset_top = 4.0
offset_right = 446.0
offset_bottom = 96.0
text = "Statistics"
horizontal_alignment = 1
[node name="StatisticsLabel" type="Label" parent="StatisticsPopupPanel" unique_id=58226477]
unique_name_in_owner = true
offset_left = 4.0
offset_top = 4.0
offset_right = 446.0
offset_bottom = 96.0
text = "Total amount of soundtracks in the playlist: 9000"
horizontal_alignment = 1
vertical_alignment = 1
[node name="AboutPopup" parent="." unique_id=141261008 instance=ExtResource("16_about_window")] [node name="AboutPopup" parent="." unique_id=141261008 instance=ExtResource("16_about_window")]
unique_name_in_owner = true unique_name_in_owner = true
visible = false visible = false
@@ -108,3 +91,11 @@ offset_left = 562.0
offset_top = 132.0 offset_top = 132.0
offset_right = 1262.0 offset_right = 1262.0
offset_bottom = 832.0 offset_bottom = 832.0
[node name="SoundtrackStatsPopup" parent="." unique_id=1213999001 instance=ExtResource("soundtrack_stats_popup_scene")]
visible = false
layout_mode = 1
offset_left = 610.0
offset_top = 140.0
offset_right = 610.0
offset_bottom = 140.0
+29 -10
View File
@@ -10,6 +10,8 @@ keycode = 83
[sub_resource type="Shortcut" id="Shortcut_fbju4"] [sub_resource type="Shortcut" id="Shortcut_fbju4"]
events = [SubResource("InputEventKey_ujjlu")] events = [SubResource("InputEventKey_ujjlu")]
[sub_resource type="ButtonGroup" id="ButtonGroup_tabs"]
[node name="TopBar" type="Control" unique_id=1489162753] [node name="TopBar" type="Control" unique_id=1489162753]
layout_mode = 3 layout_mode = 3
anchors_preset = 10 anchors_preset = 10
@@ -55,16 +57,6 @@ action_mode = 0
shortcut = SubResource("Shortcut_fbju4") shortcut = SubResource("Shortcut_fbju4")
text = "Search" text = "Search"
[node name="StatisticsButton" type="Button" parent="." unique_id=2045021269]
layout_mode = 0
offset_left = 1530.0
offset_top = 8.0
offset_right = 1600.0
offset_bottom = 48.0
focus_mode = 0
action_mode = 0
text = "Statistics"
[node name="ShortcutsButton" type="Button" parent="." unique_id=2066498226] [node name="ShortcutsButton" type="Button" parent="." unique_id=2066498226]
layout_mode = 0 layout_mode = 0
offset_left = 1626.0 offset_left = 1626.0
@@ -94,3 +86,30 @@ offset_bottom = 48.0
focus_mode = 0 focus_mode = 0
action_mode = 0 action_mode = 0
text = "QR" text = "QR"
[node name="QuizTab" type="Button" parent="." unique_id=1903412671]
layout_mode = 0
offset_left = 840.0
offset_top = 8.0
offset_right = 955.0
offset_bottom = 48.0
focus_mode = 0
theme_type_variation = &"TabButton"
toggle_mode = true
button_pressed = true
action_mode = 0
button_group = SubResource("ButtonGroup_tabs")
text = "Quiz"
[node name="StatisticsTab" type="Button" parent="." unique_id=1903412672]
layout_mode = 0
offset_left = 965.0
offset_top = 8.0
offset_right = 1080.0
offset_bottom = 48.0
focus_mode = 0
theme_type_variation = &"TabButton"
toggle_mode = true
action_mode = 0
button_group = SubResource("ButtonGroup_tabs")
text = "Statistics"
+20 -12
View File
@@ -1,9 +1,9 @@
class_name MainWindow class_name MainWindow
extends Control extends Control
## The main window: the top bar, the popups opened from it and the quiz below it. ## The main window: the top bar, the popups opened from it, and below it the quiz or the
## statistics, depending on the chosen tab.
## ##
## Everything that is not part of a quiz lives here, so other views can be added next ## Everything that is not part of a quiz lives here.
## to the quiz later.
## Dims the window behind an open popup and keeps clicks from reaching the buttons under it. ## Dims the window behind an open popup and keeps clicks from reaching the buttons under it.
var popup_backdrop: PopupBackdrop var popup_backdrop: PopupBackdrop
@@ -16,8 +16,8 @@ var popup_backdrop: PopupBackdrop
@onready var qr_popup: QRPopup = %QRPopup @onready var qr_popup: QRPopup = %QRPopup
@onready var about_popup: AboutPopup = %AboutPopup @onready var about_popup: AboutPopup = %AboutPopup
@onready var log_popup: LogPopup = %LogPopup @onready var log_popup: LogPopup = %LogPopup
@onready var statistic_popup: PopupPanel = $StatisticsPopupPanel @onready var statistics_view: StatisticsView = $StatisticsView
@onready var statistic_label: Label = %StatisticsLabel @onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup
func _ready() -> void: func _ready() -> void:
@@ -26,7 +26,7 @@ func _ready() -> void:
GameLog.debug("is_mac") GameLog.debug("is_mac")
top_bar.search_pressed.connect(show_search) top_bar.search_pressed.connect(show_search)
top_bar.sync_pressed.connect(sync_soundtracks) top_bar.sync_pressed.connect(sync_soundtracks)
top_bar.statistics_pressed.connect(get_statistics) top_bar.tab_selected.connect(show_tab)
top_bar.shortcuts_pressed.connect(show_shortcuts) top_bar.shortcuts_pressed.connect(show_shortcuts)
top_bar.about_pressed.connect(show_about) top_bar.about_pressed.connect(show_about)
top_bar.qr_pressed.connect(show_qr) top_bar.qr_pressed.connect(show_qr)
@@ -35,6 +35,7 @@ func _ready() -> void:
sync_popup.sync_finished.connect(_on_sync_finished) sync_popup.sync_finished.connect(_on_sync_finished)
quiz.server_changed.connect(search_popup.get_list_of_soundtracks) quiz.server_changed.connect(search_popup.get_list_of_soundtracks)
quiz.event_logged.connect(log_popup.add_log_row) quiz.event_logged.connect(log_popup.add_log_row)
statistics_view.soundtrack_selected.connect(soundtrack_stats_popup.show_soundtrack)
search_popup.get_list_of_soundtracks() search_popup.get_list_of_soundtracks()
@@ -55,7 +56,8 @@ func _input(event: InputEvent) -> void:
func _popups() -> Array[BasePopup]: func _popups() -> Array[BasePopup]:
return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup] return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup,
soundtrack_stats_popup]
func show_search() -> void: func show_search() -> void:
@@ -78,11 +80,15 @@ func show_qr() -> void:
qr_popup.open() qr_popup.open()
func get_statistics() -> void: ## Shows the quiz for [code]&"quiz"[/code] or the statistics for [code]&"statistics"[/code].
GameLog.debug("get_statistics") ## A hidden quiz keeps playing its song.
statistic_popup.visible = true func show_tab(tab: StringName) -> void:
statistic_label.text = "Total amount of soundtracks in the playlist: " \ GameLog.debug("show_tab ", tab)
+ str(search_popup.soundtracks.size()) top_bar.select_tab(tab)
statistics_view.visible = tab == &"statistics"
quiz.visible = !statistics_view.visible
if statistics_view.visible:
statistics_view.refresh()
func sync_soundtracks() -> void: func sync_soundtracks() -> void:
@@ -100,3 +106,5 @@ func _on_sync_finished() -> void:
GameLog.debug("_on_sync_finished") GameLog.debug("_on_sync_finished")
search_popup.get_list_of_soundtracks() search_popup.get_list_of_soundtracks()
quiz.on_sync_finished() quiz.on_sync_finished()
if statistics_view.visible:
statistics_view.refresh()
+14 -4
View File
@@ -1,22 +1,32 @@
class_name TopBar class_name TopBar
extends Control extends Control
## The bar along the top of the main window, with the title and the buttons for things ## The bar along the top of the main window, with the title, the Quiz and Statistics tabs
## that are not part of a quiz. ## and the buttons for things that are not part of a quiz.
signal search_pressed signal search_pressed
signal sync_pressed signal sync_pressed
signal statistics_pressed
signal shortcuts_pressed signal shortcuts_pressed
signal about_pressed signal about_pressed
signal qr_pressed signal qr_pressed
## Emitted when a tab is chosen: [code]&"quiz"[/code] or [code]&"statistics"[/code].
signal tab_selected(tab: StringName)
@onready var sync_button: Button = $SyncButton @onready var sync_button: Button = $SyncButton
@onready var quiz_tab: Button = $QuizTab
@onready var statistics_tab: Button = $StatisticsTab
func _ready() -> void: func _ready() -> void:
$SearchButton.pressed.connect(search_pressed.emit) $SearchButton.pressed.connect(search_pressed.emit)
sync_button.pressed.connect(sync_pressed.emit) sync_button.pressed.connect(sync_pressed.emit)
$StatisticsButton.pressed.connect(statistics_pressed.emit)
$ShortcutsButton.pressed.connect(shortcuts_pressed.emit) $ShortcutsButton.pressed.connect(shortcuts_pressed.emit)
$AboutButton.pressed.connect(about_pressed.emit) $AboutButton.pressed.connect(about_pressed.emit)
$QRButton.pressed.connect(qr_pressed.emit) $QRButton.pressed.connect(qr_pressed.emit)
quiz_tab.pressed.connect(tab_selected.emit.bind(&"quiz"))
statistics_tab.pressed.connect(tab_selected.emit.bind(&"statistics"))
## Shows [param tab] as the chosen tab, without emitting [signal tab_selected].
func select_tab(tab: StringName) -> void:
quiz_tab.set_pressed_no_signal(tab == &"quiz")
statistics_tab.set_pressed_no_signal(tab == &"statistics")
+53
View File
@@ -0,0 +1,53 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://popups/soundtrack_stats_popup.gd" id="popup_script"]
[node name="SoundtrackStatsPopup" type="Control" unique_id=1213000001 node_paths=PackedStringArray("panel")]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("popup_script")
panel = NodePath("StatsPanel")
[node name="StatsPanel" type="Panel" parent="." unique_id=1213000002]
unique_name_in_owner = true
layout_mode = 0
offset_right = 700.0
offset_bottom = 800.0
theme_type_variation = &"PopupBackground"
[node name="TitleLabel" type="Label" parent="StatsPanel" unique_id=1213000003]
unique_name_in_owner = true
layout_mode = 0
offset_left = 20.0
offset_top = 12.0
offset_right = 680.0
offset_bottom = 50.0
theme_type_variation = &"Title"
horizontal_alignment = 1
text_overrun_behavior = 3
[node name="SummaryLabel" type="Label" parent="StatsPanel" unique_id=1213000004]
unique_name_in_owner = true
layout_mode = 0
offset_left = 20.0
offset_top = 54.0
offset_right = 680.0
offset_bottom = 80.0
horizontal_alignment = 1
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
layout_mode = 0
offset_left = 20.0
offset_top = 92.0
offset_right = 680.0
offset_bottom = 784.0
horizontal_scroll_mode = 0
[node name="SongList" type="VBoxContainer" parent="StatsPanel/ScrollContainer" unique_id=1213000006]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
+6 -78
View File
@@ -1,18 +1,12 @@
class_name SearchPopup class_name SearchPopup
extends BasePopup extends BasePopup
## Searches every soundtrack on the server as you type. Matches ignore case, spaces and ## Searches every soundtrack on the server as you type, with [SoundtrackSearch].
## special characters, and also find the letters in order with others in between.
var soundtracks: Array = [] var soundtracks: Array = []
## Each soundtrack as a Dictionary with its original, lowercase and cleaned name.
var precomputed_soundtracks: Array = []
## Compiled patterns for the letters-in-order search, by cleaned search term.
var regex_cache: Dictionary = {}
var label_pool: Array[Label] = [] var label_pool: Array[Label] = []
var _matcher: SoundtrackSearch = SoundtrackSearch.new()
@onready var search_list: VBoxContainer = %SearchResults @onready var search_list: VBoxContainer = %SearchResults
@onready var search_panel: Panel = %SearchPanel @onready var search_panel: Panel = %SearchPanel
@onready var clear_button: Button = $ClearButton @onready var clear_button: Button = $ClearButton
@@ -67,70 +61,10 @@ func perform_search(search_text: String) -> void:
child.visible = false child.visible = false
label_pool.append(child) label_pool.append(child)
for soundtrack: String in find_matches(search_text): for soundtrack: String in _matcher.find_matches(search_text):
add_soundtrack_reuse(soundtrack) add_soundtrack_reuse(soundtrack)
## Returns the soundtracks matching [param search_text], best matches first: plain
## case-insensitive matches, then matches ignoring spaces and special characters,
## then matches with the letters in order but not next to each other.
func find_matches(search_text: String) -> Array[String]:
var seen: Dictionary = {}
var search_text_lower: String = search_text.to_lower()
var clean_search_text: String = clean_term(search_text)
var matches: Array[String] = []
# Pass 1: Exact case-insensitive contains match on lowercase versions
for i: int in range(precomputed_soundtracks.size()):
var st_data: Dictionary = precomputed_soundtracks[i]
if st_data["lowercase"].contains(search_text_lower):
if not st_data["original"] in seen:
seen[st_data["original"]] = true
matches.append(st_data["original"])
# Pass 2: Contains match on cleaned terms
for i: int in range(precomputed_soundtracks.size()):
var st_data: Dictionary = precomputed_soundtracks[i]
if st_data["cleaned"].contains(clean_search_text):
if not st_data["original"] in seen:
seen[st_data["original"]] = true
matches.append(st_data["original"])
# Pass 3: Regex match on cleaned terms
var regex: RegEx = compile_regex_cached(clean_search_text)
for i: int in range(precomputed_soundtracks.size()):
var st_data: Dictionary = precomputed_soundtracks[i]
if regex.search(st_data["cleaned"]):
if not st_data["original"] in seen:
seen[st_data["original"]] = true
matches.append(st_data["original"])
return matches
## Cleans a term by removing spaces and replacing special characters.
## Matches MusicServer's clean_term() function.
func clean_term(term: String) -> String:
return term.replace(" ", "").replace("é", "e").replace("+", "plus").replace("&", "and") \
.replace("'n", "and").to_lower()
## Compiles the letters-in-order pattern for [param search_term], once per term.
func compile_regex_cached(search_term: String) -> RegEx:
if not search_term in regex_cache:
var regex: RegEx = RegEx.new()
var regex_text: String = ".*"
for letter: String in search_term:
# Escape regex metacharacters so searches like "Q*bert" or "(Remix)" work
if "\\^$.|?*+()[]{}".contains(letter):
regex_text += "\\"
regex_text += letter + ".*"
regex.compile(regex_text)
regex_cache[search_term] = regex
return regex_cache[search_term]
func add_soundtrack(soundtrack: String) -> void: func add_soundtrack(soundtrack: String) -> void:
# Backward compatibility - delegates to reuse version # Backward compatibility - delegates to reuse version
add_soundtrack_reuse(soundtrack) add_soundtrack_reuse(soundtrack)
@@ -177,17 +111,11 @@ func get_list_of_soundtracks() -> void:
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks) MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
## Replaces the searchable soundtracks and precomputes their search forms. ## Replaces the searchable soundtracks.
func set_soundtracks(list: Array) -> void: func set_soundtracks(list: Array) -> void:
soundtracks = [] soundtracks = []
precomputed_soundtracks = []
soundtracks.append_array(list) soundtracks.append_array(list)
for soundtrack: String in soundtracks: _matcher.set_names(soundtracks)
precomputed_soundtracks.append({
"original": soundtrack,
"lowercase": soundtrack.to_lower(),
"cleaned": clean_term(soundtrack)
})
func clear() -> void: func clear() -> void:
+27
View File
@@ -0,0 +1,27 @@
class_name SoundtrackStatsPopup
extends BasePopup
## Shows a soundtrack's play statistics and every song in it, most played first.
## Opened by clicking a card in the statistics view.
@onready var title_label: Label = %TitleLabel
@onready var summary_label: Label = %SummaryLabel
@onready var song_list: VBoxContainer = %SongList
## Shows [param soundtrack] and opens the popup.
func show_soundtrack(soundtrack: SoundtrackWithSongs) -> void:
title_label.text = soundtrack.soundtrack_name
var summary: PackedStringArray = [SoundtrackCard.played_text(soundtrack.soundtrack_played)]
var last_played: String = SoundtrackCard.last_played_text(soundtrack.soundtrack_last_played)
if !last_played.is_empty():
summary.append(last_played)
summary.append("%d %s" % [soundtrack.songs.size(),
"song" if soundtrack.songs.size() == 1 else "songs"])
summary_label.text = " · ".join(summary)
Settings.delete_children(song_list)
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(soundtrack.songs)
for row: int in songs.size():
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
&"TextLarge"))
open()
+1
View File
@@ -0,0 +1 @@
uid://sjteu2abyr0f
+13 -16
View File
@@ -22,19 +22,16 @@ var retry_seconds: float = 5.0
## Preloading waits for the list, because loading the list clears the playlist. ## Preloading waits for the list, because loading the list clears the playlist.
func load_song_list() -> void: func load_song_list() -> void:
GameLog.debug("load_song_list") 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() Playlist.clear_song_list()
if typeof(data) == TYPE_ARRAY: for info: SongInfo in songs:
for d: Dictionary in data: var new_song_object: SongObject = SongObject.new()
var new_song_object: SongObject = SongObject.new() new_song_object.song_number = info.song_no
new_song_object.song_number = d.SongNo new_song_object.soundtrack_title = info.soundtrack
new_song_object.soundtrack_title = d.Soundtrack new_song_object.song_title = info.song
new_song_object.song_title = d.Song new_song_object.is_answered = true
new_song_object.is_answered = true new_song_object.has_played = true
new_song_object.has_played = true Playlist.add_to_playlist(new_song_object)
Playlist.add_to_playlist(new_song_object)
elif data != null:
GameLog.warning("Unexpected data")
Playlist.update_last_played_song() Playlist.update_last_played_song()
song_list_loaded.emit() song_list_loaded.emit()
fetch_song_if_idle() 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: 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") GameLog.debug("song_info_fetched")
new_song_object.song_number = data_received.SongNo new_song_object.song_number = info.song_no
new_song_object.soundtrack_title = data_received.Soundtrack new_song_object.soundtrack_title = info.soundtrack
new_song_object.song_title = data_received.Song new_song_object.song_title = info.song
GameLog.debug("new_song_object: ", new_song_object.to_string()) GameLog.debug("new_song_object: ", new_song_object.to_string())
Playlist.add_to_playlist(new_song_object) Playlist.add_to_playlist(new_song_object)
if Playlist.should_preload_more_songs(): if Playlist.should_preload_more_songs():
+22
View File
@@ -0,0 +1,22 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name ApiError
extends RefCounted
## The [code]APIError[/code] schema of the MusicServer API.
## What went wrong
var error: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> ApiError:
var result: ApiError = ApiError.new()
result.error = ApiJson.get_string(data, "error", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"error": error,
}
+1
View File
@@ -0,0 +1 @@
uid://4ak2e86vltp2
+36
View File
@@ -0,0 +1,36 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name ApiJson
extends RefCounted
## Reads fields from parsed JSON for the generated API classes. A field that is
## missing, null or of the wrong type gives the default instead.
static func get_string(data: Dictionary, key: String, default: String = "") -> String:
var value: Variant = data.get(key)
return value if value is String else default
static func get_int(data: Dictionary, key: String, default: int = 0) -> int:
var value: Variant = data.get(key)
return int(value) if value is int || value is float else default
static func get_float(data: Dictionary, key: String, default: float = 0.0) -> float:
var value: Variant = data.get(key)
return float(value) if value is int || value is float else default
static func get_bool(data: Dictionary, key: String, default: bool = false) -> bool:
var value: Variant = data.get(key)
return value if value is bool else default
static func get_array(data: Dictionary, key: String) -> Array:
var value: Variant = data.get(key)
return value if value is Array else []
static func get_dictionary(data: Dictionary, key: String) -> Dictionary:
var value: Variant = data.get(key)
return value if value is Dictionary else {}
+1
View File
@@ -0,0 +1 @@
uid://kpihxs4wsldn
+24
View File
@@ -0,0 +1,24 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name ProgressResponse
extends RefCounted
## The [code]ProgressResponse[/code] schema of the MusicServer API.
var progress: String
var time_spent: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> ProgressResponse:
var result: ProgressResponse = ProgressResponse.new()
result.progress = ApiJson.get_string(data, "progress", "")
result.time_spent = ApiJson.get_string(data, "time_spent", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"progress": progress,
"time_spent": time_spent,
}
@@ -0,0 +1 @@
uid://blka8xmsytbw1
+36
View File
@@ -0,0 +1,36 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name Song
extends RefCounted
## The [code]Song[/code] schema of the MusicServer API.
var file_name: String
var name: String
var soundtrack_name: String
var soundtrack_uuid: String
var times_played: int
var uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> Song:
var result: Song = Song.new()
result.file_name = ApiJson.get_string(data, "file_name", "")
result.name = ApiJson.get_string(data, "name", "")
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
result.times_played = ApiJson.get_int(data, "times_played", 0)
result.uuid = ApiJson.get_string(data, "uuid", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"file_name": file_name,
"name": name,
"soundtrack_name": soundtrack_name,
"soundtrack_uuid": soundtrack_uuid,
"times_played": times_played,
"uuid": uuid,
}
+1
View File
@@ -0,0 +1 @@
uid://cmsr3kl148gth
+42
View File
@@ -0,0 +1,42 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name SongInfo
extends RefCounted
## The [code]SongInfo[/code] schema of the MusicServer API.
var currently_playing: bool
var song: String
var song_no: int
var song_played: int
var song_uuid: String
var soundtrack: String
var soundtrack_played: int
var soundtrack_uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SongInfo:
var result: SongInfo = SongInfo.new()
result.currently_playing = ApiJson.get_bool(data, "CurrentlyPlaying", false)
result.song = ApiJson.get_string(data, "Song", "")
result.song_no = ApiJson.get_int(data, "SongNo", 0)
result.song_played = ApiJson.get_int(data, "SongPlayed", 0)
result.song_uuid = ApiJson.get_string(data, "SongUuid", "")
result.soundtrack = ApiJson.get_string(data, "Soundtrack", "")
result.soundtrack_played = ApiJson.get_int(data, "SoundtrackPlayed", 0)
result.soundtrack_uuid = ApiJson.get_string(data, "SoundtrackUuid", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"CurrentlyPlaying": currently_playing,
"Song": song,
"SongNo": song_no,
"SongPlayed": song_played,
"SongUuid": song_uuid,
"Soundtrack": soundtrack,
"SoundtrackPlayed": soundtrack_played,
"SoundtrackUuid": soundtrack_uuid,
}
+1
View File
@@ -0,0 +1 @@
uid://blybmr7a2rm6a
+42
View File
@@ -0,0 +1,42 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name SongInfoForStats
extends RefCounted
## The [code]SongInfoForStats[/code] schema of the MusicServer API.
var file_name: String
var path: String
var song_name: String
var song_uuid: String
var soundtrack_id: int
var soundtrack_name: String
var soundtrack_uuid: String
var times_played: int
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SongInfoForStats:
var result: SongInfoForStats = SongInfoForStats.new()
result.file_name = ApiJson.get_string(data, "file_name", "")
result.path = ApiJson.get_string(data, "path", "")
result.song_name = ApiJson.get_string(data, "song_name", "")
result.song_uuid = ApiJson.get_string(data, "song_uuid", "")
result.soundtrack_id = ApiJson.get_int(data, "soundtrack_id", 0)
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
result.times_played = ApiJson.get_int(data, "times_played", 0)
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"file_name": file_name,
"path": path,
"song_name": song_name,
"song_uuid": song_uuid,
"soundtrack_id": soundtrack_id,
"soundtrack_name": soundtrack_name,
"soundtrack_uuid": soundtrack_uuid,
"times_played": times_played,
}
@@ -0,0 +1 @@
uid://chexehl48yklr
+33
View File
@@ -0,0 +1,33 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name Soundtrack
extends RefCounted
## The [code]Soundtrack[/code] schema of the MusicServer API.
var added: String
var last_played: String
var name: String
var times_played: int
var uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> Soundtrack:
var result: Soundtrack = Soundtrack.new()
result.added = ApiJson.get_string(data, "added", "")
result.last_played = ApiJson.get_string(data, "last_played", "")
result.name = ApiJson.get_string(data, "name", "")
result.times_played = ApiJson.get_int(data, "times_played", 0)
result.uuid = ApiJson.get_string(data, "uuid", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"added": added,
"last_played": last_played,
"name": name,
"times_played": times_played,
"uuid": uuid,
}
+1
View File
@@ -0,0 +1 @@
uid://bdbaxmn66tuv7
@@ -0,0 +1,38 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name SoundtrackWithSongList
extends RefCounted
## The [code]SoundtrackWithSongList[/code] schema of the MusicServer API.
var added: String
var last_played: String
var name: String
var songs: Array[Song]
var times_played: int
var uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
var result: SoundtrackWithSongList = SoundtrackWithSongList.new()
result.added = ApiJson.get_string(data, "added", "")
result.last_played = ApiJson.get_string(data, "last_played", "")
result.name = ApiJson.get_string(data, "name", "")
for item: Variant in ApiJson.get_array(data, "songs"):
if item is Dictionary:
result.songs.append(Song.from_dict(item))
result.times_played = ApiJson.get_int(data, "times_played", 0)
result.uuid = ApiJson.get_string(data, "uuid", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"added": added,
"last_played": last_played,
"name": name,
"songs": songs.map(func(item: Song) -> Dictionary: return item.to_dict()),
"times_played": times_played,
"uuid": uuid,
}
@@ -0,0 +1 @@
uid://cj4rx6m53oarm
+38
View File
@@ -0,0 +1,38 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name SoundtrackWithSongs
extends RefCounted
## The [code]SoundtrackWithSongs[/code] schema of the MusicServer API.
var songs: Array[SongInfoForStats]
var soundtrack_id: int
var soundtrack_last_played: String
var soundtrack_name: String
var soundtrack_played: int
var soundtrack_uuid: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
var result: SoundtrackWithSongs = SoundtrackWithSongs.new()
for item: Variant in ApiJson.get_array(data, "songs"):
if item is Dictionary:
result.songs.append(SongInfoForStats.from_dict(item))
result.soundtrack_id = ApiJson.get_int(data, "soundtrack_id", 0)
result.soundtrack_last_played = ApiJson.get_string(data, "soundtrack_last_played", "")
result.soundtrack_name = ApiJson.get_string(data, "soundtrack_name", "")
result.soundtrack_played = ApiJson.get_int(data, "soundtrack_played", 0)
result.soundtrack_uuid = ApiJson.get_string(data, "soundtrack_uuid", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"songs": songs.map(func(item: SongInfoForStats) -> Dictionary: return item.to_dict()),
"soundtrack_id": soundtrack_id,
"soundtrack_last_played": soundtrack_last_played,
"soundtrack_name": soundtrack_name,
"soundtrack_played": soundtrack_played,
"soundtrack_uuid": soundtrack_uuid,
}
@@ -0,0 +1 @@
uid://yxxvcbbr5ioi
+39
View File
@@ -0,0 +1,39 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name StatisticsSummary
extends RefCounted
## The [code]StatisticsSummary[/code] schema of the MusicServer API.
var avg_soundtrack_plays: float
var max_soundtrack_plays: int
var min_soundtrack_plays: int
var never_played_soundtracks: int
var played_soundtracks: int
var total_soundtrack_plays: int
var total_soundtracks: int
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> StatisticsSummary:
var result: StatisticsSummary = StatisticsSummary.new()
result.avg_soundtrack_plays = ApiJson.get_float(data, "avg_soundtrack_plays", 0.0)
result.max_soundtrack_plays = ApiJson.get_int(data, "max_soundtrack_plays", 0)
result.min_soundtrack_plays = ApiJson.get_int(data, "min_soundtrack_plays", 0)
result.never_played_soundtracks = ApiJson.get_int(data, "never_played_soundtracks", 0)
result.played_soundtracks = ApiJson.get_int(data, "played_soundtracks", 0)
result.total_soundtrack_plays = ApiJson.get_int(data, "total_soundtrack_plays", 0)
result.total_soundtracks = ApiJson.get_int(data, "total_soundtracks", 0)
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"avg_soundtrack_plays": avg_soundtrack_plays,
"max_soundtrack_plays": max_soundtrack_plays,
"min_soundtrack_plays": min_soundtrack_plays,
"never_played_soundtracks": never_played_soundtracks,
"played_soundtracks": played_soundtracks,
"total_soundtrack_plays": total_soundtrack_plays,
"total_soundtracks": total_soundtracks,
}
@@ -0,0 +1 @@
uid://d2acyameyuhv2
+21
View File
@@ -0,0 +1,21 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name StatusMessage
extends RefCounted
## The [code]StatusMessage[/code] schema of the MusicServer API.
var status: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> StatusMessage:
var result: StatusMessage = StatusMessage.new()
result.status = ApiJson.get_string(data, "status", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"status": status,
}
+1
View File
@@ -0,0 +1 @@
uid://5inwlixefu0m
+41
View File
@@ -0,0 +1,41 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name SyncResponse
extends RefCounted
## The [code]SyncResponse[/code] schema of the MusicServer API.
var catched_errors: Array[String]
var soundtracks_added: Array[String]
var soundtracks_changed_content: Array[String]
var soundtracks_changed_title: Dictionary[String, String]
var soundtracks_re_added: Array[String]
var soundtracks_removed: Array[String]
var total_time: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> SyncResponse:
var result: SyncResponse = SyncResponse.new()
result.catched_errors.assign(ApiJson.get_array(data, "catched_errors"))
result.soundtracks_added.assign(ApiJson.get_array(data, "soundtracks_added"))
result.soundtracks_changed_content.assign(
ApiJson.get_array(data, "soundtracks_changed_content"))
result.soundtracks_changed_title.assign(
ApiJson.get_dictionary(data, "soundtracks_changed_title"))
result.soundtracks_re_added.assign(ApiJson.get_array(data, "soundtracks_re_added"))
result.soundtracks_removed.assign(ApiJson.get_array(data, "soundtracks_removed"))
result.total_time = ApiJson.get_string(data, "total_time", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"catched_errors": catched_errors,
"soundtracks_added": soundtracks_added,
"soundtracks_changed_content": soundtracks_changed_content,
"soundtracks_changed_title": soundtracks_changed_title,
"soundtracks_re_added": soundtracks_re_added,
"soundtracks_removed": soundtracks_removed,
"total_time": total_time,
}
+1
View File
@@ -0,0 +1 @@
uid://d2fcfuusfbhah
+26
View File
@@ -0,0 +1,26 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name TokenRequest
extends RefCounted
## The [code]TokenRequest[/code] schema of the MusicServer API.
## Kind of client, for example web, godot or api
var client_type: String = "web"
## API password, required when the server has one configured
var password: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> TokenRequest:
var result: TokenRequest = TokenRequest.new()
result.client_type = ApiJson.get_string(data, "client_type", "web")
result.password = ApiJson.get_string(data, "password", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"client_type": client_type,
"password": password,
}
+1
View File
@@ -0,0 +1 @@
uid://c83b66bi6vpmp
+28
View File
@@ -0,0 +1,28 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name TokenResponse
extends RefCounted
## The [code]TokenResponse[/code] schema of the MusicServer API.
var client_type: String
var expires_at: String
## Send as Authorization: Bearer <token>
var token: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> TokenResponse:
var result: TokenResponse = TokenResponse.new()
result.client_type = ApiJson.get_string(data, "client_type", "")
result.expires_at = ApiJson.get_string(data, "expires_at", "")
result.token = ApiJson.get_string(data, "token", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"client_type": client_type,
"expires_at": expires_at,
"token": token,
}
+1
View File
@@ -0,0 +1 @@
uid://4a44v85naqw0
+24
View File
@@ -0,0 +1,24 @@
# Generated by tools/generate_api_types.gd from server/openapi.json.
# Do not edit; change the server and generate again.
class_name VersionData
extends RefCounted
## The [code]VersionData[/code] schema of the MusicServer API.
var changelog: Array[String]
var version: String
## Reads the fields from parsed JSON. Missing fields keep their defaults.
static func from_dict(data: Dictionary) -> VersionData:
var result: VersionData = VersionData.new()
result.changelog.assign(ApiJson.get_array(data, "changelog"))
result.version = ApiJson.get_string(data, "version", "")
return result
## Returns the fields under their JSON names, ready for [method JSON.stringify].
func to_dict() -> Dictionary:
return {
"changelog": changelog,
"version": version,
}
+1
View File
@@ -0,0 +1 @@
uid://no7af2fycku5
+16 -8
View File
@@ -17,8 +17,15 @@ func _init(owner: Node) -> void:
## Requests [param url] and calls [param on_complete] with ## Requests [param url] and calls [param on_complete] with
## [code](response_code: int, body: PackedByteArray)[/code]. Connection errors and ## [code](response_code: int, body: PackedByteArray)[/code]. Connection errors and
## timeouts are reported with response code 0. ## timeouts are reported with response code 0. [param headers] are sent as-is; a
func request(url: String, method: int, on_complete: Callable) -> void: ## [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() var http_request: HTTPRequest = HTTPRequest.new()
http_request.timeout = timeout_seconds http_request.timeout = timeout_seconds
_owner.add_child(http_request) _owner.add_child(http_request)
@@ -27,15 +34,16 @@ func request(url: String, method: int, on_complete: Callable) -> void:
result: int, result: int,
response_code: int, response_code: int,
_headers: PackedStringArray, _headers: PackedStringArray,
body: PackedByteArray, response_body: PackedByteArray,
) -> void: ) -> void:
http_request.queue_free() 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 = [] var all_headers: PackedStringArray = headers.duplicate()
if method == HTTPClient.METHOD_PUT: if method == HTTPClient.METHOD_PUT || !body.is_empty():
headers.append("Content-Type: application/json") all_headers.append("Content-Type: application/json")
var error: int = http_request.request(url, headers, method) var error: int = http_request.request(url, all_headers, method, body)
if error != OK: if error != OK:
http_request.queue_free() http_request.queue_free()
push_error("Could not send request to %s (error %d)" % [url, error]) push_error("Could not send request to %s (error %d)" % [url, error])
+389 -27
View File
@@ -1,25 +1,47 @@
extends Node 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 ## 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 ## 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 ## goes through [member transport]; tests replace it with a fake to answer requests
## without a running server. ## 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. ## Sends the actual HTTP requests. Swap it out in tests.
var transport: HttpTransport 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: func _ready() -> void:
if transport == null: if transport == null:
transport = HttpTransport.new(self) transport = HttpTransport.new(self)
## Fetches the songs already in the server playlist, as an Array of # Queue
## [code]{SongNo, Soundtrack, Song}[/code] dictionaries.
## 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: 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 ## 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) 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. ## Adds the most recently fetched random song to the server playlist.
func add_to_queue(on_done: Callable, on_failed: Callable = Callable()) -> void: func add_to_queue(on_done: Callable, on_failed: Callable = Callable()) -> void:
send("/music/addQue", on_done, on_failed) 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: 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. ## 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) 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. ## Downloads the sound test song as MP3 bytes.
func fetch_sound_test_song(on_done: Callable, on_failed: Callable = Callable()) -> void: func fetch_sound_test_song(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_bytes("/music/soundTest", on_done, on_failed) 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) 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: func fetch_all_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json("/music/all", on_done, on_failed) get_json("/music/all", on_done, on_failed)
@@ -64,14 +114,99 @@ func fetch_all_soundtracks_ordered(on_done: Callable, on_failed: Callable = Call
get_json("/music/all/order", on_done, on_failed) get_json("/music/all/order", on_done, on_failed)
## Records in the server statistics that a song has been played. ## Fetches every soundtrack as an [code]Array[Soundtrack][/code].
func mark_song_played(song_no: int) -> void: func fetch_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> void:
send("/music/played?song=" + str(song_no), Callable(), Callable(), HTTPClient.METHOD_PUT) 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 every soundtrack with its songs and play counts, sorted by name, as an
## [code]Array[SoundtrackWithSongs][/code].
func fetch_soundtrack_statistics(on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json_list("/statistics/soundtracks", SoundtrackWithSongs, on_done, on_failed)
## Fetches the [param limit] most played soundtracks as an
## [code]Array[SoundtrackWithSongs][/code]. The server allows a limit of 1 to 100.
func fetch_most_played_soundtracks(
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
_fetch_soundtrack_statistics("most-played", limit, on_done, on_failed)
## Fetches the [param limit] least played soundtracks as an
## [code]Array[SoundtrackWithSongs][/code].
func fetch_least_played_soundtracks(
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
_fetch_soundtrack_statistics("least-played", limit, on_done, on_failed)
## Fetches the [param limit] most recently played soundtracks as an
## [code]Array[SoundtrackWithSongs][/code].
func fetch_last_played_soundtracks(
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
_fetch_soundtrack_statistics("last-played", limit, on_done, on_failed)
## Fetches the [param limit] soundtracks that were played longest ago, leaving out those
## never played, as an [code]Array[SoundtrackWithSongs][/code].
func fetch_oldest_played_soundtracks(
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
_fetch_soundtrack_statistics("oldest-played", limit, on_done, on_failed)
## Fetches every soundtrack that has never been played as an
## [code]Array[SoundtrackWithSongs][/code].
func fetch_never_played_soundtracks(
on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json_list("/statistics/soundtracks/never-played", SoundtrackWithSongs, on_done,
on_failed)
## Fetches the [param limit] most played songs as an
## [code]Array[SongInfoForStats][/code].
func fetch_most_played_songs(
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json_list("/statistics/songs/most-played?limit=%d" % limit, SongInfoForStats,
on_done, on_failed)
## Fetches the [param limit] least played songs as an
## [code]Array[SongInfoForStats][/code].
func fetch_least_played_songs(
limit: int, on_done: Callable, on_failed: Callable = Callable()) -> void:
get_json_list("/statistics/songs/least-played?limit=%d" % limit, SongInfoForStats,
on_done, on_failed)
# Characters
## Fetches the names of all character images. ## Fetches the names of all character images.
func fetch_character_list(on_done: Callable, on_failed: Callable = Callable()) -> void: 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. ## Downloads a character image as PNG bytes.
@@ -80,9 +215,17 @@ func fetch_character_image(
get_bytes("/character?name=" + character_name.uri_encode(), on_done, on_failed) 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: 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 ## Fetches the sync state: [code]{progress, time_spent}[/code] while running, or the
@@ -91,17 +234,115 @@ func fetch_sync_progress(on_done: Callable, on_failed: Callable = Callable()) ->
get_json("/sync/progress", on_done, on_failed) 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. ## 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 ## [param on_failed] is called with no arguments if the request fails or the body is
## not valid JSON. ## not valid JSON.
func get_json(path: String, on_done: Callable, on_failed: Callable = Callable()) -> void: func get_json(path: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
_request(path, HTTPClient.METHOD_GET, func(body: PackedByteArray) -> void: _request(path, HTTPClient.METHOD_GET, _parse_json.bind(path, on_done, on_failed),
var json: JSON = JSON.new() on_failed)
if json.parse(body.get_string_from_utf8()) != OK:
push_warning("Invalid JSON from %s: %s" % [path, json.get_error_message()])
## 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) _call_if_valid(on_failed)
return 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) , on_failed)
@@ -124,17 +365,138 @@ func send(
, on_failed) , on_failed)
func _request(path: String, method: int, on_success: Callable, on_failed: Callable) -> void: func _fetch_soundtrack_statistics(
var url: String = Settings.default_path + path list_name: String, limit: int, on_done: Callable, on_failed: Callable) -> void:
transport.request(url, method, func(response_code: int, body: PackedByteArray) -> void: get_json_list("/statistics/soundtracks/%s?limit=%d" % [list_name, limit],
if response_code != 200: SoundtrackWithSongs, on_done, on_failed)
push_warning("Request to %s failed with response code %d" % [url, response_code])
_call_if_valid(on_failed)
return func _parse_json(
on_success.call(body) 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: static func _call_if_valid(callback: Callable) -> void:
if callback.is_valid(): if callback.is_valid():
callback.call() callback.call()
+3335
View File
File diff suppressed because it is too large. Load diff
+57
View File
@@ -0,0 +1,57 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://statistics/soundtrack_card.gd" id="card_script"]
[node name="SoundtrackCard" type="PanelContainer" unique_id=1211000001]
custom_minimum_size = Vector2(290, 340)
mouse_default_cursor_shape = 2
theme_type_variation = &"StatisticsCard"
script = ExtResource("card_script")
[node name="VBox" type="VBoxContainer" parent="." unique_id=1211000002]
layout_mode = 2
mouse_filter = 2
[node name="NameLabel" type="Label" parent="VBox" unique_id=1211000003]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 62)
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"TextLarge"
autowrap_mode = 3
text_overrun_behavior = 3
max_lines_visible = 2
[node name="PlayedLabel" type="Label" parent="VBox" unique_id=1211000004]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
[node name="LastPlayedLabel" type="Label" parent="VBox" unique_id=1211000005]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
[node name="MostPlayedHeader" type="Label" parent="VBox" unique_id=1211000006]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"HighlightMedium"
text = "Most played"
[node name="MostPlayedList" type="VBoxContainer" parent="VBox" unique_id=1211000007]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
[node name="LeastPlayedHeader" type="Label" parent="VBox" unique_id=1211000008]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
theme_type_variation = &"HighlightMedium"
text = "Least played"
[node name="LeastPlayedList" type="VBoxContainer" parent="VBox" unique_id=1211000009]
unique_name_in_owner = true
layout_mode = 2
mouse_filter = 2
+132
View File
@@ -0,0 +1,132 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://statistics/statistics_view.gd" id="view_script"]
[node name="StatisticsView" type="Panel" unique_id=1212000001]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 56.0
grow_horizontal = 2
grow_vertical = 2
theme_type_variation = &"WindowBackground"
script = ExtResource("view_script")
[node name="Toolbar" type="HBoxContainer" parent="." unique_id=1212000002]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_left = 32.0
offset_top = 16.0
offset_right = -32.0
offset_bottom = 56.0
grow_horizontal = 2
theme_override_constants/separation = 12
[node name="SearchField" type="LineEdit" parent="Toolbar" unique_id=1212000003]
unique_name_in_owner = true
custom_minimum_size = Vector2(480, 0)
layout_mode = 2
placeholder_text = "Search soundtracks"
clear_button_enabled = true
[node name="ClearButton" type="Button" parent="Toolbar" unique_id=1212000004]
unique_name_in_owner = true
layout_mode = 2
focus_mode = 0
text = "Clear"
[node name="CountLabel" type="Label" parent="Toolbar" unique_id=1212000005]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
horizontal_alignment = 1
[node name="ShowLabel" type="Label" parent="Toolbar" unique_id=1212000006]
layout_mode = 2
text = "Show"
[node name="ShowOption" type="OptionButton" parent="Toolbar" unique_id=1212000007]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 0)
layout_mode = 2
focus_mode = 0
selected = 0
item_count = 3
popup/item_0/text = "All"
popup/item_0/id = 0
popup/item_1/text = "Played"
popup/item_1/id = 1
popup/item_2/text = "Never played"
popup/item_2/id = 2
[node name="SortLabel" type="Label" parent="Toolbar" unique_id=1212000008]
layout_mode = 2
text = "Sort"
[node name="SortOption" type="OptionButton" parent="Toolbar" unique_id=1212000009]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 0)
layout_mode = 2
focus_mode = 0
selected = 0
item_count = 4
popup/item_0/text = "A-Z"
popup/item_0/id = 0
popup/item_1/text = "Z-A"
popup/item_1/id = 1
popup/item_2/text = "Most played"
popup/item_2/id = 2
popup/item_3/text = "Least played"
popup/item_3/id = 3
[node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=1212000010]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 72.0
offset_right = -32.0
offset_bottom = -16.0
grow_horizontal = 2
grow_vertical = 2
horizontal_scroll_mode = 0
[node name="CardGrid" type="GridContainer" parent="ScrollContainer" unique_id=1212000011]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 0
theme_type_variation = &"StatisticsGrid"
columns = 6
[node name="StatusLabel" type="Label" parent="." unique_id=1212000012]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 14
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_top = -40.0
offset_bottom = -4.0
grow_horizontal = 2
grow_vertical = 2
theme_type_variation = &"Title"
horizontal_alignment = 1
[node name="RetryButton" type="Button" parent="." unique_id=1212000013]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -50.0
offset_top = 8.0
offset_right = 50.0
offset_bottom = 48.0
grow_horizontal = 2
grow_vertical = 2
focus_mode = 0
text = "Retry"
+104
View File
@@ -0,0 +1,104 @@
class_name SoundtrackCard
extends PanelContainer
## One soundtrack in the statistics view: its name, how often and when it was last
## played, and its most and least played songs. Clicking it asks for all its songs.
## Emitted when the card is clicked.
signal pressed(soundtrack: SoundtrackWithSongs)
## How many of the most and least played songs the card lists.
const SONGS_SHOWN: int = 3
## The soundtrack the card shows.
var soundtrack: SoundtrackWithSongs
@onready var name_label: Label = %NameLabel
@onready var played_label: Label = %PlayedLabel
@onready var last_played_label: Label = %LastPlayedLabel
@onready var most_played_header: Label = %MostPlayedHeader
@onready var most_played_list: VBoxContainer = %MostPlayedList
@onready var least_played_header: Label = %LeastPlayedHeader
@onready var least_played_list: VBoxContainer = %LeastPlayedList
func _ready() -> void:
mouse_entered.connect(_set_hovered.bind(true))
mouse_exited.connect(_set_hovered.bind(false))
gui_input.connect(_on_gui_input)
## Shows [param data] on the card.
func show_soundtrack(data: SoundtrackWithSongs) -> void:
soundtrack = data
name_label.text = data.soundtrack_name
tooltip_text = data.soundtrack_name
played_label.text = played_text(data.soundtrack_played)
last_played_label.text = last_played_text(data.soundtrack_last_played)
last_played_label.visible = !data.soundtrack_last_played.is_empty()
var most_played: Array[SongInfoForStats] = songs_by_plays(data.songs).slice(0, SONGS_SHOWN)
# Without the songs already shown as most played
var least_played: Array[SongInfoForStats] = songs_by_plays(data.songs, true).filter(
func(song: SongInfoForStats) -> bool: return !most_played.has(song)
).slice(0, SONGS_SHOWN)
most_played_header.text = "Most played" if !data.songs.is_empty() else "No songs"
_fill_song_list(most_played_list, most_played)
least_played_header.visible = !least_played.is_empty()
_fill_song_list(least_played_list, least_played)
## "Played 3 times", or "Never played" for [code]0[/code].
static func played_text(times_played: int) -> String:
if times_played == 0:
return "Never played"
return "Played %d %s" % [times_played, "time" if times_played == 1 else "times"]
## "Last played 2026-10-01" from the server's date and time, or an empty String when
## there is none.
static func last_played_text(last_played: String) -> String:
if last_played.is_empty():
return ""
return "Last played " + last_played.substr(0, 10)
## One line of a song list: how often the song was played, then its name.
static func song_text(song: SongInfoForStats) -> String:
return "%d × %s" % [song.times_played, song.song_name]
## [param songs] sorted with the most played first, or the least played first with
## [param least_first], and by name when played as often.
static func songs_by_plays(
songs: Array[SongInfoForStats], least_first: bool = false) -> Array[SongInfoForStats]:
var sorted: Array[SongInfoForStats] = songs.duplicate()
sorted.sort_custom(func(a: SongInfoForStats, b: SongInfoForStats) -> bool:
if a.times_played != b.times_played:
return (a.times_played < b.times_played) == least_first
return a.song_name.naturalnocasecmp_to(b.song_name) < 0
)
return sorted
func _fill_song_list(list: VBoxContainer, songs: Array[SongInfoForStats]) -> void:
while list.get_child_count() < songs.size():
var label: Label = Label.new()
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
list.add_child(label)
for i: int in list.get_child_count():
var label: Label = list.get_child(i)
label.visible = i < songs.size()
if label.visible:
label.text = song_text(songs[i])
func _set_hovered(hovered: bool) -> void:
theme_type_variation = &"StatisticsCardHover" if hovered else &"StatisticsCard"
func _on_gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton && event.pressed \
&& event.button_index == MOUSE_BUTTON_LEFT:
accept_event()
pressed.emit(soundtrack)
+1
View File
@@ -0,0 +1 @@
uid://beeibc7xpftq1
+139
View File
@@ -0,0 +1,139 @@
class_name StatisticsView
extends Panel
## Shows every soundtrack on the server as a card with its play statistics. The cards
## can be searched, filtered on whether they have been played, and sorted.
##
## Shown instead of the quiz when the Statistics tab is chosen.
## Emitted when a card is clicked, to show all songs of [param soundtrack].
signal soundtrack_selected(soundtrack: SoundtrackWithSongs)
## The orders the cards can be sorted in, in the order of the sort menu.
enum SortOrder { A_TO_Z, Z_TO_A, MOST_PLAYED, LEAST_PLAYED }
## Which soundtracks are shown, in the order of the show menu.
enum ShowFilter { ALL, PLAYED, NEVER_PLAYED }
const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
## The cards in the order they are shown, hidden ones included.
var cards: Array[SoundtrackCard] = []
var _search: SoundtrackSearch = SoundtrackSearch.new()
var _loading: bool = false
@onready var search_field: LineEdit = %SearchField
@onready var clear_button: Button = %ClearButton
@onready var show_option: OptionButton = %ShowOption
@onready var sort_option: OptionButton = %SortOption
@onready var count_label: Label = %CountLabel
@onready var status_label: Label = %StatusLabel
@onready var retry_button: Button = %RetryButton
@onready var card_grid: GridContainer = %CardGrid
func _ready() -> void:
search_field.text_changed.connect(func(_text: String) -> void: apply_filter())
clear_button.pressed.connect(clear_search)
show_option.item_selected.connect(func(_index: int) -> void: apply_filter())
sort_option.item_selected.connect(func(_index: int) -> void: apply_sort())
retry_button.pressed.connect(refresh)
retry_button.visible = false
## Loads the statistics from the server. The cards already shown stay until the new
## ones arrive.
func refresh() -> void:
if _loading:
return
_loading = true
retry_button.visible = false
if cards.is_empty():
status_label.text = "Loading statistics…"
MusicServerClient.fetch_soundtrack_statistics(_on_loaded, _on_load_failed)
## Shows every card again, keeping the show filter and the sort order.
func clear_search() -> void:
search_field.text = ""
apply_filter()
## Puts the cards in the order chosen in the sort menu.
func apply_sort() -> void:
var order: SortOrder = sort_option.selected as SortOrder
cards.sort_custom(func(a: SoundtrackCard, b: SoundtrackCard) -> bool:
return _comes_before(a.soundtrack, b.soundtrack, order))
for i: int in cards.size():
card_grid.move_child(cards[i], i)
## Shows only the cards that match the search text and the show menu.
func apply_filter() -> void:
var search_text: String = search_field.text.strip_edges()
var matching: Dictionary = {}
for soundtrack_name: String in _search.find_matches(search_text) \
if !search_text.is_empty() else []:
matching[soundtrack_name] = true
var filter: ShowFilter = show_option.selected as ShowFilter
var shown: int = 0
for card: SoundtrackCard in cards:
card.visible = (search_text.is_empty() || matching.has(card.soundtrack.soundtrack_name)) \
&& _passes_filter(card.soundtrack, filter)
if card.visible:
shown += 1
if shown == cards.size():
count_label.text = "%d soundtracks" % cards.size()
else:
count_label.text = "%d of %d soundtracks" % [shown, cards.size()]
func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
_loading = false
status_label.text = ""
# Cards are reused, so opening the tab again only updates their text
while cards.size() < soundtracks.size():
var card: SoundtrackCard = CARD_SCENE.instantiate()
card_grid.add_child(card)
card.pressed.connect(soundtrack_selected.emit)
cards.append(card)
while cards.size() > soundtracks.size():
cards.pop_back().free()
var names: Array[String] = []
for i: int in soundtracks.size():
cards[i].show_soundtrack(soundtracks[i])
names.append(soundtracks[i].soundtrack_name)
_search.set_names(names)
apply_sort()
apply_filter()
func _on_load_failed() -> void:
_loading = false
if cards.is_empty():
status_label.text = "Could not load statistics"
retry_button.visible = true
static func _passes_filter(soundtrack: SoundtrackWithSongs, filter: ShowFilter) -> bool:
match filter:
ShowFilter.PLAYED:
return soundtrack.soundtrack_played > 0
ShowFilter.NEVER_PLAYED:
return soundtrack.soundtrack_played == 0
return true
static func _comes_before(
a: SoundtrackWithSongs, b: SoundtrackWithSongs, order: SortOrder) -> bool:
var by_name: int = a.soundtrack_name.naturalnocasecmp_to(b.soundtrack_name)
match order:
SortOrder.Z_TO_A:
return by_name > 0
SortOrder.MOST_PLAYED:
if a.soundtrack_played != b.soundtrack_played:
return a.soundtrack_played > b.soundtrack_played
SortOrder.LEAST_PLAYED:
if a.soundtrack_played != b.soundtrack_played:
return a.soundtrack_played < b.soundtrack_played
return by_name < 0
+1
View File
@@ -0,0 +1 @@
uid://bdh0vdom0jenq
+24116 -2148
View File
File diff suppressed because it is too large. Load diff
+69 -3
View File
@@ -5,16 +5,33 @@ extends HttpTransport
## Register canned responses per path with [method respond_json] or ## Register canned responses per path with [method respond_json] or
## [method respond_bytes], then install it with [method install]. Requests to paths ## [method respond_bytes], then install it with [method install]. Requests to paths
## without a response get a 404. Responses are delivered immediately. ## without a response get a 404. Responses are delivered immediately.
##
## Paths are relative to the API, e.g. [code]/music/list[/code] for
## [code]<Settings.default_path>/api/v1/music/list[/code].
##
## Like the real server it hands out tokens on [code]POST /token[/code] and answers 401
## to requests without a valid one. Token requests are counted in
## [member token_requests], not in [member requested_paths].
## 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] = [] var requested_paths: Array[String] = []
## HTTP method of each request, in the same order as [member requested_paths]. ## HTTP method of each request, in the same order as [member requested_paths].
var requested_methods: Array[int] = [] 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 _responses: Dictionary = {}
var _successes_left: Dictionary = {} var _successes_left: Dictionary = {}
var _held_paths: Dictionary = {} var _held_paths: Dictionary = {}
var _previous_transport: HttpTransport var _previous_transport: HttpTransport
var _valid_tokens: Array[String] = []
func _init() -> void: func _init() -> void:
@@ -25,12 +42,14 @@ func _init() -> void:
func install() -> FakeHttpTransport: func install() -> FakeHttpTransport:
_previous_transport = MusicServerClient.transport _previous_transport = MusicServerClient.transport
MusicServerClient.transport = self MusicServerClient.transport = self
MusicServerClient.clear_token()
return self return self
## Restores the transport that was active before [method install]. ## Restores the transport that was active before [method install].
func uninstall() -> void: func uninstall() -> void:
MusicServerClient.transport = _previous_transport MusicServerClient.transport = _previous_transport
MusicServerClient.clear_token()
## Answers requests to [param path] with [param data] encoded as JSON. ## 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) return requested_paths.count(path)
func request(url: String, method: int, on_complete: Callable) -> void: ## Makes every token handed out so far invalid, like after 24 hours or a server restart.
var path: String = url.trim_prefix(Settings.default_path) 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_paths.append(path)
requested_methods.append(method) 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): if _held_paths.has(path):
_held_paths[path].append(_answer.bind(path, on_complete)) _held_paths[path].append(_answer.bind(path, on_complete))
return return
@@ -89,3 +130,28 @@ func _answer(path: String, on_complete: Callable) -> void:
on_complete.call(_responses[path].code, _responses[path].body) on_complete.call(_responses[path].code, _responses[path].body)
else: else:
on_complete.call(404, PackedByteArray()) 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
+1 -1
View File
@@ -30,7 +30,7 @@ func before_test() -> void:
_server.respond_json("/music/all", ["Chrono Trigger", "Final Fantasy VI"]) _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/all/order", ["Chrono Trigger", "Final Fantasy VI"])
_server.respond_json("/music/list", []) _server.respond_json("/music/list", [])
_server.respond_json("/characters", []) _server.respond_json("/character/list", [])
_server.respond_bytes("/music/rand", mp3) _server.respond_bytes("/music/rand", mp3)
_server.respond_bytes("/music/addQue", PackedByteArray()) _server.respond_bytes("/music/addQue", PackedByteArray())
_server.respond_json("/music/info", SONG_INFO[0]) _server.respond_json("/music/info", SONG_INFO[0])
+57 -5
View File
@@ -1,7 +1,8 @@
class_name UiTour class_name UiTour
extends RefCounted extends RefCounted
## Walks the main window through a fixed set of states against a fake MusicServer: ## Walks the main window through a fixed set of states against a fake MusicServer:
## startup, players added, a song playing, every popup open and a sync. ## startup, players added, a song playing, every popup open, a sync and the statistics
## tab.
## ##
## Used for screenshots ([code]tools/take_screenshots.gd[/code]) and for the layout ## Used for screenshots ([code]tools/take_screenshots.gd[/code]) and for the layout
## test, so both look at exactly the same states. Nodes are looked up by name in the ## test, so both look at exactly the same states. Nodes are looked up by name in the
@@ -38,7 +39,6 @@ const POPUPS: Array[Array] = [
["SettingsWindow", "SettingsPopup"], ["QRWindow", "QRPopup"], ["SettingsWindow", "SettingsPopup"], ["QRWindow", "QRPopup"],
["NewCharacterSelect", "CharacterSelectPopup"], ["ShortcutsWindow", "ShortcutsPopup"], ["NewCharacterSelect", "CharacterSelectPopup"], ["ShortcutsWindow", "ShortcutsPopup"],
["AboutWindow", "AboutPopup"], ["WinnerWindow", "WinnerPopup"], ["Log", "LogPopup"], ["AboutWindow", "AboutPopup"], ["WinnerWindow", "WinnerPopup"], ["Log", "LogPopup"],
["StatisticsPopupPanel", "StatisticsPopupPanel"],
] ]
var _main: Control var _main: Control
@@ -57,7 +57,7 @@ static func make_server() -> FakeHttpTransport:
server.respond_json("/music/all/order", SOUNDTRACKS) server.respond_json("/music/all/order", SOUNDTRACKS)
server.respond_json("/music/list", []) server.respond_json("/music/list", [])
var characters: Array[String] = character_names() var characters: Array[String] = character_names()
server.respond_json("/characters", characters) server.respond_json("/character/list", characters)
for character_name: String in characters: for character_name: String in characters:
server.respond_bytes("/character?name=" + character_name.uri_encode(), server.respond_bytes("/character?name=" + character_name.uri_encode(),
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_name))) FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(character_name)))
@@ -68,9 +68,37 @@ static func make_server() -> FakeHttpTransport:
server.respond_bytes("/music/played?song=0", PackedByteArray()) server.respond_bytes("/music/played?song=0", PackedByteArray())
server.respond_bytes("/sync", PackedByteArray()) server.respond_bytes("/sync", PackedByteArray())
server.respond_json("/sync/progress", SYNC_PROGRESS) server.respond_json("/sync/progress", SYNC_PROGRESS)
server.respond_json("/statistics/soundtracks", soundtrack_statistics())
return server return server
## Play statistics for every soundtrack in [constant SOUNDTRACKS], the same on every
## run. Some have never been played.
static func soundtrack_statistics() -> Array[Dictionary]:
var statistics: Array[Dictionary] = []
for i: int in SOUNDTRACKS.size():
var played: int = (i * 7) % 11
var songs: Array[Dictionary] = []
for song: int in 5:
songs.append({
"song_uuid": "song-%d-%d" % [i, song],
"soundtrack_uuid": "soundtrack-%d" % i,
"soundtrack_name": SOUNDTRACKS[i],
"song_name": "Track %d" % (song + 1),
"times_played": maxi(0, played - song * 2),
})
var soundtrack: Dictionary = {
"soundtrack_uuid": "soundtrack-%d" % i,
"soundtrack_name": SOUNDTRACKS[i],
"soundtrack_played": played,
"songs": songs,
}
if played > 0:
soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1)
statistics.append(soundtrack)
return statistics
## The pictures in [constant CHARACTERS_DIR], sorted by name. ## The pictures in [constant CHARACTERS_DIR], sorted by name.
static func character_names() -> Array[String]: static func character_names() -> Array[String]:
var names: Array[String] = [] var names: Array[String] = []
@@ -162,6 +190,32 @@ func run(on_state: Callable) -> void:
sync_popup.close() sync_popup.close()
await _settle() await _settle()
var statistics_view: StatisticsView = _node("StatisticsView")
_main.show_tab(&"statistics")
await _settle()
await on_state.call("statistics", statistics_view)
statistics_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED)
statistics_view.apply_filter()
await _settle()
await on_state.call("statistics_never_played", statistics_view)
statistics_view.show_option.select(StatisticsView.ShowFilter.ALL)
statistics_view.sort_option.select(StatisticsView.SortOrder.MOST_PLAYED)
statistics_view.apply_sort()
statistics_view.search_field.text = "chrono"
statistics_view.apply_filter()
await _settle()
await on_state.call("statistics_search", statistics_view)
var stats_popup: SoundtrackStatsPopup = _node("SoundtrackStatsPopup")
for card: SoundtrackCard in statistics_view.cards:
if card.visible:
stats_popup.show_soundtrack(card.soundtrack)
break
await _settle()
await on_state.call("popup_SoundtrackStats", stats_popup)
stats_popup.close()
_main.show_tab(&"quiz")
await _settle()
## Returns where every Control and Window in [param subtree] is on screen, keyed by its ## Returns where every Control and Window in [param subtree] is on screen, keyed by its
## path from [param main]: [code]{"rect": [x, y, width, height], "visible": bool}[/code]. ## path from [param main]: [code]{"rect": [x, y, width, height], "visible": bool}[/code].
@@ -270,8 +324,6 @@ func _open_popup(popup_name: String) -> Node:
popup.show_grid("Crono") popup.show_grid("Crono")
"WinnerPopup": "WinnerPopup":
popup.show_winner(Settings.player_array[0]) popup.show_winner(Settings.player_array[0])
"StatisticsPopupPanel":
_call("get_statistics")
_: _:
popup.open() popup.open()
return popup return popup
+3 -11
View File
@@ -6,7 +6,7 @@ extends MainWindowTestSuite
const MAIN_WINDOW_CHILDREN: Array[String] = [ const MAIN_WINDOW_CHILDREN: Array[String] = [
"Background", "TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup", "Background", "TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup",
"ShortcutsPopup", "StatisticsPopupPanel", "AboutPopup", "LogPopup", "ShortcutsPopup", "StatisticsView", "AboutPopup", "LogPopup", "SoundtrackStatsPopup",
] ]
## The parts of the quiz and the scene each one comes from. ## The parts of the quiz and the scene each one comes from.
@@ -27,15 +27,14 @@ const LOCKED_BY_SYNC: Array[String] = [
] ]
const TOP_BAR_CHILDREN: Array[String] = [ const TOP_BAR_CHILDREN: Array[String] = [
"TopPanelContainer", "Title", "SyncButton", "SearchButton", "StatisticsButton", "TopPanelContainer", "Title", "SyncButton", "SearchButton", "ShortcutsButton",
"ShortcutsButton", "AboutButton", "QRButton", "AboutButton", "QRButton", "QuizTab", "StatisticsTab",
] ]
## Each top bar button and the popup it opens in the main window. ## Each top bar button and the popup it opens in the main window.
const TOP_BAR_POPUPS: Dictionary = { const TOP_BAR_POPUPS: Dictionary = {
"SearchButton": "SearchPopup", "SearchButton": "SearchPopup",
"SyncButton": "SyncPopup", "SyncButton": "SyncPopup",
"StatisticsButton": "StatisticsPopupPanel",
"ShortcutsButton": "ShortcutsPopup", "ShortcutsButton": "ShortcutsPopup",
"AboutButton": "AboutPopup", "AboutButton": "AboutPopup",
"QRButton": "QRPopup", "QRButton": "QRPopup",
@@ -285,10 +284,3 @@ func test_alt_enter_switches_fullscreen_and_settings_shows_it() -> void:
assert_bool(settings.fullscreen_button.button_pressed).is_equal(!was_fullscreen) assert_bool(settings.fullscreen_button.button_pressed).is_equal(!was_fullscreen)
await _press_keys([KEY_ALT, KEY_ENTER]) await _press_keys([KEY_ALT, KEY_ENTER])
assert_bool(Settings.fullscreen).is_equal(was_fullscreen) assert_bool(Settings.fullscreen).is_equal(was_fullscreen)
func test_statistics_counts_the_soundtracks() -> void:
_top_bar_button("StatisticsButton").pressed.emit()
assert_str((_window.get_node("StatisticsPopupPanel/StatisticsLabel") as Label).text) \
.is_equal("Total amount of soundtracks in the playlist: 2")
+372
View File
@@ -0,0 +1,372 @@
extends MainWindowTestSuite
## The Statistics tab: switching between it and the quiz, the soundtrack cards, sorting,
## searching, the show filter and the popup with all songs of a soundtrack.
const STATISTICS: Array[Dictionary] = [
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5,
"soundtrack_last_played": "2026-09-30T20:00:00+02:00", "songs": [
{"song_name": "Corridors of Time", "times_played": 1},
{"song_name": "Frog's Theme", "times_played": 3},
{"song_name": "Wind Scene", "times_played": 0},
{"song_name": "Battle with Magus", "times_played": 2},
{"song_name": "To Far Away Times", "times_played": 0},
{"song_name": "Schala's Theme", "times_played": 4},
{"song_name": "Peaceful Days", "times_played": 1},
]},
{"soundtrack_uuid": "st-2", "soundtrack_name": "Terranigma", "soundtrack_played": 0,
"songs": [{"song_name": "Underworld", "times_played": 0}]},
{"soundtrack_uuid": "st-3", "soundtrack_name": "earthbound", "soundtrack_played": 9,
"soundtrack_last_played": "2026-10-01T10:00:00Z", "songs": []},
{"soundtrack_uuid": "st-4", "soundtrack_name": "Final Fantasy VI", "soundtrack_played": 1,
"soundtrack_last_played": "2026-08-01T10:00:00Z", "songs": [
{"song_name": "Dancing Mad", "times_played": 1},
]},
]
var _view: StatisticsView
func _before_window_starts() -> void:
_server.respond_json("/statistics/soundtracks", STATISTICS)
func before_test() -> void:
super()
_view = _window.get_node("StatisticsView")
func _open_statistics() -> void:
await _click_at(_center(_window.get_node("TopBar/StatisticsTab")))
await _runner.simulate_frames(2)
func _center(control: Control) -> Vector2:
return control.get_global_rect().get_center()
func _shown_names() -> Array[String]:
var names: Array[String] = []
for card: SoundtrackCard in _view.cards:
if card.visible:
names.append(card.soundtrack.soundtrack_name)
return names
func _card(soundtrack_name: String) -> SoundtrackCard:
for card: SoundtrackCard in _view.cards:
if card.soundtrack.soundtrack_name == soundtrack_name:
return card
return null
func _visible_texts(list: VBoxContainer) -> Array[String]:
var texts: Array[String] = []
for label: Label in list.get_children():
if label.visible:
texts.append(label.text)
return texts
# Tabs
func test_quiz_tab_is_chosen_at_startup() -> void:
assert_bool(_view.visible).is_false()
assert_bool((_window.get_node("TopBar/QuizTab") as Button).button_pressed).is_true()
assert_int(_server.count("/statistics/soundtracks")).is_equal(0)
func test_clicking_statistics_tab_shows_statistics_instead_of_the_quiz() -> void:
await _open_statistics()
assert_bool(_view.visible).is_true()
assert_bool(_quiz.visible).is_false()
assert_bool((_window.get_node("TopBar/StatisticsTab") as Button).button_pressed).is_true()
assert_bool((_window.get_node("TopBar/QuizTab") as Button).button_pressed).is_false()
assert_vector(_view.get_global_rect().position).is_equal(Vector2(0, 56))
assert_vector(_view.get_global_rect().size).is_equal(Vector2(1920, 1024))
func test_clicking_quiz_tab_goes_back_to_the_quiz() -> void:
await _open_statistics()
await _click_at(_center(_window.get_node("TopBar/QuizTab")))
assert_bool(_view.visible).is_false()
assert_bool(_quiz.visible).is_true()
assert_bool((_window.get_node("TopBar/QuizTab") as Button).button_pressed).is_true()
func test_clicks_on_statistics_do_not_reach_the_quiz_behind() -> void:
await _open_statistics()
var next_button: Button = _quiz.get_node("SongControls/NextButton")
await _click_at(_center(next_button))
assert_int(Playlist.currently_playing_song).is_equal(-1)
func test_song_keeps_playing_behind_statistics() -> void:
await _play_first_song()
await _open_statistics()
assert_int(Playlist.currently_playing_song).is_equal(0)
assert_bool((_quiz.get_node("MusicPlayer") as MusicPlayer).audio_player.stream_paused) \
.is_false()
func test_statistics_are_loaded_again_each_time_the_tab_opens() -> void:
await _open_statistics()
await _click_at(_center(_window.get_node("TopBar/QuizTab")))
await _open_statistics()
assert_int(_server.count("/statistics/soundtracks")).is_equal(2)
assert_int(_view.cards.size()).is_equal(STATISTICS.size())
func test_statistics_are_loaded_again_after_a_sync() -> void:
await _open_statistics()
_window.get_node("SyncPopup").sync_finished.emit()
assert_int(_server.count("/statistics/soundtracks")).is_equal(2)
# Cards
func test_one_card_per_soundtrack_sorted_a_to_z() -> void:
await _open_statistics()
assert_array(_shown_names()).contains_exactly(
["Chrono Trigger", "earthbound", "Final Fantasy VI", "Terranigma"])
assert_str(_view.count_label.text).is_equal("4 soundtracks")
func test_card_shows_plays_last_played_and_most_and_least_played_songs() -> void:
await _open_statistics()
var card: SoundtrackCard = _card("Chrono Trigger")
assert_str(card.name_label.text).is_equal("Chrono Trigger")
assert_str(card.played_label.text).is_equal("Played 5 times")
assert_str(card.last_played_label.text).is_equal("Last played 2026-09-30")
assert_array(_visible_texts(card.most_played_list)).contains_exactly(
["4 × Schala's Theme", "3 × Frog's Theme", "2 × Battle with Magus"])
assert_array(_visible_texts(card.least_played_list)).contains_exactly(
["0 × To Far Away Times", "0 × Wind Scene", "1 × Corridors of Time"])
func test_card_of_never_played_soundtrack() -> void:
await _open_statistics()
var card: SoundtrackCard = _card("Terranigma")
assert_str(card.played_label.text).is_equal("Never played")
assert_bool(card.last_played_label.visible).is_false()
assert_array(_visible_texts(card.most_played_list)).contains_exactly(["0 × Underworld"])
assert_bool(card.least_played_header.visible).is_false()
func test_card_without_songs() -> void:
await _open_statistics()
var card: SoundtrackCard = _card("earthbound")
assert_str(card.most_played_header.text).is_equal("No songs")
assert_array(_visible_texts(card.most_played_list)).is_empty()
assert_bool(card.least_played_header.visible).is_false()
# Sorting, searching and filtering
func test_sort_orders() -> void:
await _open_statistics()
var expected: Dictionary = {
StatisticsView.SortOrder.Z_TO_A:
["Terranigma", "Final Fantasy VI", "earthbound", "Chrono Trigger"],
StatisticsView.SortOrder.MOST_PLAYED:
["earthbound", "Chrono Trigger", "Final Fantasy VI", "Terranigma"],
StatisticsView.SortOrder.LEAST_PLAYED:
["Terranigma", "Final Fantasy VI", "Chrono Trigger", "earthbound"],
StatisticsView.SortOrder.A_TO_Z:
["Chrono Trigger", "earthbound", "Final Fantasy VI", "Terranigma"],
}
for order: StatisticsView.SortOrder in expected:
_view.sort_option.select(order)
_view.sort_option.item_selected.emit(order)
assert_array(_shown_names()).contains_exactly(expected[order])
# The grid shows the cards in the same order
for i: int in _view.cards.size():
assert_int(_view.cards[i].get_index()).is_equal(i)
func test_sort_by_plays_breaks_ties_by_name() -> void:
var tied: Array[Dictionary] = [
{"soundtrack_name": "Lufia II", "soundtrack_played": 2},
{"soundtrack_name": "Earthbound", "soundtrack_played": 2},
]
_server.respond_json("/statistics/soundtracks", tied)
await _open_statistics()
_view.sort_option.select(StatisticsView.SortOrder.MOST_PLAYED)
_view.apply_sort()
assert_array(_shown_names()).contains_exactly(["Earthbound", "Lufia II"])
func test_typing_in_search_filters_cards_and_keeps_the_sort() -> void:
await _open_statistics()
_view.sort_option.select(StatisticsView.SortOrder.Z_TO_A)
_view.apply_sort()
# What typing does; the test runner's key presses type every letter twice
_view.search_field.text = "tri"
_view.search_field.text_changed.emit("tri")
assert_str(_view.search_field.text).is_equal("tri")
# "Chrono Trigger" contains it, "Terranigma" has the letters in order
assert_array(_shown_names()).contains_exactly(["Terranigma", "Chrono Trigger"])
assert_str(_view.count_label.text).is_equal("2 of 4 soundtracks")
func test_search_uses_the_search_popup_matching() -> void:
await _open_statistics()
_view.search_field.text = "finalfantasy"
_view.apply_filter()
assert_array(_shown_names()).contains_exactly(["Final Fantasy VI"])
func test_clear_button_shows_all_cards() -> void:
await _open_statistics()
_view.search_field.text = "chrono"
_view.apply_filter()
await _click_at(_center(_view.clear_button))
assert_str(_view.search_field.text).is_empty()
assert_int(_shown_names().size()).is_equal(4)
func test_show_played_and_never_played() -> void:
await _open_statistics()
_view.show_option.select(StatisticsView.ShowFilter.PLAYED)
_view.show_option.item_selected.emit(StatisticsView.ShowFilter.PLAYED)
assert_array(_shown_names()).contains_exactly(
["Chrono Trigger", "earthbound", "Final Fantasy VI"])
assert_str(_view.count_label.text).is_equal("3 of 4 soundtracks")
_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED)
_view.show_option.item_selected.emit(StatisticsView.ShowFilter.NEVER_PLAYED)
assert_array(_shown_names()).contains_exactly(["Terranigma"])
assert_str(_view.count_label.text).is_equal("1 of 4 soundtracks")
_view.show_option.select(StatisticsView.ShowFilter.ALL)
_view.show_option.item_selected.emit(StatisticsView.ShowFilter.ALL)
assert_int(_shown_names().size()).is_equal(4)
assert_str(_view.count_label.text).is_equal("4 soundtracks")
func test_show_filter_and_search_together() -> void:
await _open_statistics()
_view.show_option.select(StatisticsView.ShowFilter.PLAYED)
_view.search_field.text = "t"
_view.apply_filter()
# Terranigma matches the search but has never been played
assert_array(_shown_names()).contains_exactly(
["Chrono Trigger", "earthbound", "Final Fantasy VI"])
func test_filters_stay_when_statistics_are_loaded_again() -> void:
await _open_statistics()
_view.show_option.select(StatisticsView.ShowFilter.NEVER_PLAYED)
_view.sort_option.select(StatisticsView.SortOrder.Z_TO_A)
_view.refresh()
assert_array(_shown_names()).contains_exactly(["Terranigma"])
assert_str(_view.cards[0].soundtrack.soundtrack_name).is_equal("Terranigma")
# Popup
func test_clicking_a_card_shows_all_its_songs_most_played_first() -> void:
await _open_statistics()
var popup: SoundtrackStatsPopup = _window.get_node("SoundtrackStatsPopup")
await _click_at(_center(_card("Chrono Trigger")))
assert_bool(popup.visible).is_true()
assert_str(popup.title_label.text).is_equal("Chrono Trigger")
assert_str(popup.summary_label.text) \
.is_equal("Played 5 times · Last played 2026-09-30 · 7 songs")
var rows: Array[String] = []
for row: ListRow in popup.song_list.get_children():
rows.append(row.text)
assert_array(rows).contains_exactly([
"4 × Schala's Theme", "3 × Frog's Theme", "2 × Battle with Magus",
"1 × Corridors of Time", "1 × Peaceful Days", "0 × To Far Away Times", "0 × Wind Scene",
])
func test_clicking_outside_the_popup_closes_it() -> void:
await _open_statistics()
var popup: SoundtrackStatsPopup = _window.get_node("SoundtrackStatsPopup")
await _click_at(_center(_card("Chrono Trigger")))
await _runner.simulate_frames(1)
await _click_at(Vector2(100, 1000))
assert_bool(popup.visible).is_false()
assert_bool(_view.visible).is_true()
# Loading
func test_failed_load_shows_error_and_retry_loads() -> void:
_server.respond_json("/statistics/soundtracks", {"error": "Internal Server Error"}, 500)
await _open_statistics()
assert_str(_view.status_label.text).is_equal("Could not load statistics")
assert_bool(_view.retry_button.visible).is_true()
assert_array(_view.cards).is_empty()
_server.respond_json("/statistics/soundtracks", STATISTICS)
await _click_at(_center(_view.retry_button))
assert_str(_view.status_label.text).is_empty()
assert_bool(_view.retry_button.visible).is_false()
assert_int(_view.cards.size()).is_equal(4)
func test_shows_loading_until_the_server_answers() -> void:
_server.hold("/statistics/soundtracks")
await _open_statistics()
assert_str(_view.status_label.text).is_equal("Loading statistics…")
_server.release("/statistics/soundtracks")
assert_str(_view.status_label.text).is_empty()
assert_int(_view.cards.size()).is_equal(4)
func test_failed_reload_keeps_the_cards() -> void:
await _open_statistics()
_server.respond_json("/statistics/soundtracks", [], 500)
_view.refresh()
assert_int(_view.cards.size()).is_equal(4)
assert_bool(_view.retry_button.visible).is_false()
func test_fewer_soundtracks_after_reload_removes_cards() -> void:
await _open_statistics()
_server.respond_json("/statistics/soundtracks", STATISTICS.slice(0, 2))
_view.refresh()
assert_int(_view.cards.size()).is_equal(2)
assert_int(_view.card_grid.get_child_count()).is_equal(2)
@@ -0,0 +1 @@
uid://bmq2a4by6wjpq
+5 -1
View File
@@ -7,7 +7,7 @@ const THEME_PATH: String = "res://themes/main_theme.tres"
## Folders with the game's scenes and scripts. ## Folders with the game's scenes and scripts.
const GAME_FOLDERS: Array[String] = [ const GAME_FOLDERS: Array[String] = [
"res://main", "res://quiz", "res://player", "res://popups", "res://common", "res://data", "res://main", "res://quiz", "res://player", "res://popups", "res://common", "res://data",
"res://server", "res://server", "res://statistics",
] ]
## The named styles the game uses, and the type each one is based on. ## The named styles the game uses, and the type each one is based on.
@@ -30,6 +30,10 @@ const STYLES: Dictionary = {
"HighlightMedium": "Label", "HighlightMedium": "Label",
"SongRowLabel": "Label", "SongRowLabel": "Label",
"SongRowLabelPlaying": "Label", "SongRowLabelPlaying": "Label",
"StatisticsCard": "PanelContainer",
"StatisticsCardHover": "PanelContainer",
"StatisticsGrid": "GridContainer",
"TabButton": "Button",
} }
var _theme: Theme = load(THEME_PATH) var _theme: Theme = load(THEME_PATH)
+190
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bbuyu23x8rxbv
+429 -14
View File
@@ -1,5 +1,6 @@
extends GdUnitTestSuite 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 var _server: FakeHttpTransport
@@ -12,16 +13,50 @@ func after_test() -> void:
_server.uninstall() _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"}] var songs: Array = [{"SongNo": 0, "Soundtrack": "Chrono Trigger", "Song": "Corridors of Time"}]
_server.respond_json("/music/list", songs) _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(received.size()).is_equal(1)
assert_int(int(received[0].SongNo)).is_equal(0) assert_int(received[0].song_no).is_equal(0)
assert_str(received[0].Soundtrack).is_equal("Chrono Trigger") 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: 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()) _server.respond_bytes("/music/info", "not json".to_utf8_buffer())
var called: Array[bool] = [false] 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() assert_bool(called[0]).is_false()
@@ -80,7 +115,7 @@ func test_invalid_json_calls_on_failed() -> void:
var outcome: Array[String] = [] var outcome: Array[String] = []
MusicServerClient.fetch_song_info( MusicServerClient.fetch_song_info(
func(_data: Variant) -> void: outcome.append("done"), func(_data: SongInfo) -> void: outcome.append("done"),
func() -> void: outcome.append("failed")) func() -> void: outcome.append("failed"))
assert_array(outcome).contains_exactly(["failed"]) assert_array(outcome).contains_exactly(["failed"])
@@ -122,17 +157,389 @@ func test_character_name_is_url_encoded() -> void:
assert_array(_server.requested_paths).contains_exactly(["/character?name=Paper%20Mario.png"]) 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 var previous_path: String = Settings.default_path
Settings.default_path = "http://localhost:8080" Settings.default_path = "http://localhost:8080"
var requested_urls: Array[String] = [] var requested_urls: Array[String] = []
var recording_transport: HttpTransport = _RecordingTransport.new(requested_urls) MusicServerClient.transport = _RecordingTransport.new(requested_urls)
MusicServerClient.transport = recording_transport
MusicServerClient.fetch_song_info(func(_data: Variant) -> void: pass) MusicServerClient.fetch_song_info(func(_data: SongInfo) -> void: pass)
Settings.default_path = previous_path 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_soundtrack_statistics(noop),
"/statistics/soundtracks", HTTPClient.METHOD_GET],
[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_soundtrack_statistics_are_typed() -> void:
_server.respond_json("/statistics/soundtracks", [
{"soundtrack_name": "Chrono Trigger", "soundtrack_played": 2,
"songs": [{"song_name": "Frog's Theme", "times_played": 2}]},
{"soundtrack_name": "Lufia II", "soundtrack_played": 0, "songs": []},
])
var received: Array[SoundtrackWithSongs] = []
MusicServerClient.fetch_soundtrack_statistics(
func(list: Array[SoundtrackWithSongs]) -> void: received.append_array(list))
assert_int(received.size()).is_equal(2)
assert_str(received[0].songs[0].song_name).is_equal("Frog's Theme")
assert_int(received[1].soundtrack_played).is_equal(0)
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",
"/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", "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", "failed"])
func _call_every_statistics_endpoint(on_done: Callable, on_failed: Callable) -> void:
MusicServerClient.fetch_statistics_summary(on_done, on_failed)
MusicServerClient.fetch_soundtrack_statistics(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: class _RecordingTransport:
@@ -144,5 +551,13 @@ class _RecordingTransport:
super(null) super(null)
_urls = urls _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) _urls.append(url)
if url.ends_with("/token"):
on_complete.call(200, JSON.stringify({"token": "t"}).to_utf8_buffer())
@@ -1,14 +1,13 @@
extends GdUnitTestSuite extends GdUnitTestSuite
## How the search popup finds and orders matching soundtracks. ## How SoundtrackSearch, used by the Search popup and the statistics view, finds and
## orders matching soundtracks.
const SearchWindowScript: GDScript = preload("res://popups/search_popup.gd") var _search: SoundtrackSearch
var _search: Control
func before_test() -> void: func before_test() -> void:
_search = auto_free(SearchWindowScript.new()) _search = SoundtrackSearch.new()
_search.set_soundtracks([ _search.set_names([
"Chrono Trigger", "Chrono Trigger",
"Final Fantasy VI", "Final Fantasy VI",
"Mario & Luigi: Superstar Saga", "Mario & Luigi: Superstar Saga",
@@ -60,5 +59,20 @@ func test_no_matches() -> void:
func test_clean_term() -> void: func test_clean_term() -> void:
assert_str(_search.clean_term("Rock 'n Roll Racing")).is_equal("rockandrollracing") assert_str(SoundtrackSearch.clean_term("Rock 'n Roll Racing")).is_equal("rockandrollracing")
assert_str(_search.clean_term("Ys I & II")).is_equal("ysiandii") assert_str(SoundtrackSearch.clean_term("Ys I & II")).is_equal("ysiandii")
func test_search_popup_searches_with_soundtrack_search() -> void:
var popup: SearchPopup = auto_free(load("res://popups/SearchPopup.tscn").instantiate())
add_child(popup)
popup.set_soundtracks(["Chrono Trigger", "Super Mario 64"])
popup.show_all_soundtracks()
popup.perform_search("smr")
var shown: Array[String] = []
for label: Label in popup.search_list.get_children():
if label.visible:
shown.append(label.text)
assert_array(shown).contains_exactly(["Super Mario 64"])
File renamed without changes.
+3 -2
View File
@@ -42,8 +42,9 @@ inspector or `theme_type_variation` in code:
| `TextMedium` | Label | Rows in the sync result (18) | | `TextMedium` | Label | Rows in the sync result (18) |
| `HighlightLarge`, `HighlightMedium` | Label | Orange text: sync headers and every other sync row | | `HighlightLarge`, `HighlightMedium` | Label | Orange text: sync headers and every other sync row |
| `SongRowLabel`, `SongRowLabelPlaying` | Label | A song's text, and the one that is playing | | `SongRowLabel`, `SongRowLabelPlaying` | Label | A song's text, and the one that is playing |
| `TabButton` | Button | The Quiz and Statistics tabs; the chosen one is orange |
The **PopupPanel** type itself is styled too, for the Statistics popup. | `StatisticsCard`, `StatisticsCardHover` | PanelContainer | A soundtrack card in the statistics, and the one under the mouse |
| `StatisticsGrid` | GridContainer | The grid of soundtrack cards, with the space between them |
**Palette**: colours used from code. **Palette**: colours used from code.
+54
View File
@@ -703,6 +703,50 @@ vertical = true
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2mdtn"] [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2mdtn"]
bg_color = Color(0.3, 0.3, 0.3, 1) bg_color = Color(0.3, 0.3, 0.3, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tabpressed"]
content_margin_left = 8.0
content_margin_top = 4.0
content_margin_right = 8.0
content_margin_bottom = 4.0
bg_color = Color(1, 0.5, 0, 0.85)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
corner_detail = 5
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_statscard"]
content_margin_left = 12.0
content_margin_top = 10.0
content_margin_right = 12.0
content_margin_bottom = 10.0
bg_color = Color(0.16, 0.16, 0.16, 1)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(0.3, 0.3, 0.3, 1)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_statscardhover"]
content_margin_left = 12.0
content_margin_top = 10.0
content_margin_right = 12.0
content_margin_bottom = 10.0
bg_color = Color(0.2, 0.2, 0.2, 1)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 0.5, 0, 1)
corner_radius_top_left = 6
corner_radius_top_right = 6
corner_radius_bottom_right = 6
corner_radius_bottom_left = 6
[resource] [resource]
Button/colors/font_color = Color(0.875, 0.875, 0.875, 1) Button/colors/font_color = Color(0.875, 0.875, 0.875, 1)
Button/colors/font_disabled_color = Color(0.875, 0.875, 0.875, 0.5) Button/colors/font_disabled_color = Color(0.875, 0.875, 0.875, 0.5)
@@ -865,6 +909,16 @@ SongRowLabelPlaying/base_type = &"Label"
SongRowLabelPlaying/colors/font_color = Color(1, 0.5, 0, 1) SongRowLabelPlaying/colors/font_color = Color(1, 0.5, 0, 1)
SongRowLabelPlaying/font_sizes/font_size = 20 SongRowLabelPlaying/font_sizes/font_size = 20
SongRowLabelPlaying/styles/normal = SubResource("StyleBoxFlat_lkqs8") SongRowLabelPlaying/styles/normal = SubResource("StyleBoxFlat_lkqs8")
StatisticsCard/base_type = &"PanelContainer"
StatisticsCard/styles/panel = SubResource("StyleBoxFlat_statscard")
StatisticsCardHover/base_type = &"PanelContainer"
StatisticsCardHover/styles/panel = SubResource("StyleBoxFlat_statscardhover")
StatisticsGrid/base_type = &"GridContainer"
StatisticsGrid/constants/h_separation = 16
StatisticsGrid/constants/v_separation = 16
TabButton/base_type = &"Button"
TabButton/styles/hover_pressed = SubResource("StyleBoxFlat_tabpressed")
TabButton/styles/pressed = SubResource("StyleBoxFlat_tabpressed")
TextEdit/colors/background_color = Color(0, 0, 0, 0) TextEdit/colors/background_color = Color(0, 0, 0, 0)
TextEdit/colors/caret_background_color = Color(0, 0, 0, 1) TextEdit/colors/caret_background_color = Color(0, 0, 0, 1)
TextEdit/colors/caret_color = Color(0.875, 0.875, 0.875, 1) TextEdit/colors/caret_color = Color(0.875, 0.875, 0.875, 1)
+262
View File
@@ -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 [-- <spec>]
##
## [code]<spec>[/code] defaults to [code]server/openapi.json[/code]. To update it, run
## [code]just openapi[/code] in MusicServer, or save [code]<server>/openapi.json[/code]
## from a running server. Each schema becomes [code]server/generated/<name>.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))
+1
View File
@@ -0,0 +1 @@
uid://cummnobkmb3le