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:
|
||||
|
||||
Reference in new issue
Block a user