Show soundtrack covers and edit alternative names (#49)
Covers: statistics cards show the soundtrack's cover on the left, and the
statistics popup shows it large. Until it arrives, and without a cover, the
soundtrack's initials stand in. Covers load only for cards on or near the
screen, three at a time, and are cached in covers/ next to the settings
(CoverCache), downloaded again only when cover_modified changes. The index and
file handling are shared with CharacterCache in a new FileCache.
Alternative names: the popup shows them and has an editor (add, remove, save)
that sends PUT /soundtracks/{uuid}/alternative-names. The statistics search and
the Search popup find soundtracks by them; Search shows the matching name after
the real one. Search now loads /soundtracks, falling back to the plain list.
The API types are regenerated from the server's spec, with a SoundtrackCover
theme style in every theme.
Closes #49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a735c515bf
commit
ef6ac8f1fd
37 files changed
+8029
-1763
No files matched your search
+14
-62
@@ -14,15 +14,15 @@ extends RefCounted
|
||||
## can't be reached at all, the cached pictures are used as they are.
|
||||
|
||||
const FOLDER_NAME: String = "characters"
|
||||
const INDEX_FILE_NAME: String = "index.json"
|
||||
const INDEX_FILE_NAME: String = FileCache.INDEX_FILE_NAME
|
||||
## How long a picture is used before downloading it again, when the server can't tell
|
||||
## whether it changed.
|
||||
const MAX_AGE_SECONDS: int = 7 * 24 * 60 * 60
|
||||
## The change time of a picture when the server can't tell it.
|
||||
const UNKNOWN_TIME: int = -1
|
||||
const UNKNOWN_TIME: int = FileCache.UNKNOWN_TIME
|
||||
|
||||
# What index.json holds: name -> {"modified": int, "downloaded": int}
|
||||
var _index: Dictionary = {}
|
||||
# The pictures on disk and their index
|
||||
var _files: FileCache = FileCache.new(FOLDER_NAME)
|
||||
# When the server last changed each picture on the list, or UNKNOWN_TIME
|
||||
var _modified: Dictionary[String, int] = {}
|
||||
# True when the server could not be reached and only cached pictures are shown
|
||||
@@ -38,7 +38,7 @@ static func folder() -> String:
|
||||
## [code]Array[String][/code]. Cached pictures of characters no longer on the server are
|
||||
## deleted.
|
||||
func load_list(on_done: Callable) -> void:
|
||||
_load_index()
|
||||
_files.load_index()
|
||||
_cache_only = false
|
||||
var details_fetched: Callable = func details_fetched(details: Array) -> void:
|
||||
_modified.clear()
|
||||
@@ -51,9 +51,8 @@ func load_list(on_done: Callable) -> void:
|
||||
## Calls [param on_done] with the picture of [param character_name] as an [Image], from
|
||||
## disk when the cached one is still fresh, otherwise from the server.
|
||||
func fetch_image(character_name: String, on_done: Callable) -> void:
|
||||
var path: String = _file_path(character_name)
|
||||
if (_cache_only || _is_fresh(character_name)) && FileAccess.file_exists(path):
|
||||
var cached: Image = CharacterCard.image_from_bytes(FileAccess.get_file_as_bytes(path))
|
||||
if (_cache_only || _is_fresh(character_name)) && _files.has_file(character_name):
|
||||
var cached: Image = CharacterCard.image_from_bytes(_files.read(character_name))
|
||||
if cached != null:
|
||||
on_done.call(cached)
|
||||
return
|
||||
@@ -63,7 +62,7 @@ func fetch_image(character_name: String, on_done: Callable) -> void:
|
||||
if picture == null:
|
||||
GameLog.warning("Could not load the picture of ", character_name)
|
||||
return
|
||||
_store(character_name, bytes)
|
||||
_files.store(character_name, bytes, _modified.get(character_name, UNKNOWN_TIME))
|
||||
on_done.call(picture)
|
||||
MusicServerClient.fetch_character_image(character_name, image_fetched)
|
||||
|
||||
@@ -84,71 +83,24 @@ func _load_cached_only(on_done: Callable) -> void:
|
||||
GameLog.warning("Could not get the characters from the server, showing the cached ones")
|
||||
_cache_only = true
|
||||
var names: Array[String] = []
|
||||
for character_name: String in _index:
|
||||
if FileAccess.file_exists(_file_path(character_name)):
|
||||
for character_name: String in _files.keys():
|
||||
if _files.has_file(character_name):
|
||||
names.append(character_name)
|
||||
names.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0)
|
||||
on_done.call(names)
|
||||
|
||||
|
||||
func _list_loaded(on_done: Callable) -> void:
|
||||
var removed: bool = false
|
||||
for character_name: String in _index.keys():
|
||||
if !_modified.has(character_name):
|
||||
DirAccess.remove_absolute(_file_path(character_name))
|
||||
_index.erase(character_name)
|
||||
removed = true
|
||||
if removed:
|
||||
_save_index()
|
||||
_files.keep_only(_modified)
|
||||
var names: Array[String] = []
|
||||
names.assign(_modified.keys())
|
||||
on_done.call(names)
|
||||
|
||||
|
||||
func _is_fresh(character_name: String) -> bool:
|
||||
if !_index.has(character_name):
|
||||
if !_files.keys().has(character_name):
|
||||
return false
|
||||
var entry: Dictionary = _index[character_name]
|
||||
var modified: int = _modified.get(character_name, UNKNOWN_TIME)
|
||||
if modified != UNKNOWN_TIME:
|
||||
return int(entry.get("modified", UNKNOWN_TIME)) == modified
|
||||
var age: float = Time.get_unix_time_from_system() - float(entry.get("downloaded", 0))
|
||||
return age < MAX_AGE_SECONDS
|
||||
|
||||
|
||||
func _store(character_name: String, bytes: PackedByteArray) -> void:
|
||||
DirAccess.make_dir_recursive_absolute(folder())
|
||||
var file: FileAccess = FileAccess.open(_file_path(character_name), FileAccess.WRITE)
|
||||
if file == null:
|
||||
GameLog.warning("Could not cache the picture of ", character_name, ": ",
|
||||
error_string(FileAccess.get_open_error()))
|
||||
return
|
||||
file.store_buffer(bytes)
|
||||
file.close()
|
||||
_index[character_name] = {
|
||||
"modified": _modified.get(character_name, UNKNOWN_TIME),
|
||||
"downloaded": int(Time.get_unix_time_from_system()),
|
||||
}
|
||||
_save_index()
|
||||
|
||||
|
||||
func _load_index() -> void:
|
||||
_index = {}
|
||||
var path: String = folder().path_join(INDEX_FILE_NAME)
|
||||
if FileAccess.file_exists(path):
|
||||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||
if parsed is Dictionary:
|
||||
_index = parsed
|
||||
|
||||
|
||||
func _save_index() -> void:
|
||||
var file: FileAccess = FileAccess.open(folder().path_join(INDEX_FILE_NAME),
|
||||
FileAccess.WRITE)
|
||||
if file != null:
|
||||
file.store_string(JSON.stringify(_index, "\t", true))
|
||||
|
||||
|
||||
# The server's names are plain file names, but they are made safe anyway since they
|
||||
# become paths on this computer
|
||||
func _file_path(character_name: String) -> String:
|
||||
return folder().path_join(character_name.validate_filename())
|
||||
return _files.modified(character_name) == modified
|
||||
return _files.age(character_name) < MAX_AGE_SECONDS
|
||||
@@ -0,0 +1,95 @@
|
||||
class_name CoverCache
|
||||
extends RefCounted
|
||||
## Soundtrack covers kept on disk in the [code]covers[/code] folder next to the settings,
|
||||
## by soundtrack UUID, so each one is downloaded only when it changed on the server (its
|
||||
## [code]cover_modified[/code] time). See [FileCache].
|
||||
##
|
||||
## Covers are downloaded a few at a time, in the order they are asked for, so opening the
|
||||
## statistics doesn't send a request for every soundtrack at once.
|
||||
|
||||
const FOLDER_NAME: String = "covers"
|
||||
## How many covers are downloaded at the same time.
|
||||
const MAX_DOWNLOADS: int = 3
|
||||
|
||||
# The covers on disk and their index
|
||||
var _files: FileCache = FileCache.new(FOLDER_NAME)
|
||||
# Covers waiting to be downloaded: [uuid, modified]
|
||||
var _waiting: Array[Array] = []
|
||||
# Who is waiting for each cover that is asked for or downloading: uuid -> Array[Callable]
|
||||
var _callbacks: Dictionary = {}
|
||||
var _downloading: int = 0
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_files.load_index()
|
||||
|
||||
|
||||
## The folder the covers are kept in.
|
||||
static func folder() -> String:
|
||||
return Settings.config_dir().path_join(FOLDER_NAME)
|
||||
|
||||
|
||||
## Deletes the covers of soundtracks that are not in [param covers] (UUID -> cover
|
||||
## time), and those whose soundtrack no longer has a cover.
|
||||
func keep_only(covers: Dictionary) -> void:
|
||||
var kept: Dictionary = {}
|
||||
for uuid: String in covers:
|
||||
if int(covers[uuid]) > 0:
|
||||
kept[uuid] = true
|
||||
_files.keep_only(kept)
|
||||
|
||||
|
||||
## Calls [param on_done] with the cover of the soundtrack [param uuid] as an [Image]:
|
||||
## from disk when the cached one has the server's time [param modified], otherwise once
|
||||
## it has been downloaded. Nothing is called when the soundtrack has no cover
|
||||
## ([param modified] 0) or the download fails.
|
||||
func fetch_cover(uuid: String, modified: int, on_done: Callable) -> void:
|
||||
if modified <= 0 || uuid.is_empty():
|
||||
return
|
||||
if _files.has_file(uuid) && _files.modified(uuid) == modified:
|
||||
var cached: Image = CharacterCard.image_from_bytes(_files.read(uuid))
|
||||
if cached != null:
|
||||
on_done.call(cached)
|
||||
return
|
||||
if _callbacks.has(uuid):
|
||||
_callbacks[uuid].append(on_done)
|
||||
return
|
||||
_callbacks[uuid] = [on_done]
|
||||
_waiting.append([uuid, modified])
|
||||
_download_next()
|
||||
|
||||
|
||||
## How many covers are waiting for their turn or downloading.
|
||||
func busy() -> int:
|
||||
return _callbacks.size()
|
||||
|
||||
|
||||
func _download_next() -> void:
|
||||
while _downloading < MAX_DOWNLOADS && !_waiting.is_empty():
|
||||
var next: Array = _waiting.pop_front()
|
||||
var uuid: String = next[0]
|
||||
_downloading += 1
|
||||
MusicServerClient.fetch_soundtrack_cover(uuid,
|
||||
_on_downloaded.bind(uuid, next[1]), _on_failed.bind(uuid))
|
||||
|
||||
|
||||
func _on_downloaded(bytes: PackedByteArray, uuid: String, modified: int) -> void:
|
||||
_downloading -= 1
|
||||
var cover: Image = CharacterCard.image_from_bytes(bytes)
|
||||
if cover == null:
|
||||
GameLog.warning("The cover of soundtrack ", uuid, " could not be read")
|
||||
else:
|
||||
_files.store(uuid, bytes, modified)
|
||||
for on_done: Callable in _callbacks.get(uuid, []):
|
||||
# A card may be gone by the time its cover arrives
|
||||
if cover != null && on_done.is_valid():
|
||||
on_done.call(cover)
|
||||
_callbacks.erase(uuid)
|
||||
_download_next()
|
||||
|
||||
|
||||
# A 404 means the server has no cover for it yet
|
||||
func _on_failed(uuid: String) -> void:
|
||||
_downloading -= 1
|
||||
_callbacks.erase(uuid)
|
||||
_download_next()
|
||||
@@ -0,0 +1 @@
|
||||
uid://djxm2kkq8hol1
|
||||
@@ -0,0 +1,117 @@
|
||||
class_name FileCache
|
||||
extends RefCounted
|
||||
## Downloaded files kept in a folder next to the settings, with an [code]index.json[/code]
|
||||
## that records for each file when the server last changed it and when it was
|
||||
## downloaded:
|
||||
## [codeblock]
|
||||
## {"Crono.png": {"modified": 1730000000, "downloaded": 1730100000}}
|
||||
## [/codeblock]
|
||||
## Used by [CharacterCache] and [CoverCache], which decide when a file is fresh.
|
||||
|
||||
const INDEX_FILE_NAME: String = "index.json"
|
||||
## The change time of a file when the server can't tell it.
|
||||
const UNKNOWN_TIME: int = -1
|
||||
|
||||
## The folder's name in [method Settings.config_dir].
|
||||
var folder_name: String
|
||||
|
||||
# What index.json holds: key -> {"modified": int, "downloaded": int}
|
||||
var _index: Dictionary = {}
|
||||
|
||||
|
||||
func _init(new_folder_name: String) -> void:
|
||||
folder_name = new_folder_name
|
||||
|
||||
|
||||
## The folder the files are kept in.
|
||||
func folder() -> String:
|
||||
return Settings.config_dir().path_join(folder_name)
|
||||
|
||||
|
||||
## Reads [code]index.json[/code]. A missing or broken index starts empty.
|
||||
func load_index() -> void:
|
||||
_index = {}
|
||||
var path: String = folder().path_join(INDEX_FILE_NAME)
|
||||
if FileAccess.file_exists(path):
|
||||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||
if parsed is Dictionary:
|
||||
_index = parsed
|
||||
|
||||
|
||||
## The keys of the files in the index.
|
||||
func keys() -> Array:
|
||||
return _index.keys()
|
||||
|
||||
|
||||
## Whether the file for [param key] is in the index and on disk.
|
||||
func has_file(key: String) -> bool:
|
||||
return _index.has(key) && FileAccess.file_exists(file_path(key))
|
||||
|
||||
|
||||
## When the server last changed the file for [param key], as recorded when it was
|
||||
## downloaded, or [constant UNKNOWN_TIME].
|
||||
func modified(key: String) -> int:
|
||||
return int(_index.get(key, {}).get("modified", UNKNOWN_TIME))
|
||||
|
||||
|
||||
## How many seconds ago the file for [param key] was downloaded.
|
||||
func age(key: String) -> float:
|
||||
return Time.get_unix_time_from_system() - float(_index.get(key, {}).get("downloaded", 0))
|
||||
|
||||
|
||||
## The file for [param key].
|
||||
func read(key: String) -> PackedByteArray:
|
||||
return FileAccess.get_file_as_bytes(file_path(key))
|
||||
|
||||
|
||||
## Writes [param bytes] as the file for [param key], last changed on the server at
|
||||
## [param modified_time], and saves the index. Returns false if it could not be written.
|
||||
func store(key: String, bytes: PackedByteArray, modified_time: int) -> bool:
|
||||
DirAccess.make_dir_recursive_absolute(folder())
|
||||
var file: FileAccess = FileAccess.open(file_path(key), FileAccess.WRITE)
|
||||
if file == null:
|
||||
GameLog.warning("Could not cache ", key, " in ", folder_name, ": ",
|
||||
error_string(FileAccess.get_open_error()))
|
||||
return false
|
||||
file.store_buffer(bytes)
|
||||
file.close()
|
||||
_index[key] = {
|
||||
"modified": modified_time,
|
||||
"downloaded": int(Time.get_unix_time_from_system()),
|
||||
}
|
||||
save_index()
|
||||
return true
|
||||
|
||||
|
||||
## Deletes the file for [param key] and its entry, and saves the index.
|
||||
func remove(key: String) -> void:
|
||||
DirAccess.remove_absolute(file_path(key))
|
||||
if _index.erase(key):
|
||||
save_index()
|
||||
|
||||
|
||||
## Deletes every file whose key is not in [param kept], e.g. files of things the server
|
||||
## no longer has.
|
||||
func keep_only(kept: Dictionary) -> void:
|
||||
var removed: bool = false
|
||||
for key: String in _index.keys():
|
||||
if !kept.has(key):
|
||||
DirAccess.remove_absolute(file_path(key))
|
||||
_index.erase(key)
|
||||
removed = true
|
||||
if removed:
|
||||
save_index()
|
||||
|
||||
|
||||
func save_index() -> void:
|
||||
DirAccess.make_dir_recursive_absolute(folder())
|
||||
var file: FileAccess = FileAccess.open(folder().path_join(INDEX_FILE_NAME),
|
||||
FileAccess.WRITE)
|
||||
if file != null:
|
||||
file.store_string(JSON.stringify(_index, "\t", true))
|
||||
|
||||
|
||||
## Where the file for [param key] is. Keys come from the server, so they are made safe
|
||||
## as file names.
|
||||
func file_path(key: String) -> String:
|
||||
return folder().path_join(key.validate_filename())
|
||||
@@ -0,0 +1 @@
|
||||
uid://0jfq23w54ihw
|
||||
@@ -5,23 +5,36 @@ extends RefCounted
|
||||
## letters 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.
|
||||
##
|
||||
## A soundtrack is also found by its alternative names, e.g. "FF7" for "Final Fantasy
|
||||
## VII". The results are always the real names; [method matched_alternative] tells which
|
||||
## alternative name matched.
|
||||
|
||||
## Each name as a Dictionary with its original, lowercase and cleaned form.
|
||||
## Each searchable name as a Dictionary: the soundtrack's real name ("original"), the name
|
||||
## searched in ("text"), and its lowercase and cleaned forms.
|
||||
var _names: Array[Dictionary] = []
|
||||
# The alternative name that matched in the last search, by real name
|
||||
var _matched_alternatives: 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:
|
||||
## Replaces the names to search in. [param alternatives] has the other names of a
|
||||
## soundtrack, by its real name.
|
||||
func set_names(names: Array, alternatives: Dictionary = {}) -> void:
|
||||
_names.clear()
|
||||
_matched_alternatives.clear()
|
||||
for soundtrack: String in names:
|
||||
_names.append({
|
||||
"original": soundtrack,
|
||||
"lowercase": soundtrack.to_lower(),
|
||||
"cleaned": clean_term(soundtrack),
|
||||
})
|
||||
_add_name(soundtrack, soundtrack)
|
||||
for alternative: String in alternatives.get(soundtrack, []):
|
||||
_add_name(soundtrack, alternative)
|
||||
|
||||
|
||||
## The alternative name of [param soundtrack_name] that matched in the last
|
||||
## [method find_matches], or an empty String when its real name matched.
|
||||
func matched_alternative(soundtrack_name: String) -> String:
|
||||
return _matched_alternatives.get(soundtrack_name, "")
|
||||
|
||||
|
||||
## Returns the names matching [param search_text], best matches first: plain
|
||||
@@ -41,11 +54,14 @@ func find_matches(search_text: String) -> Array[String]:
|
||||
func(entry: Dictionary) -> bool: return regex.search(entry.cleaned) != null)
|
||||
|
||||
var matches: Array[String] = []
|
||||
_matched_alternatives.clear()
|
||||
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)
|
||||
if entry.text != entry.original:
|
||||
_matched_alternatives[entry.original] = entry.text
|
||||
return matches
|
||||
|
||||
|
||||
@@ -107,6 +123,15 @@ static func clean_term(term: String) -> String:
|
||||
.replace("'n", "and").to_lower()
|
||||
|
||||
|
||||
func _add_name(soundtrack: String, text: String) -> void:
|
||||
_names.append({
|
||||
"original": soundtrack,
|
||||
"text": text,
|
||||
"lowercase": text.to_lower(),
|
||||
"cleaned": clean_term(text),
|
||||
})
|
||||
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -47,6 +47,12 @@ func _ready() -> void:
|
||||
quiz.server_changed.connect(_on_music_source_changed)
|
||||
quiz.event_logged.connect(log_popup.add_log_row)
|
||||
statistics_view.soundtrack_selected.connect(soundtrack_stats_popup.show_soundtrack)
|
||||
soundtrack_stats_popup.covers = statistics_view.covers
|
||||
# New alternative names are found by the statistics search and the Search popup
|
||||
soundtrack_stats_popup.alternative_names_changed.connect(
|
||||
statistics_view.update_alternative_names)
|
||||
soundtrack_stats_popup.alternative_names_changed.connect(
|
||||
func(_soundtrack: Soundtrack) -> void: search_popup.get_list_of_soundtracks())
|
||||
|
||||
# A saved quiz may have been played from a music folder
|
||||
top_bar.update_offline_state()
|
||||
|
||||
@@ -30,19 +30,78 @@ theme_type_variation = &"Title"
|
||||
horizontal_alignment = 1
|
||||
text_overrun_behavior = 3
|
||||
|
||||
[node name="SummaryLabel" type="Label" parent="StatsPanel" unique_id=1213000004]
|
||||
[node name="CoverFrame" type="Panel" parent="StatsPanel"]
|
||||
unique_name_in_owner = true
|
||||
clip_children = 2
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 64.0
|
||||
offset_right = 164.0
|
||||
offset_bottom = 264.0
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"SoundtrackCover"
|
||||
|
||||
[node name="CoverPlaceholder" type="Label" parent="StatsPanel/CoverFrame"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
theme_type_variation = &"TitleMedium"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="CoverPicture" type="TextureRect" parent="StatsPanel/CoverFrame"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
expand_mode = 1
|
||||
stretch_mode = 6
|
||||
|
||||
[node name="Info" type="VBoxContainer" parent="StatsPanel"]
|
||||
layout_mode = 0
|
||||
offset_left = 180.0
|
||||
offset_top = 64.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 264.0
|
||||
|
||||
[node name="SummaryLabel" type="Label" parent="StatsPanel/Info" unique_id=1213000004]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="NamesTitle" type="Label" parent="StatsPanel/Info"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
text = "Also known as"
|
||||
|
||||
[node name="NamesLabel" type="Label" parent="StatsPanel/Info"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"TextMedium"
|
||||
autowrap_mode = 3
|
||||
text_overrun_behavior = 3
|
||||
max_lines_visible = 3
|
||||
|
||||
[node name="EditNamesButton" type="Button" parent="StatsPanel/Info"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 0
|
||||
focus_mode = 0
|
||||
text = "Edit names"
|
||||
|
||||
[node name="ScrollContainer" type="ScrollContainer" parent="StatsPanel" unique_id=1213000005]
|
||||
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_top = 280.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 784.0
|
||||
horizontal_scroll_mode = 0
|
||||
@@ -51,3 +110,73 @@ horizontal_scroll_mode = 0
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="NamesEditor" type="VBoxContainer" parent="StatsPanel"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 0
|
||||
offset_left = 20.0
|
||||
offset_top = 280.0
|
||||
offset_right = 680.0
|
||||
offset_bottom = 784.0
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="EditorTitle" type="Label" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"Title"
|
||||
text = "Alternative names"
|
||||
|
||||
[node name="EditorHelp" type="Label" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
autowrap_mode = 3
|
||||
text = "Other names the soundtrack goes by, e.g. short names or the name in another language. Search finds it by any of them."
|
||||
|
||||
[node name="EditorScroll" type="ScrollContainer" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="EditorNames" type="VBoxContainer" parent="StatsPanel/NamesEditor/EditorScroll"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="AddRow" type="HBoxContainer" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="NewNameField" type="LineEdit" parent="StatsPanel/NamesEditor/AddRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Another name"
|
||||
|
||||
[node name="AddNameButton" type="Button" parent="StatsPanel/NamesEditor/AddRow"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Add"
|
||||
|
||||
[node name="EditorStatus" type="Label" parent="StatsPanel/NamesEditor"]
|
||||
unique_name_in_owner = true
|
||||
visible = false
|
||||
layout_mode = 2
|
||||
theme_type_variation = &"HighlightMedium"
|
||||
autowrap_mode = 3
|
||||
|
||||
[node name="EditorButtons" type="HBoxContainer" parent="StatsPanel/NamesEditor"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 2
|
||||
|
||||
[node name="CancelNamesButton" type="Button" parent="StatsPanel/NamesEditor/EditorButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Cancel"
|
||||
|
||||
[node name="SaveNamesButton" type="Button" parent="StatsPanel/NamesEditor/EditorButtons"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
focus_mode = 0
|
||||
text = "Save"
|
||||
+36
-11
@@ -1,7 +1,8 @@
|
||||
class_name SearchPopup
|
||||
extends BasePopup
|
||||
## Searches every soundtrack on the server as you type, with [SoundtrackSearch], and
|
||||
## highlights the letters that matched.
|
||||
## highlights the letters that matched. Soundtracks are also found by their alternative
|
||||
## names; the alternative name that matched is shown after the real name.
|
||||
|
||||
## Every soundtrack on the server, sorted by name.
|
||||
var soundtracks: Array = []
|
||||
@@ -10,6 +11,8 @@ var _matcher: SoundtrackSearch = SoundtrackSearch.new()
|
||||
# One row per soundtrack, in the order of soundtracks. Rows are reused between searches.
|
||||
var _rows: Array[PanelContainer] = []
|
||||
var _index_of: Dictionary = {}
|
||||
# The other names of each soundtrack, by its real name
|
||||
var _alternatives: Dictionary = {}
|
||||
|
||||
@onready var search_panel: Panel = %SearchPanel
|
||||
@onready var search_field: LineEdit = %SearchField
|
||||
@@ -86,19 +89,25 @@ func get_list_of_soundtracks() -> void:
|
||||
if Settings.is_offline():
|
||||
set_soundtracks(Settings.music_folder.soundtrack_names())
|
||||
return
|
||||
var handle_soundtracks: Callable = func handle_soundtracks(array: Variant) -> void:
|
||||
if typeof(array) == TYPE_ARRAY:
|
||||
set_soundtracks(array)
|
||||
else:
|
||||
GameLog.warning("Unexpected data")
|
||||
MusicServerClient.fetch_all_soundtracks_ordered(handle_soundtracks)
|
||||
var with_names: Callable = func with_names(list: Array[Soundtrack]) -> void:
|
||||
var names: Array[String] = []
|
||||
var alternatives: Dictionary = {}
|
||||
for soundtrack: Soundtrack in list:
|
||||
names.append(soundtrack.name)
|
||||
alternatives[soundtrack.name] = soundtrack.alternative_names
|
||||
names.sort_custom(func(a: String, b: String) -> bool:
|
||||
return a.naturalnocasecmp_to(b) < 0)
|
||||
set_soundtracks(names, alternatives)
|
||||
MusicServerClient.fetch_soundtracks(with_names, _load_names_only)
|
||||
|
||||
|
||||
## Replaces the searchable soundtracks and shows them, keeping the current search.
|
||||
func set_soundtracks(list: Array) -> void:
|
||||
## [param alternatives] has the other names of a soundtrack, by its real name.
|
||||
func set_soundtracks(list: Array, alternatives: Dictionary = {}) -> void:
|
||||
soundtracks = []
|
||||
soundtracks.append_array(list)
|
||||
_matcher.set_names(soundtracks)
|
||||
_alternatives = alternatives
|
||||
_matcher.set_names(soundtracks, alternatives)
|
||||
_index_of.clear()
|
||||
for index: int in range(soundtracks.size() - 1, -1, -1):
|
||||
_index_of[soundtracks[index]] = index
|
||||
@@ -145,9 +154,25 @@ func _make_row() -> PanelContainer:
|
||||
return row
|
||||
|
||||
|
||||
# Servers without /soundtracks only list the names
|
||||
func _load_names_only() -> void:
|
||||
MusicServerClient.fetch_all_soundtracks_ordered(func(array: Variant) -> void:
|
||||
if typeof(array) == TYPE_ARRAY:
|
||||
set_soundtracks(array)
|
||||
else:
|
||||
GameLog.warning("Unexpected data"))
|
||||
|
||||
|
||||
func _show_name(row: PanelContainer, soundtrack_name: String, search_text: String) -> void:
|
||||
var text: String = highlighted_name(soundtrack_name, search_text,
|
||||
get_theme_color(&"font_color", &"HighlightLarge"))
|
||||
var highlight: Color = get_theme_color(&"font_color", &"HighlightLarge")
|
||||
var text: String = highlighted_name(soundtrack_name, search_text, highlight)
|
||||
# Found by another name: show which, dimmer, after the real name
|
||||
var alternative: String = _matcher.matched_alternative(soundtrack_name) \
|
||||
if !search_text.is_empty() else ""
|
||||
if !alternative.is_empty():
|
||||
text += " [color=#%s]%s[/color]" % [
|
||||
get_theme_color(&"default_color", &"SongStatsSecondary").to_html(false),
|
||||
highlighted_name(alternative, search_text, highlight)]
|
||||
var label: RichTextLabel = row.get_child(0)
|
||||
# Setting the same text again would make the label lay it out again for nothing
|
||||
if label.text != text:
|
||||
|
||||
@@ -1,27 +1,173 @@
|
||||
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.
|
||||
## Shows a soundtrack's cover, play statistics and alternative names, and every song in
|
||||
## it, most played first. Opened by clicking a card in the statistics view.
|
||||
##
|
||||
## Edit names opens an editor for the alternative names in place of the song list. Saving
|
||||
## sends them to the server, which search then finds the soundtrack by.
|
||||
|
||||
## Emitted when the alternative names of [param soundtrack] have been saved.
|
||||
signal alternative_names_changed(soundtrack: Soundtrack)
|
||||
|
||||
## The covers on disk, shared with the statistics view. No cover is shown without it.
|
||||
var covers: CoverCache
|
||||
## The soundtrack shown.
|
||||
var soundtrack: SoundtrackWithSongs
|
||||
|
||||
# The names in the editor, saved only when Save is pressed
|
||||
var _edited_names: Array[String] = []
|
||||
|
||||
@onready var title_label: Label = %TitleLabel
|
||||
@onready var summary_label: Label = %SummaryLabel
|
||||
@onready var cover_picture: TextureRect = %CoverPicture
|
||||
@onready var cover_placeholder: Label = %CoverPlaceholder
|
||||
@onready var names_label: Label = %NamesLabel
|
||||
@onready var edit_names_button: Button = %EditNamesButton
|
||||
@onready var song_scroll: ScrollContainer = %ScrollContainer
|
||||
@onready var song_list: VBoxContainer = %SongList
|
||||
@onready var names_editor: VBoxContainer = %NamesEditor
|
||||
@onready var editor_names: VBoxContainer = %EditorNames
|
||||
@onready var new_name_field: LineEdit = %NewNameField
|
||||
@onready var add_name_button: Button = %AddNameButton
|
||||
@onready var editor_status: Label = %EditorStatus
|
||||
@onready var save_names_button: Button = %SaveNamesButton
|
||||
@onready var cancel_names_button: Button = %CancelNamesButton
|
||||
|
||||
|
||||
## 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)
|
||||
func _ready() -> void:
|
||||
edit_names_button.pressed.connect(edit_names)
|
||||
add_name_button.pressed.connect(add_name)
|
||||
new_name_field.text_submitted.connect(func(_text: String) -> void: add_name())
|
||||
save_names_button.pressed.connect(save_names)
|
||||
cancel_names_button.pressed.connect(stop_editing)
|
||||
|
||||
|
||||
## Shows [param shown] and opens the popup.
|
||||
func show_soundtrack(shown: SoundtrackWithSongs) -> void:
|
||||
soundtrack = shown
|
||||
stop_editing()
|
||||
title_label.text = shown.soundtrack_name
|
||||
var summary: PackedStringArray = [SoundtrackCard.played_text(shown.soundtrack_played)]
|
||||
var last_played: String = SoundtrackCard.last_played_text(shown.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.append("%d %s" % [shown.songs.size(),
|
||||
"song" if shown.songs.size() == 1 else "songs"])
|
||||
summary_label.text = " · ".join(summary)
|
||||
_show_names()
|
||||
|
||||
cover_placeholder.text = SoundtrackCard.initials(shown.soundtrack_name)
|
||||
_show_cover(null)
|
||||
if covers != null:
|
||||
covers.fetch_cover(shown.soundtrack_uuid, shown.cover_modified,
|
||||
_show_cover_of.bind(shown.soundtrack_uuid))
|
||||
|
||||
Settings.delete_children(song_list)
|
||||
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(soundtrack.songs)
|
||||
var songs: Array[SongInfoForStats] = SoundtrackCard.songs_by_plays(shown.songs)
|
||||
for row: int in songs.size():
|
||||
song_list.add_child(ListRow.create(SoundtrackCard.song_text(songs[row]), row,
|
||||
&"TextLarge"))
|
||||
open()
|
||||
|
||||
|
||||
## Opens the editor for the alternative names, in place of the song list.
|
||||
func edit_names() -> void:
|
||||
_edited_names.assign(soundtrack.alternative_names)
|
||||
new_name_field.text = ""
|
||||
editor_status.visible = false
|
||||
_show_edited_names()
|
||||
names_editor.visible = true
|
||||
song_scroll.visible = false
|
||||
edit_names_button.disabled = true
|
||||
new_name_field.grab_focus()
|
||||
|
||||
|
||||
## Closes the editor without saving.
|
||||
func stop_editing() -> void:
|
||||
names_editor.visible = false
|
||||
song_scroll.visible = true
|
||||
edit_names_button.disabled = false
|
||||
_set_saving(false)
|
||||
|
||||
|
||||
## Adds the name in the field to the edited names, unless it is empty, the soundtrack's
|
||||
## own name or already there.
|
||||
func add_name() -> void:
|
||||
var new_name: String = new_name_field.text.strip_edges()
|
||||
if new_name.is_empty() || new_name.to_lower() == soundtrack.soundtrack_name.to_lower() \
|
||||
|| _edited_names.any(func(name: String) -> bool:
|
||||
return name.to_lower() == new_name.to_lower()):
|
||||
return
|
||||
_edited_names.append(new_name)
|
||||
new_name_field.text = ""
|
||||
_show_edited_names()
|
||||
|
||||
|
||||
## Takes [param removed] out of the edited names.
|
||||
func remove_name(removed: String) -> void:
|
||||
_edited_names.erase(removed)
|
||||
_show_edited_names()
|
||||
|
||||
|
||||
## Sends the edited names to the server. The editor closes when they are saved.
|
||||
func save_names() -> void:
|
||||
# A name typed but not added yet is meant to be saved too
|
||||
add_name()
|
||||
_set_saving(true)
|
||||
MusicServerClient.set_alternative_names(soundtrack.soundtrack_uuid, _edited_names,
|
||||
_on_names_saved, _on_names_not_saved)
|
||||
|
||||
|
||||
func _on_names_saved(updated: Soundtrack) -> void:
|
||||
soundtrack.alternative_names = updated.alternative_names
|
||||
_show_names()
|
||||
stop_editing()
|
||||
alternative_names_changed.emit(updated)
|
||||
|
||||
|
||||
func _on_names_not_saved() -> void:
|
||||
_set_saving(false)
|
||||
editor_status.text = "Could not save the names. Try again."
|
||||
editor_status.visible = true
|
||||
|
||||
|
||||
func _set_saving(saving: bool) -> void:
|
||||
save_names_button.disabled = saving
|
||||
cancel_names_button.disabled = saving
|
||||
save_names_button.text = "Saving…" if saving else "Save"
|
||||
|
||||
|
||||
func _show_names() -> void:
|
||||
var names: Array[String] = soundtrack.alternative_names
|
||||
names_label.text = ", ".join(names) if !names.is_empty() else "No other names yet"
|
||||
|
||||
|
||||
func _show_edited_names() -> void:
|
||||
Settings.delete_children(editor_names)
|
||||
for row: int in _edited_names.size():
|
||||
var line: HBoxContainer = HBoxContainer.new()
|
||||
var label: Label = Label.new()
|
||||
label.text = _edited_names[row]
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
line.add_child(label)
|
||||
var remove: Button = Button.new()
|
||||
remove.text = "Remove"
|
||||
remove.focus_mode = Control.FOCUS_NONE
|
||||
remove.pressed.connect(remove_name.bind(_edited_names[row]))
|
||||
line.add_child(remove)
|
||||
var list_row: PanelContainer = PanelContainer.new()
|
||||
list_row.theme_type_variation = &"ListRowOdd" if row % 2 != 0 else &"ListRowEven"
|
||||
list_row.add_child(line)
|
||||
editor_names.add_child(list_row)
|
||||
|
||||
|
||||
func _show_cover(cover: Image) -> void:
|
||||
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
|
||||
cover_placeholder.visible = cover == null
|
||||
|
||||
|
||||
# Covers arrive later; one for a soundtrack no longer shown is left out
|
||||
func _show_cover_of(cover: Image, uuid: String) -> void:
|
||||
if soundtrack != null && soundtrack.soundtrack_uuid == uuid:
|
||||
_show_cover(cover)
|
||||
@@ -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 AlternativeNames
|
||||
extends RefCounted
|
||||
## The [code]AlternativeNames[/code] schema of the MusicServer API.
|
||||
|
||||
## The new list of alternative names. Replaces the old list; send [] to remove them all.
|
||||
var names: Array[String]
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> AlternativeNames:
|
||||
var result: AlternativeNames = AlternativeNames.new()
|
||||
result.names.assign(ApiJson.get_array(data, "names"))
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"names": names,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bpdabeiw5wupm
|
||||
@@ -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 CoverSource
|
||||
extends RefCounted
|
||||
## The [code]CoverSource[/code] schema of the MusicServer API.
|
||||
|
||||
## The game's ID on SteamGridDB, or the anime's ID on AniList
|
||||
var id: String
|
||||
## Where the cover comes from
|
||||
var source: String
|
||||
|
||||
|
||||
## Reads the fields from parsed JSON. Missing fields keep their defaults.
|
||||
static func from_dict(data: Dictionary) -> CoverSource:
|
||||
var result: CoverSource = CoverSource.new()
|
||||
result.id = ApiJson.get_string(data, "id", "")
|
||||
result.source = ApiJson.get_string(data, "source", "")
|
||||
return result
|
||||
|
||||
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"id": id,
|
||||
"source": source,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://06q8sppe2evn
|
||||
@@ -5,6 +5,10 @@ extends RefCounted
|
||||
## The [code]Soundtrack[/code] schema of the MusicServer API.
|
||||
|
||||
var added: String
|
||||
## Other names the soundtrack is known by
|
||||
var alternative_names: Array[String]
|
||||
## When the cover last changed, in Unix seconds. Missing when there is no cover.
|
||||
var cover_modified: int
|
||||
var last_played: String
|
||||
var name: String
|
||||
var times_played: int
|
||||
@@ -15,6 +19,8 @@ var uuid: String
|
||||
static func from_dict(data: Dictionary) -> Soundtrack:
|
||||
var result: Soundtrack = Soundtrack.new()
|
||||
result.added = ApiJson.get_string(data, "added", "")
|
||||
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
|
||||
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
|
||||
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)
|
||||
@@ -26,6 +32,8 @@ static func from_dict(data: Dictionary) -> Soundtrack:
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"added": added,
|
||||
"alternative_names": alternative_names,
|
||||
"cover_modified": cover_modified,
|
||||
"last_played": last_played,
|
||||
"name": name,
|
||||
"times_played": times_played,
|
||||
|
||||
@@ -5,6 +5,10 @@ extends RefCounted
|
||||
## The [code]SoundtrackWithSongList[/code] schema of the MusicServer API.
|
||||
|
||||
var added: String
|
||||
## Other names the soundtrack is known by
|
||||
var alternative_names: Array[String]
|
||||
## When the cover last changed, in Unix seconds. Missing when there is no cover.
|
||||
var cover_modified: int
|
||||
var last_played: String
|
||||
var name: String
|
||||
var songs: Array[Song]
|
||||
@@ -16,6 +20,8 @@ var uuid: String
|
||||
static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
|
||||
var result: SoundtrackWithSongList = SoundtrackWithSongList.new()
|
||||
result.added = ApiJson.get_string(data, "added", "")
|
||||
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
|
||||
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
|
||||
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"):
|
||||
@@ -30,6 +36,8 @@ static func from_dict(data: Dictionary) -> SoundtrackWithSongList:
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"added": added,
|
||||
"alternative_names": alternative_names,
|
||||
"cover_modified": cover_modified,
|
||||
"last_played": last_played,
|
||||
"name": name,
|
||||
"songs": songs.map(func(item: Song) -> Dictionary: return item.to_dict()),
|
||||
|
||||
@@ -4,6 +4,10 @@ class_name SoundtrackWithSongs
|
||||
extends RefCounted
|
||||
## The [code]SoundtrackWithSongs[/code] schema of the MusicServer API.
|
||||
|
||||
## Other names the soundtrack is known by
|
||||
var alternative_names: Array[String]
|
||||
## When the cover last changed, in Unix seconds. Missing when there is no cover.
|
||||
var cover_modified: int
|
||||
var songs: Array[SongInfoForStats]
|
||||
var soundtrack_id: int
|
||||
var soundtrack_last_played: String
|
||||
@@ -15,6 +19,8 @@ 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()
|
||||
result.alternative_names.assign(ApiJson.get_array(data, "alternative_names"))
|
||||
result.cover_modified = ApiJson.get_int(data, "cover_modified", 0)
|
||||
for item: Variant in ApiJson.get_array(data, "songs"):
|
||||
if item is Dictionary:
|
||||
result.songs.append(SongInfoForStats.from_dict(item))
|
||||
@@ -29,6 +35,8 @@ static func from_dict(data: Dictionary) -> SoundtrackWithSongs:
|
||||
## Returns the fields under their JSON names, ready for [method JSON.stringify].
|
||||
func to_dict() -> Dictionary:
|
||||
return {
|
||||
"alternative_names": alternative_names,
|
||||
"cover_modified": cover_modified,
|
||||
"songs": songs.map(func(item: SongInfoForStats) -> Dictionary: return item.to_dict()),
|
||||
"soundtrack_id": soundtrack_id,
|
||||
"soundtrack_last_played": soundtrack_last_played,
|
||||
|
||||
@@ -131,6 +131,31 @@ func fetch_soundtracks(on_done: Callable, on_failed: Callable = Callable()) -> v
|
||||
get_json_list("/soundtracks", Soundtrack, on_done, on_failed)
|
||||
|
||||
|
||||
## Downloads the cover picture of the soundtrack with [param uuid] as bytes (JPEG or
|
||||
## PNG). The server answers 404 while it has no cover for it.
|
||||
func fetch_soundtrack_cover(
|
||||
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
get_bytes("/soundtracks/%s/cover" % uuid.uri_encode(), on_done, on_failed)
|
||||
|
||||
|
||||
## Replaces the alternative names of the soundtrack with [param uuid] with
|
||||
## [param names]. The server trims them, drops duplicates and the soundtrack's own name,
|
||||
## and calls [param on_done] with the updated [Soundtrack].
|
||||
func set_alternative_names(uuid: String, names: Array[String], on_done: Callable,
|
||||
on_failed: Callable = Callable()) -> void:
|
||||
var request: AlternativeNames = AlternativeNames.new()
|
||||
request.names = names
|
||||
var path: String = "/soundtracks/%s/alternative-names" % uuid.uri_encode()
|
||||
var saved: Callable = func saved(data: Variant) -> void:
|
||||
if !(data is Dictionary):
|
||||
push_warning("Expected a JSON object from " + path)
|
||||
_call_if_valid(on_failed)
|
||||
return
|
||||
on_done.call(Soundtrack.from_dict(data))
|
||||
_request(path, HTTPClient.METHOD_PUT, _parse_json.bind(path, saved, on_failed), on_failed,
|
||||
true, JSON.stringify(request.to_dict()))
|
||||
|
||||
|
||||
## Fetches a soundtrack and its songs as a [SoundtrackWithSongList].
|
||||
func fetch_soundtrack(
|
||||
uuid: String, on_done: Callable, on_failed: Callable = Callable()) -> void:
|
||||
@@ -419,13 +444,14 @@ func _parse_json(
|
||||
|
||||
|
||||
# Sends the request with the token, and once more with a new token if the server
|
||||
# answers 401
|
||||
# answers 401. A [param json_body] is sent as the request's body.
|
||||
func _request(
|
||||
path: String,
|
||||
method: int,
|
||||
on_success: Callable,
|
||||
on_failed: Callable,
|
||||
may_retry: bool = true,
|
||||
json_body: String = "",
|
||||
) -> void:
|
||||
_with_token(func(token: String) -> void:
|
||||
_send(path, method, token, func(response_code: int, body: PackedByteArray) -> void:
|
||||
@@ -433,10 +459,10 @@ func _request(
|
||||
GameLog.debug("Token refused by the server, getting a new one")
|
||||
if _token == token:
|
||||
clear_token()
|
||||
_request(path, method, on_success, on_failed, false)
|
||||
_request(path, method, on_success, on_failed, false, json_body)
|
||||
return
|
||||
_handle_response(path, response_code, body, on_success, on_failed)
|
||||
)
|
||||
, json_body)
|
||||
, on_failed)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,23 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AlternativeNames": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"names": {
|
||||
"description": "The new list of alternative names. Replaces the old list; send [] to remove them all.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"maxItems": 50,
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"names"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CharacterDetails": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -36,6 +53,30 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CoverSource": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "The game's ID on SteamGridDB, or the anime's ID on AniList",
|
||||
"maxLength": 50,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"description": "Where the cover comes from",
|
||||
"enum": [
|
||||
"steamgriddb",
|
||||
"anilist"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"source",
|
||||
"id"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProgressResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -175,6 +216,18 @@
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"alternative_names": {
|
||||
"description": "Other names the soundtrack is known by",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cover_modified": {
|
||||
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"last_played": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
@@ -193,6 +246,7 @@
|
||||
"required": [
|
||||
"uuid",
|
||||
"name",
|
||||
"alternative_names",
|
||||
"times_played",
|
||||
"added"
|
||||
],
|
||||
@@ -205,6 +259,18 @@
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
},
|
||||
"alternative_names": {
|
||||
"description": "Other names the soundtrack is known by",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cover_modified": {
|
||||
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"last_played": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
@@ -230,6 +296,7 @@
|
||||
"songs",
|
||||
"uuid",
|
||||
"name",
|
||||
"alternative_names",
|
||||
"times_played",
|
||||
"added"
|
||||
],
|
||||
@@ -238,6 +305,18 @@
|
||||
"SoundtrackWithSongs": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"alternative_names": {
|
||||
"description": "Other names the soundtrack is known by",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cover_modified": {
|
||||
"description": "When the cover last changed, in Unix seconds. Missing when there is no cover.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"songs": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SongInfoForStats"
|
||||
@@ -267,6 +346,7 @@
|
||||
"soundtrack_id",
|
||||
"soundtrack_uuid",
|
||||
"soundtrack_name",
|
||||
"alternative_names",
|
||||
"soundtrack_played",
|
||||
"songs"
|
||||
],
|
||||
@@ -2663,6 +2743,515 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/soundtracks/{uuid}/alternative-names": {
|
||||
"put": {
|
||||
"description": "Replaces the soundtrack's alternative names, which search and the cover search also use. Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.",
|
||||
"operationId": "set-alternative-names",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "UUID of the soundtrack",
|
||||
"in": "path",
|
||||
"name": "uuid",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "UUID of the soundtrack",
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AlternativeNames"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Soundtrack"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Set alternative names",
|
||||
"tags": [
|
||||
"library"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/soundtracks/{uuid}/cover": {
|
||||
"delete": {
|
||||
"description": "Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.",
|
||||
"operationId": "delete-soundtrack-cover",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "UUID of the soundtrack or song",
|
||||
"in": "path",
|
||||
"name": "uuid",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "UUID of the soundtrack or song",
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Remove the cover",
|
||||
"tags": [
|
||||
"covers"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"description": "Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.",
|
||||
"operationId": "get-soundtrack-cover",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "UUID of the soundtrack or song",
|
||||
"in": "path",
|
||||
"name": "uuid",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "UUID of the soundtrack or song",
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"image/jpeg": {
|
||||
"schema": {
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"image/png": {
|
||||
"schema": {
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"image/webp": {
|
||||
"schema": {
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "The cover image"
|
||||
},
|
||||
"206": {
|
||||
"content": {
|
||||
"image/jpeg": {
|
||||
"schema": {
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"image/png": {
|
||||
"schema": {
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"image/webp": {
|
||||
"schema": {
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Part of the file, for a Range request"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Soundtrack cover",
|
||||
"tags": [
|
||||
"covers"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"description": "Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.",
|
||||
"operationId": "upload-soundtrack-cover",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "UUID of the soundtrack",
|
||||
"in": "path",
|
||||
"name": "uuid",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "UUID of the soundtrack",
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"image/*": {
|
||||
"schema": {
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Upload a cover",
|
||||
"tags": [
|
||||
"covers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/soundtracks/{uuid}/cover/source": {
|
||||
"put": {
|
||||
"description": "Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.",
|
||||
"operationId": "set-soundtrack-cover-source",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "UUID of the soundtrack",
|
||||
"in": "path",
|
||||
"name": "uuid",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "UUID of the soundtrack",
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CoverSource"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Unprocessable Entity"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
"502": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Gateway"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Service Unavailable"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
}
|
||||
],
|
||||
"summary": "Pick a cover",
|
||||
"tags": [
|
||||
"covers"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/statistics/songs/least-played": {
|
||||
"get": {
|
||||
"description": "Returns the least played songs with their soundtrack.",
|
||||
|
||||
@@ -12,9 +12,52 @@ script = ExtResource("card_script")
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="NameLabel" type="Label" parent="VBox" unique_id=1211000003]
|
||||
[node name="Header" type="HBoxContainer" parent="VBox"]
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_override_constants/separation = 10
|
||||
|
||||
[node name="CoverFrame" type="Panel" parent="VBox/Header"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 62)
|
||||
clip_children = 2
|
||||
custom_minimum_size = Vector2(72, 100)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"SoundtrackCover"
|
||||
|
||||
[node name="CoverPlaceholder" type="Label" parent="VBox/Header/CoverFrame"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"TextLarge"
|
||||
horizontal_alignment = 1
|
||||
vertical_alignment = 1
|
||||
|
||||
[node name="CoverPicture" type="TextureRect" parent="VBox/Header/CoverFrame"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
expand_mode = 1
|
||||
stretch_mode = 6
|
||||
|
||||
[node name="Info" type="VBoxContainer" parent="VBox/Header"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="NameLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000003]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(0, 50)
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
theme_type_variation = &"TextLarge"
|
||||
@@ -22,12 +65,12 @@ autowrap_mode = 3
|
||||
text_overrun_behavior = 3
|
||||
max_lines_visible = 2
|
||||
|
||||
[node name="PlayedLabel" type="Label" parent="VBox" unique_id=1211000004]
|
||||
[node name="PlayedLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000004]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
[node name="LastPlayedLabel" type="Label" parent="VBox" unique_id=1211000005]
|
||||
[node name="LastPlayedLabel" type="Label" parent="VBox/Header/Info" unique_id=1211000005]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
mouse_filter = 2
|
||||
|
||||
@@ -4,6 +4,9 @@ extends PanelContainer
|
||||
## played, and its most and least played songs. Songs never played are not listed, and
|
||||
## the least played are only shown when there are enough played songs to not repeat
|
||||
## any. Clicking it asks for all its songs.
|
||||
##
|
||||
## Its cover shows on the left once [method load_cover] has fetched it; until then, and
|
||||
## when the soundtrack has no cover, the soundtrack's initials stand in for it.
|
||||
|
||||
## Emitted when the card is clicked.
|
||||
signal pressed(soundtrack: SoundtrackWithSongs)
|
||||
@@ -15,6 +18,11 @@ const SONGS_SHOWN: int = 3
|
||||
## The soundtrack the card shows.
|
||||
var soundtrack: SoundtrackWithSongs
|
||||
|
||||
# The cover time asked for, so a cover is only asked for again when it changed
|
||||
var _cover_asked: int = 0
|
||||
|
||||
@onready var cover_picture: TextureRect = %CoverPicture
|
||||
@onready var cover_placeholder: Label = %CoverPlaceholder
|
||||
@onready var name_label: Label = %NameLabel
|
||||
@onready var played_label: Label = %PlayedLabel
|
||||
@onready var last_played_label: Label = %LastPlayedLabel
|
||||
@@ -32,7 +40,14 @@ func _ready() -> void:
|
||||
|
||||
## Shows [param data] on the card.
|
||||
func show_soundtrack(data: SoundtrackWithSongs) -> void:
|
||||
var cover_changed: bool = soundtrack == null \
|
||||
|| soundtrack.soundtrack_uuid != data.soundtrack_uuid \
|
||||
|| soundtrack.cover_modified != data.cover_modified
|
||||
soundtrack = data
|
||||
if cover_changed:
|
||||
show_cover(null)
|
||||
_cover_asked = 0
|
||||
cover_placeholder.text = initials(data.soundtrack_name)
|
||||
name_label.text = data.soundtrack_name
|
||||
tooltip_text = data.soundtrack_name
|
||||
played_label.text = played_text(data.soundtrack_played)
|
||||
@@ -55,6 +70,34 @@ func show_soundtrack(data: SoundtrackWithSongs) -> void:
|
||||
_fill_song_list(least_played_list, least_played)
|
||||
|
||||
|
||||
## Asks [param covers] for the soundtrack's cover, unless it already has, and shows it
|
||||
## when it comes. Soundtracks without a cover keep their initials.
|
||||
func load_cover(covers: CoverCache) -> void:
|
||||
if soundtrack == null || soundtrack.cover_modified <= 0 \
|
||||
|| _cover_asked == soundtrack.cover_modified:
|
||||
return
|
||||
_cover_asked = soundtrack.cover_modified
|
||||
covers.fetch_cover(soundtrack.soundtrack_uuid, soundtrack.cover_modified, show_cover)
|
||||
|
||||
|
||||
## Shows [param cover], or the initials when it is null.
|
||||
func show_cover(cover: Image) -> void:
|
||||
cover_picture.texture = ImageTexture.create_from_image(cover) if cover != null else null
|
||||
cover_placeholder.visible = cover == null
|
||||
|
||||
|
||||
## Up to two initials of [param soundtrack_name], e.g. "FF" for "Final Fantasy VI".
|
||||
static func initials(soundtrack_name: String) -> String:
|
||||
var letters: String = ""
|
||||
for word: String in soundtrack_name.split(" ", false):
|
||||
var first: String = word.left(1)
|
||||
if first.to_upper() != first.to_lower():
|
||||
letters += first.to_upper()
|
||||
if letters.length() == 2:
|
||||
break
|
||||
return letters if !letters.is_empty() else soundtrack_name.left(1)
|
||||
|
||||
|
||||
## "Played 3 times", or "Never played" for [code]0[/code].
|
||||
static func played_text(times_played: int) -> String:
|
||||
if times_played == 0:
|
||||
|
||||
@@ -5,6 +5,9 @@ extends Panel
|
||||
## cards filtered on whether the soundtrack has been played.
|
||||
##
|
||||
## Shown instead of the quiz when the Statistics tab is chosen.
|
||||
##
|
||||
## Covers are loaded for the cards on screen as they scroll into view, a few at a time,
|
||||
## not for every soundtrack when the tab opens. See [CoverCache].
|
||||
|
||||
## Emitted when a card is clicked, to show all songs of [param soundtrack].
|
||||
signal soundtrack_selected(soundtrack: SoundtrackWithSongs)
|
||||
@@ -19,12 +22,18 @@ enum ShowFilter { ALL, PLAYED, NEVER_PLAYED }
|
||||
enum Mode { SOUNDTRACKS, SONGS }
|
||||
|
||||
const CARD_SCENE: PackedScene = preload("res://statistics/SoundtrackCard.tscn")
|
||||
## How far outside the visible part cards still get their covers, so they are ready when
|
||||
## scrolled to.
|
||||
const COVER_MARGIN: float = 400.0
|
||||
|
||||
## The cards in the order they are shown, hidden ones included.
|
||||
var cards: Array[SoundtrackCard] = []
|
||||
## Whether soundtracks or songs are shown. Change it with [method set_mode].
|
||||
var mode: Mode = Mode.SOUNDTRACKS
|
||||
|
||||
## The covers on disk, shared by the cards.
|
||||
var covers: CoverCache = CoverCache.new()
|
||||
|
||||
var _search: SoundtrackSearch = SoundtrackSearch.new()
|
||||
var _loading: bool = false
|
||||
# The soundtracks last loaded, by name
|
||||
@@ -61,6 +70,11 @@ func _ready() -> void:
|
||||
sort_option.item_selected.connect(func(_index: int) -> void: apply_sort())
|
||||
retry_button.pressed.connect(refresh)
|
||||
retry_button.visible = false
|
||||
# The cards have moved by the end of the frame
|
||||
card_scroll.get_v_scroll_bar().value_changed.connect(
|
||||
func(_value: float) -> void: load_covers_on_screen.call_deferred())
|
||||
card_scroll.resized.connect(load_covers_on_screen)
|
||||
visibility_changed.connect(load_covers_on_screen)
|
||||
|
||||
|
||||
## Loads the statistics from the server. The cards already shown stay until the new
|
||||
@@ -108,6 +122,7 @@ func apply_sort() -> void:
|
||||
return _comes_before(a.soundtrack, b.soundtrack, order))
|
||||
for i: int in cards.size():
|
||||
card_grid.move_child(cards[i], i)
|
||||
load_covers_on_screen.call_deferred()
|
||||
|
||||
|
||||
## Shows only the cards or songs that match the search text and the show menu.
|
||||
@@ -136,6 +151,8 @@ func apply_filter() -> void:
|
||||
count_label.text = "%d soundtracks" % cards.size()
|
||||
else:
|
||||
count_label.text = "%d of %d soundtracks" % [shown, cards.size()]
|
||||
# The grid has moved the cards by the end of the frame
|
||||
load_covers_on_screen.call_deferred()
|
||||
|
||||
|
||||
func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
|
||||
@@ -149,19 +166,49 @@ func _on_loaded(soundtracks: Array[SoundtrackWithSongs]) -> void:
|
||||
cards.append(card)
|
||||
while cards.size() > soundtracks.size():
|
||||
cards.pop_back().free()
|
||||
var names: Array[String] = []
|
||||
_soundtracks.clear()
|
||||
var cover_times: Dictionary = {}
|
||||
for i: int in soundtracks.size():
|
||||
cards[i].show_soundtrack(soundtracks[i])
|
||||
names.append(soundtracks[i].soundtrack_name)
|
||||
_soundtracks[soundtracks[i].soundtrack_name] = soundtracks[i]
|
||||
_search.set_names(names)
|
||||
cover_times[soundtracks[i].soundtrack_uuid] = soundtracks[i].cover_modified
|
||||
covers.keep_only(cover_times)
|
||||
_update_search_names()
|
||||
_songs_ready = false
|
||||
apply_sort()
|
||||
if mode == Mode.SOUNDTRACKS:
|
||||
apply_filter()
|
||||
|
||||
|
||||
## Asks for the covers of the cards that are on screen, or nearly.
|
||||
func load_covers_on_screen() -> void:
|
||||
if !is_visible_in_tree() || mode != Mode.SOUNDTRACKS:
|
||||
return
|
||||
var view: Rect2 = card_scroll.get_global_rect().grow(COVER_MARGIN)
|
||||
for card: SoundtrackCard in cards:
|
||||
if card.visible && view.intersects(card.get_global_rect()):
|
||||
card.load_cover(covers)
|
||||
|
||||
|
||||
## Shows the new alternative names of the soundtrack [param updated] and finds it by
|
||||
## them, without loading the statistics again.
|
||||
func update_alternative_names(updated: Soundtrack) -> void:
|
||||
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
|
||||
if soundtrack.soundtrack_uuid == updated.uuid:
|
||||
soundtrack.alternative_names = updated.alternative_names
|
||||
_update_search_names()
|
||||
apply_filter()
|
||||
|
||||
|
||||
func _update_search_names() -> void:
|
||||
var names: Array[String] = []
|
||||
var alternatives: Dictionary = {}
|
||||
for soundtrack: SoundtrackWithSongs in _soundtracks.values():
|
||||
names.append(soundtrack.soundtrack_name)
|
||||
alternatives[soundtrack.soundtrack_name] = soundtrack.alternative_names
|
||||
_search.set_names(names, alternatives)
|
||||
|
||||
|
||||
# Typing in the song list waits for a pause, since searching every song takes a moment
|
||||
func _on_search_text_changed(_text: String) -> void:
|
||||
if mode == Mode.SONGS:
|
||||
|
||||
+6180
-1651
File diff suppressed because it is too large.
Load diff
@@ -25,6 +25,9 @@ var requested_paths: Array[String] = []
|
||||
var requested_methods: Array[int] = []
|
||||
## Headers of each request, in the same order as [member requested_paths].
|
||||
var requested_headers: Array[PackedStringArray] = []
|
||||
## The JSON body of each request, empty for none, in the same order as
|
||||
## [member requested_paths].
|
||||
var requested_bodies: Array[String] = []
|
||||
## How many tokens have been asked for.
|
||||
var token_requests: int = 0
|
||||
## The JSON body of the last token request.
|
||||
@@ -120,6 +123,7 @@ func request(
|
||||
requested_paths.append(path)
|
||||
requested_methods.append(method)
|
||||
requested_headers.append(headers)
|
||||
requested_bodies.append(body)
|
||||
if !OPEN_PATHS.has(path.get_slice("?", 0)) && !_has_valid_token(headers):
|
||||
on_complete.call(401, JSON.stringify({"error": "Unauthorized"}).to_utf8_buffer(),
|
||||
PackedStringArray())
|
||||
|
||||
@@ -91,6 +91,12 @@ static func make_server() -> FakeHttpTransport:
|
||||
server.respond_bytes("/sync", PackedByteArray())
|
||||
server.respond_json("/sync/progress", SYNC_PROGRESS)
|
||||
server.respond_json("/statistics/soundtracks", soundtrack_statistics())
|
||||
# The character pictures stand in for covers
|
||||
var pictures: Array[String] = character_names()
|
||||
for i: int in SOUNDTRACKS.size():
|
||||
server.respond_bytes("/soundtracks/soundtrack-%d/cover" % i,
|
||||
FileAccess.get_file_as_bytes(CHARACTERS_DIR.path_join(
|
||||
pictures[i % pictures.size()])))
|
||||
server.respond_json("/version/history", SERVER_HISTORY)
|
||||
return server
|
||||
|
||||
@@ -118,6 +124,11 @@ static func soundtrack_statistics() -> Array[Dictionary]:
|
||||
}
|
||||
if played > 0:
|
||||
soundtrack.soundtrack_last_played = "2026-09-%02dT20:00:00Z" % (i + 1)
|
||||
# Every other soundtrack has a cover, the rest show their initials
|
||||
if i % 2 == 0:
|
||||
soundtrack.cover_modified = 1700000000
|
||||
if i == 0:
|
||||
soundtrack.alternative_names = ["CT", "クロノ・トリガー"]
|
||||
statistics.append(soundtrack)
|
||||
return statistics
|
||||
|
||||
@@ -292,6 +303,11 @@ func run(on_state: Callable) -> void:
|
||||
break
|
||||
await _settle()
|
||||
await on_state.call("popup_SoundtrackStats", stats_popup)
|
||||
stats_popup.edit_names()
|
||||
stats_popup.new_name_field.text = "Chrono"
|
||||
await _settle()
|
||||
await on_state.call("popup_SoundtrackStats_names", stats_popup)
|
||||
stats_popup.stop_editing()
|
||||
stats_popup.close()
|
||||
_main.show_tab(&"quiz")
|
||||
await _settle()
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
extends MainWindowTestSuite
|
||||
## Soundtrack covers on the statistics cards and in the popup, loaded for the cards on
|
||||
## screen, and editing a soundtrack's alternative names, which search then finds it by.
|
||||
|
||||
const STATISTICS: Array[Dictionary] = [
|
||||
{"soundtrack_uuid": "st-1", "soundtrack_name": "Chrono Trigger", "soundtrack_played": 5,
|
||||
"cover_modified": 100, "alternative_names": [], "songs": [
|
||||
{"song_name": "Corridors of Time", "times_played": 1},
|
||||
]},
|
||||
{"soundtrack_uuid": "st-2", "soundtrack_name": "Terranigma", "soundtrack_played": 0,
|
||||
"songs": []},
|
||||
]
|
||||
|
||||
var _view: StatisticsView
|
||||
var _popup: SoundtrackStatsPopup
|
||||
var _png: PackedByteArray
|
||||
|
||||
|
||||
func _before_window_starts() -> void:
|
||||
_server.respond_json("/statistics/soundtracks", STATISTICS)
|
||||
var image: Image = Image.create(6, 8, false, Image.FORMAT_RGB8)
|
||||
_png = image.save_png_to_buffer()
|
||||
_server.respond_bytes("/soundtracks/st-1/cover", _png)
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
# Covers are kept next to the settings, which the tests keep in a folder of their own
|
||||
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("covers_%d" % randi()))
|
||||
await super()
|
||||
_view = _window.get_node("StatisticsView")
|
||||
_popup = _window.get_node("SoundtrackStatsPopup")
|
||||
|
||||
|
||||
func _open_statistics() -> void:
|
||||
_window.show_tab(&"statistics")
|
||||
await _runner.simulate_frames(3)
|
||||
|
||||
|
||||
func _card(soundtrack_name: String) -> SoundtrackCard:
|
||||
for card: SoundtrackCard in _view.cards:
|
||||
if card.soundtrack.soundtrack_name == soundtrack_name:
|
||||
return card
|
||||
return null
|
||||
|
||||
|
||||
func test_card_shows_its_cover() -> void:
|
||||
await _open_statistics()
|
||||
|
||||
var card: SoundtrackCard = _card("Chrono Trigger")
|
||||
assert_object(card.cover_picture.texture).is_not_null()
|
||||
assert_bool(card.cover_placeholder.visible).is_false()
|
||||
|
||||
|
||||
func test_card_without_a_cover_shows_initials_and_asks_nothing() -> void:
|
||||
await _open_statistics()
|
||||
|
||||
var card: SoundtrackCard = _card("Terranigma")
|
||||
assert_object(card.cover_picture.texture).is_null()
|
||||
assert_bool(card.cover_placeholder.visible).is_true()
|
||||
assert_str(card.cover_placeholder.text).is_equal("T")
|
||||
assert_int(_server.count("/soundtracks/st-2/cover")).is_equal(0)
|
||||
|
||||
|
||||
func test_cover_missing_on_the_server_keeps_the_initials() -> void:
|
||||
_server.respond_bytes("/soundtracks/st-1/cover", PackedByteArray(), 404)
|
||||
|
||||
await _open_statistics()
|
||||
|
||||
assert_bool(_card("Chrono Trigger").cover_placeholder.visible).is_true()
|
||||
assert_str(_card("Chrono Trigger").cover_placeholder.text).is_equal("CT")
|
||||
|
||||
|
||||
func test_covers_load_for_cards_on_screen_as_they_scroll_in() -> void:
|
||||
var many: Array[Dictionary] = []
|
||||
for i: int in 40:
|
||||
many.append({"soundtrack_uuid": "many-%02d" % i, "soundtrack_name": "Game %02d" % i,
|
||||
"soundtrack_played": 1, "cover_modified": 100, "songs": []})
|
||||
_server.respond_bytes("/soundtracks/many-%02d/cover" % i, _png)
|
||||
_server.respond_json("/statistics/soundtracks", many)
|
||||
|
||||
await _open_statistics()
|
||||
|
||||
var asked: Callable = func() -> int:
|
||||
return _server.requested_paths.filter(
|
||||
func(path: String) -> bool: return path.begins_with("/soundtracks/many-")).size()
|
||||
var on_screen: int = asked.call()
|
||||
assert_int(on_screen).is_greater(0)
|
||||
assert_int(on_screen).is_less(40)
|
||||
|
||||
var scroll_bar: VScrollBar = _view.card_scroll.get_v_scroll_bar()
|
||||
scroll_bar.value = scroll_bar.max_value
|
||||
await _runner.simulate_frames(2)
|
||||
|
||||
assert_int(asked.call()).is_greater(on_screen)
|
||||
|
||||
|
||||
func test_popup_shows_the_cover_large() -> void:
|
||||
await _open_statistics()
|
||||
|
||||
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||
|
||||
assert_object(_popup.cover_picture.texture).is_not_null()
|
||||
assert_bool(_popup.cover_placeholder.visible).is_false()
|
||||
assert_str(_popup.names_label.text).is_equal("No other names yet")
|
||||
|
||||
|
||||
func test_editing_names_saves_them_and_search_finds_the_new_name() -> void:
|
||||
_server.respond_json("/soundtracks/st-1/alternative-names", {"uuid": "st-1",
|
||||
"name": "Chrono Trigger", "alternative_names": ["CT", "クロノ・トリガー"]})
|
||||
await _open_statistics()
|
||||
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||
|
||||
_popup.edit_names()
|
||||
assert_bool(_popup.names_editor.visible).is_true()
|
||||
_popup.new_name_field.text = "CT"
|
||||
_popup.add_name()
|
||||
_popup.new_name_field.text = "クロノ・トリガー"
|
||||
_popup.save_names()
|
||||
|
||||
var index: int = _server.requested_paths.find("/soundtracks/st-1/alternative-names")
|
||||
assert_int(index).is_not_equal(-1)
|
||||
assert_int(_server.requested_methods[index]).is_equal(HTTPClient.METHOD_PUT)
|
||||
var body: Dictionary = JSON.parse_string(_server.requested_bodies[index])
|
||||
assert_array(body.names).contains_exactly(["CT", "クロノ・トリガー"])
|
||||
assert_bool(_popup.names_editor.visible).is_false()
|
||||
assert_str(_popup.names_label.text).is_equal("CT, クロノ・トリガー")
|
||||
|
||||
_view.search_field.text = "クロノ"
|
||||
_view.apply_filter()
|
||||
var shown: Array = _view.cards.filter(func(card: SoundtrackCard) -> bool: return card.visible)
|
||||
assert_array(shown.map(func(card: SoundtrackCard) -> String:
|
||||
return card.soundtrack.soundtrack_name)).contains_exactly(["Chrono Trigger"])
|
||||
|
||||
|
||||
func test_editor_skips_empty_repeated_and_own_names() -> void:
|
||||
await _open_statistics()
|
||||
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||
_popup.edit_names()
|
||||
|
||||
for name: String in [" ", "CT", "ct", "Chrono Trigger"]:
|
||||
_popup.new_name_field.text = name
|
||||
_popup.add_name()
|
||||
|
||||
assert_int(_popup.editor_names.get_child_count()).is_equal(1)
|
||||
|
||||
|
||||
func test_removing_a_name_in_the_editor() -> void:
|
||||
await _open_statistics()
|
||||
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||
_popup.edit_names()
|
||||
_popup.new_name_field.text = "CT"
|
||||
_popup.add_name()
|
||||
|
||||
_popup.remove_name("CT")
|
||||
|
||||
assert_int(_popup.editor_names.get_child_count()).is_equal(0)
|
||||
|
||||
|
||||
func test_failed_save_keeps_the_editor_open() -> void:
|
||||
_server.respond_json("/soundtracks/st-1/alternative-names", {}, 500)
|
||||
await _open_statistics()
|
||||
_popup.show_soundtrack(_card("Chrono Trigger").soundtrack)
|
||||
_popup.edit_names()
|
||||
_popup.new_name_field.text = "CT"
|
||||
|
||||
_popup.save_names()
|
||||
|
||||
assert_bool(_popup.names_editor.visible).is_true()
|
||||
assert_bool(_popup.editor_status.visible).is_true()
|
||||
assert_bool(_popup.save_names_button.disabled).is_false()
|
||||
|
||||
|
||||
func test_search_popup_finds_soundtracks_by_alternative_names() -> void:
|
||||
_server.respond_json("/soundtracks", [
|
||||
{"uuid": "st-1", "name": "Chrono Trigger", "alternative_names": ["CT"]},
|
||||
{"uuid": "st-2", "name": "Terranigma", "alternative_names": []},
|
||||
])
|
||||
var search: SearchPopup = _window.get_node("SearchPopup")
|
||||
search.get_list_of_soundtracks()
|
||||
search.open()
|
||||
|
||||
search.search_field.text = "ct"
|
||||
search.search()
|
||||
|
||||
assert_array(search.shown_names()).contains(["Chrono Trigger"])
|
||||
var row: PanelContainer = search.search_results.get_child(0)
|
||||
assert_str((row.get_child(0) as RichTextLabel).get_parsed_text()).contains("CT")
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3oc3e1fsttoh
|
||||
@@ -50,6 +50,7 @@ const STYLES: Dictionary = {
|
||||
"ProgressSlider": "HSlider",
|
||||
"VolumeBar": "HSlider",
|
||||
"StatisticsCard": "PanelContainer",
|
||||
"SoundtrackCover": "Panel",
|
||||
"StatisticsCardHover": "PanelContainer",
|
||||
"StatisticsGrid": "GridContainer",
|
||||
"TabButton": "Button",
|
||||
|
||||
@@ -182,9 +182,12 @@ 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",
|
||||
"alternative_names": ["Mother 2"], "cover_modified": 1730000000,
|
||||
"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)
|
||||
# Compared as JSON, since the names come back as a typed Array[String]
|
||||
assert_str(JSON.stringify(SoundtrackWithSongs.from_dict(json).to_dict(), "", true)) \
|
||||
.is_equal(JSON.stringify(json, "", true))
|
||||
@@ -0,0 +1,142 @@
|
||||
extends GdUnitTestSuite
|
||||
## Soundtrack covers cached on disk: downloaded once, again only when the server's cover
|
||||
## time changed, a few at a time, and deleted with their soundtrack.
|
||||
|
||||
var _server: FakeHttpTransport
|
||||
var _config_dir_before: String
|
||||
var _png: PackedByteArray
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
_server = FakeHttpTransport.new().install()
|
||||
_config_dir_before = OS.get_environment(Settings.CONFIG_DIR_ENV)
|
||||
OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("cover_cache_%d" % randi()))
|
||||
var image: Image = Image.create(4, 6, false, Image.FORMAT_RGB8)
|
||||
_png = image.save_png_to_buffer()
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
OS.set_environment(Settings.CONFIG_DIR_ENV, _config_dir_before)
|
||||
_server.uninstall()
|
||||
|
||||
|
||||
func _cover_path(uuid: String) -> String:
|
||||
return "/soundtracks/%s/cover" % uuid
|
||||
|
||||
|
||||
func _serve(uuid: String) -> void:
|
||||
_server.respond_bytes(_cover_path(uuid), _png)
|
||||
|
||||
|
||||
# Asks a new cache, like opening the statistics after a restart, and returns the covers
|
||||
func _fetch(uuid: String, modified: int, cache: CoverCache = null) -> Array[Image]:
|
||||
var covers: CoverCache = cache if cache != null else CoverCache.new()
|
||||
var images: Array[Image] = []
|
||||
covers.fetch_cover(uuid, modified, func(image: Image) -> void: images.append(image))
|
||||
return images
|
||||
|
||||
|
||||
func test_first_fetch_downloads_and_keeps_the_cover() -> void:
|
||||
_serve("st-1")
|
||||
|
||||
var images: Array[Image] = _fetch("st-1", 100)
|
||||
|
||||
assert_int(images.size()).is_equal(1)
|
||||
assert_int(images[0].get_width()).is_equal(4)
|
||||
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_true()
|
||||
|
||||
|
||||
func test_unchanged_cover_comes_from_disk() -> void:
|
||||
_serve("st-1")
|
||||
_fetch("st-1", 100)
|
||||
|
||||
var images: Array[Image] = _fetch("st-1", 100)
|
||||
|
||||
assert_int(images.size()).is_equal(1)
|
||||
assert_int(_server.count(_cover_path("st-1"))).is_equal(1)
|
||||
|
||||
|
||||
func test_changed_cover_is_downloaded_again() -> void:
|
||||
_serve("st-1")
|
||||
_fetch("st-1", 100)
|
||||
|
||||
_fetch("st-1", 200)
|
||||
|
||||
assert_int(_server.count(_cover_path("st-1"))).is_equal(2)
|
||||
|
||||
|
||||
func test_soundtrack_without_a_cover_asks_nothing() -> void:
|
||||
var images: Array[Image] = _fetch("st-1", 0)
|
||||
|
||||
assert_array(images).is_empty()
|
||||
assert_int(_server.count(_cover_path("st-1"))).is_equal(0)
|
||||
|
||||
|
||||
func test_missing_cover_on_the_server_gives_nothing() -> void:
|
||||
var images: Array[Image] = _fetch("st-1", 100)
|
||||
|
||||
assert_array(images).is_empty()
|
||||
assert_int(_server.count(_cover_path("st-1"))).is_equal(1)
|
||||
|
||||
|
||||
func test_cover_that_cannot_be_read_is_not_kept() -> void:
|
||||
_server.respond_bytes(_cover_path("st-1"), "not a picture".to_utf8_buffer())
|
||||
|
||||
var images: Array[Image] = _fetch("st-1", 100)
|
||||
|
||||
assert_array(images).is_empty()
|
||||
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
|
||||
|
||||
|
||||
func test_covers_of_removed_soundtracks_are_deleted() -> void:
|
||||
_serve("st-1")
|
||||
_serve("st-2")
|
||||
var covers: CoverCache = CoverCache.new()
|
||||
_fetch("st-1", 100, covers)
|
||||
_fetch("st-2", 100, covers)
|
||||
|
||||
covers.keep_only({"st-1": 100, "st-3": 0})
|
||||
|
||||
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_true()
|
||||
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-2"))).is_false()
|
||||
|
||||
|
||||
func test_cover_removed_on_the_server_is_deleted() -> void:
|
||||
_serve("st-1")
|
||||
var covers: CoverCache = CoverCache.new()
|
||||
_fetch("st-1", 100, covers)
|
||||
|
||||
covers.keep_only({"st-1": 0})
|
||||
|
||||
assert_bool(FileAccess.file_exists(CoverCache.folder().path_join("st-1"))).is_false()
|
||||
|
||||
|
||||
func test_only_a_few_covers_download_at_a_time() -> void:
|
||||
var covers: CoverCache = CoverCache.new()
|
||||
for i: int in 5:
|
||||
_server.hold(_cover_path("st-%d" % i))
|
||||
_serve("st-%d" % i)
|
||||
_fetch("st-%d" % i, 100, covers)
|
||||
|
||||
var asked: int = 0
|
||||
for i: int in 5:
|
||||
asked += _server.count(_cover_path("st-%d" % i))
|
||||
assert_int(asked).is_equal(CoverCache.MAX_DOWNLOADS)
|
||||
|
||||
_server.release(_cover_path("st-0"))
|
||||
assert_int(_server.count(_cover_path("st-3"))).is_equal(1)
|
||||
assert_int(_server.count(_cover_path("st-4"))).is_equal(0)
|
||||
|
||||
|
||||
func test_cover_asked_for_twice_is_downloaded_once() -> void:
|
||||
var covers: CoverCache = CoverCache.new()
|
||||
_server.hold(_cover_path("st-1"))
|
||||
_serve("st-1")
|
||||
var first: Array[Image] = _fetch("st-1", 100, covers)
|
||||
var second: Array[Image] = _fetch("st-1", 100, covers)
|
||||
|
||||
_server.release(_cover_path("st-1"))
|
||||
|
||||
assert_int(_server.count(_cover_path("st-1"))).is_equal(1)
|
||||
assert_int(first.size()).is_equal(1)
|
||||
assert_int(second.size()).is_equal(1)
|
||||
@@ -0,0 +1 @@
|
||||
uid://chubfhdhfqvyu
|
||||
@@ -90,3 +90,25 @@ func test_matched_letters_in_order() -> void:
|
||||
func test_matched_letters_of_no_match() -> void:
|
||||
assert_array(SoundtrackSearch.matched_letters("Chrono Trigger", "metroid")).is_empty()
|
||||
assert_array(SoundtrackSearch.matched_letters("Super Mario 64", "sm")).is_empty()
|
||||
|
||||
|
||||
func test_alternative_names_find_the_real_name() -> void:
|
||||
_search.set_names(["Final Fantasy VII", "Chrono Trigger"],
|
||||
{"Final Fantasy VII": ["FF7", "ファイナルファンタジーVII"]})
|
||||
|
||||
assert_array(_search.find_matches("ff7")).contains_exactly(["Final Fantasy VII"])
|
||||
assert_str(_search.matched_alternative("Final Fantasy VII")).is_equal("FF7")
|
||||
assert_array(_search.find_matches("ファイナル")).contains_exactly(["Final Fantasy VII"])
|
||||
|
||||
|
||||
func test_real_name_match_shows_no_alternative() -> void:
|
||||
_search.set_names(["Final Fantasy VII"], {"Final Fantasy VII": ["FF7"]})
|
||||
|
||||
assert_array(_search.find_matches("fantasy")).contains_exactly(["Final Fantasy VII"])
|
||||
assert_str(_search.matched_alternative("Final Fantasy VII")).is_empty()
|
||||
|
||||
|
||||
func test_soundtrack_is_found_once_when_several_names_match() -> void:
|
||||
_search.set_names(["Final Fantasy VII"], {"Final Fantasy VII": ["Final Fantasy 7"]})
|
||||
|
||||
assert_array(_search.find_matches("final")).contains_exactly(["Final Fantasy VII"])
|
||||
@@ -73,6 +73,7 @@ inspector or `theme_type_variation` in code:
|
||||
| `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 |
|
||||
| `TabButton` | Button | The Quiz and Statistics tabs; the chosen one is orange |
|
||||
| `SoundtrackCover` | Panel | A soundtrack's cover on a statistics card and in its popup: the colour behind the initials until the cover arrives, and the rounded corners the cover is cut to |
|
||||
| `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 |
|
||||
| `PlayerBar` | PanelContainer | The music player's rounded bar |
|
||||
|
||||
@@ -1240,6 +1240,14 @@ content_margin_right = 10.0
|
||||
content_margin_top = 4.0
|
||||
content_margin_bottom = 4.0
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_soundtrackcover"]
|
||||
bg_color = Color(0.361, 0.059, 0.094, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
corner_detail = 8
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("1_default_font")
|
||||
Button/colors/font_color = Color(0.478, 0.082, 0.125, 1.0)
|
||||
@@ -1495,6 +1503,8 @@ StepButton/styles/disabled = SubResource("StyleBoxFlat_step_disabled")
|
||||
StepButton/styles/hover = SubResource("StyleBoxFlat_step_hover")
|
||||
StepButton/styles/normal = SubResource("StyleBoxFlat_step_normal")
|
||||
StepButton/styles/pressed = SubResource("StyleBoxFlat_step_pressed")
|
||||
SoundtrackCover/base_type = &"Panel"
|
||||
SoundtrackCover/styles/panel = SubResource("StyleBoxFlat_soundtrackcover")
|
||||
StatisticsCard/base_type = &"PanelContainer"
|
||||
StatisticsCard/styles/panel = SubResource("StyleBoxFlat_statscard")
|
||||
StatisticsCardHover/base_type = &"PanelContainer"
|
||||
|
||||
@@ -1048,6 +1048,14 @@ corner_detail = 5
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_playericon"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_soundtrackcover"]
|
||||
bg_color = Color(0.22, 0.22, 0.22, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
corner_detail = 8
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("1_default_font")
|
||||
Button/colors/font_color = Color(0.875, 0.875, 0.875, 1)
|
||||
@@ -1303,6 +1311,8 @@ StepButton/styles/disabled = SubResource("StyleBoxFlat_step_disabled")
|
||||
StepButton/styles/hover = SubResource("StyleBoxFlat_step_hover")
|
||||
StepButton/styles/normal = SubResource("StyleBoxFlat_step_normal")
|
||||
StepButton/styles/pressed = SubResource("StyleBoxFlat_step_pressed")
|
||||
SoundtrackCover/base_type = &"Panel"
|
||||
SoundtrackCover/styles/panel = SubResource("StyleBoxFlat_soundtrackcover")
|
||||
StatisticsCard/base_type = &"PanelContainer"
|
||||
StatisticsCard/styles/panel = SubResource("StyleBoxFlat_statscard")
|
||||
StatisticsCardHover/base_type = &"PanelContainer"
|
||||
|
||||
@@ -1223,6 +1223,14 @@ content_margin_bottom = 4.0
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_playericon"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_soundtrackcover"]
|
||||
bg_color = Color(0.161, 0.341, 0.498, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
corner_detail = 8
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("1_default_font")
|
||||
Button/colors/font_color = Color(0.925, 0.937, 0.957, 1.0)
|
||||
@@ -1478,6 +1486,8 @@ StepButton/styles/disabled = SubResource("StyleBoxFlat_step_disabled")
|
||||
StepButton/styles/hover = SubResource("StyleBoxFlat_step_hover")
|
||||
StepButton/styles/normal = SubResource("StyleBoxFlat_step_normal")
|
||||
StepButton/styles/pressed = SubResource("StyleBoxFlat_step_pressed")
|
||||
SoundtrackCover/base_type = &"Panel"
|
||||
SoundtrackCover/styles/panel = SubResource("StyleBoxFlat_soundtrackcover")
|
||||
StatisticsCard/base_type = &"PanelContainer"
|
||||
StatisticsCard/styles/panel = SubResource("StyleBoxFlat_statscard")
|
||||
StatisticsCardHover/base_type = &"PanelContainer"
|
||||
|
||||
@@ -1149,6 +1149,14 @@ bg_color = Color(0.2, 0.2, 0.2, 1)
|
||||
|
||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_playericon"]
|
||||
|
||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_soundtrackcover"]
|
||||
bg_color = Color(0.259, 0.259, 0.259, 1)
|
||||
corner_radius_top_left = 8
|
||||
corner_radius_top_right = 8
|
||||
corner_radius_bottom_right = 8
|
||||
corner_radius_bottom_left = 8
|
||||
corner_detail = 8
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("1_default_font")
|
||||
Button/colors/font_color = Color(1, 0.612, 0, 1)
|
||||
@@ -1404,6 +1412,8 @@ StepButton/styles/disabled = SubResource("StyleBoxFlat_step_disabled")
|
||||
StepButton/styles/hover = SubResource("StyleBoxFlat_step_hover")
|
||||
StepButton/styles/normal = SubResource("StyleBoxFlat_step_normal")
|
||||
StepButton/styles/pressed = SubResource("StyleBoxFlat_step_pressed")
|
||||
SoundtrackCover/base_type = &"Panel"
|
||||
SoundtrackCover/styles/panel = SubResource("StyleBoxFlat_soundtrackcover")
|
||||
StatisticsCard/base_type = &"PanelContainer"
|
||||
StatisticsCard/styles/panel = SubResource("StyleBoxFlat_statscard")
|
||||
StatisticsCardHover/base_type = &"PanelContainer"
|
||||
|
||||
Reference in new issue
Block a user