class_name CharacterCache extends RefCounted ## Character pictures kept on disk in the [code]characters[/code] folder next to the ## settings, so each one is downloaded only when it changed on the server. ## ## [code]index.json[/code] in the folder records, for each picture, when the server last ## changed it and when it was downloaded: ## [codeblock] ## {"Crono.png": {"modified": 1730000000, "downloaded": 1730100000}} ## [/codeblock] ## A cached picture is used while the server's change time matches the index. Servers that ## can't tell when pictures changed answer 404 for [code]/character/details[/code]; then a ## cached picture is used until it is [constant MAX_AGE_SECONDS] old. const FOLDER_NAME: String = "characters" const INDEX_FILE_NAME: String = "index.json" ## 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 # What index.json holds: name -> {"modified": int, "downloaded": int} var _index: Dictionary = {} # When the server last changed each picture on the list, or UNKNOWN_TIME var _modified: Dictionary[String, int] = {} ## The folder the pictures are kept in. static func folder() -> String: return Settings.config_dir().path_join(FOLDER_NAME) ## Fetches the names of the characters and calls [param on_done] with them as an ## [code]Array[String][/code]. Cached pictures of characters no longer on the server are ## deleted. func load_list(on_done: Callable) -> void: _load_index() var details_fetched: Callable = func details_fetched(details: Array) -> void: _modified.clear() for character: CharacterDetails in details: _modified[character.name] = character.modified _list_loaded(on_done) MusicServerClient.fetch_character_details(details_fetched, _load_names_only.bind(on_done)) ## 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 _is_fresh(character_name) && FileAccess.file_exists(path): var cached: Image = CharacterCard.image_from_bytes(FileAccess.get_file_as_bytes(path)) if cached != null: on_done.call(cached) return GameLog.warning("The cached picture of ", character_name, " is broken") var image_fetched: Callable = func image_fetched(bytes: PackedByteArray) -> void: var picture: Image = CharacterCard.image_from_bytes(bytes) if picture == null: GameLog.warning("Could not load the picture of ", character_name) return _store(character_name, bytes) on_done.call(picture) MusicServerClient.fetch_character_image(character_name, image_fetched) # Servers older than /character/details only list the names func _load_names_only(on_done: Callable) -> void: var list_fetched: Callable = func list_fetched(list: Variant) -> void: _modified.clear() if list is Array: for character_name: Variant in list: _modified[str(character_name)] = UNKNOWN_TIME _list_loaded(on_done) MusicServerClient.fetch_character_list(list_fetched) 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() var names: Array[String] = [] names.assign(_modified.keys()) on_done.call(names) func _is_fresh(character_name: String) -> bool: if !_index.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())