diff --git a/common/character_cache.gd b/common/character_cache.gd new file mode 100644 index 0000000..d624e6a --- /dev/null +++ b/common/character_cache.gd @@ -0,0 +1,138 @@ +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()) diff --git a/common/character_cache.gd.uid b/common/character_cache.gd.uid new file mode 100644 index 0000000..dcc2da6 --- /dev/null +++ b/common/character_cache.gd.uid @@ -0,0 +1 @@ +uid://34xwe7vs2md7 diff --git a/data/playlist.gd b/data/playlist.gd index c91bba9..5a250a8 100644 --- a/data/playlist.gd +++ b/data/playlist.gd @@ -3,6 +3,12 @@ extends Node ## played, answered and playing. ## ## Registered as the [code]Playlist[/code] autoload. +## +## Downloaded songs are kept on disk in the [code]songs[/code] folder next to the +## settings, not in memory. The folder only holds the current playlist: it is emptied when +## the game starts and when the playlist is reset. + +const SONGS_FOLDER_NAME: String = "songs" var song_object_array: Array[SongObject] = [] var latest_played_song: int = 0 @@ -15,6 +21,18 @@ var is_song_downloading: bool = false ## ended with Reset points. var game_start_song: int = 0 +# How many song files have been written, so each gets a name of its own +var _song_files_written: int = 0 + + +func _ready() -> void: + clear_song_files() + + +## The folder downloaded songs are kept in. +static func songs_folder() -> String: + return Settings.config_dir().path_join(SONGS_FOLDER_NAME) + func get_next_song() -> SongObject: return song_object_array[latest_played_song] @@ -73,6 +91,7 @@ func reset_playlist() -> void: latest_played_song = 0 game_start_song = 0 clear_song_list() + clear_song_files() func clear_song_list() -> void: @@ -116,12 +135,48 @@ func remove_player_from_list(player_id: String) -> void: song_object.remove_point(player_id) +## The song of [param song_no], read from its file. Returns null if the file is gone, so +## the song can be downloaded again. func get_song(song_no: int) -> AudioStream: - return song_object_array[song_no].song + var song_object: SongObject = song_object_array[song_no] + if song_object.song_path.is_empty(): + return song_object.song + if !FileAccess.file_exists(song_object.song_path): + return null + return AudioStreamMP3.load_from_file(song_object.song_path) -func add_song_to_song_object(song_no: int, sound: AudioStream) -> void: - song_object_array[song_no].song = sound +func add_song_to_song_object(song_no: int, mp3: PackedByteArray) -> void: + store_song(song_object_array[song_no], mp3) + + +## Keeps a downloaded song in a file in [method songs_folder]. If the file can't be +## written, the song is kept in memory instead. +func store_song(song_object: SongObject, mp3: PackedByteArray) -> void: + DirAccess.make_dir_recursive_absolute(songs_folder()) + var path: String = songs_folder().path_join("song_%d.mp3" % _song_files_written) + _song_files_written += 1 + var file: FileAccess = FileAccess.open(path, FileAccess.WRITE) + if file == null: + GameLog.warning("Could not save a song to ", path, ", keeping it in memory: ", + error_string(FileAccess.get_open_error())) + var sound: AudioStreamMP3 = AudioStreamMP3.new() + sound.data = mp3 + song_object.song = sound + song_object.song_path = "" + return + file.store_buffer(mp3) + file.close() + song_object.song = null + song_object.song_path = path + + +## Deletes the files of the songs downloaded so far. +func clear_song_files() -> void: + if !DirAccess.dir_exists_absolute(songs_folder()): + return + for file_name: String in DirAccess.get_files_at(songs_folder()): + DirAccess.remove_absolute(songs_folder().path_join(file_name)) func song_has_played(song_no: int = latest_played_song) -> void: diff --git a/data/song_object.gd b/data/song_object.gd index 7500c90..b3eca53 100644 --- a/data/song_object.gd +++ b/data/song_object.gd @@ -12,6 +12,10 @@ var players_given_point: Array[String] = [] var is_answered: bool = false var has_played: bool = false var is_playing: bool = false +## The song's file in [method Playlist.songs_folder]. Read the song with +## [method Playlist.get_song]. +var song_path: String = "" +## The song itself, only when it could not be saved to a file. var song: AudioStream diff --git a/popups/character_card.gd b/popups/character_card.gd index eb9025c..096a737 100644 --- a/popups/character_card.gd +++ b/popups/character_card.gd @@ -16,17 +16,15 @@ func _ready() -> void: mouse_exited.connect(_set_hovered.bind(false)) -## Shows [param character_name] and fetches its picture from the server. -func set_character_name(character_name: String) -> void: +## Shows [param character_name] and its picture. [param fetch_image] is called with the +## name and a Callable that takes the picture as an [Image], e.g. +## [method CharacterCache.fetch_image]. +func set_character_name(character_name: String, fetch_image: Callable) -> void: character_name_label.text = display_name(character_name) tooltip_text = character_name_label.text - var image_fetched: Callable = func image_fetched(image: PackedByteArray) -> void: - var picture: Image = image_from_bytes(image) - if picture == null: - GameLog.warning("Could not load the picture of ", character_name) - return + var image_fetched: Callable = func image_fetched(picture: Image) -> void: character_image.texture = ImageTexture.create_from_image(picture) - MusicServerClient.fetch_character_image(character_name, image_fetched) + fetch_image.call(character_name, image_fetched) ## The name to show for a picture's [param file_name]: without the file extension, and diff --git a/popups/character_select_popup.gd b/popups/character_select_popup.gd index 2f0093a..678e00c 100644 --- a/popups/character_select_popup.gd +++ b/popups/character_select_popup.gd @@ -6,6 +6,8 @@ signal character_selected(texture: ImageTexture) var can_select_character: bool = false var character: PackedScene = preload("res://popups/CharacterCard.tscn") +## Keeps the pictures on disk, so only changed ones are downloaded. +var character_cache: CharacterCache = CharacterCache.new() @onready var character_panel: Panel = %CharacterPanel @onready var title_label: Label = %TitleLabel @@ -25,17 +27,16 @@ func _on_close_timeout() -> void: func load_characters() -> void: GameLog.debug("load_characters") - var fetch_character_list: Callable = func fetch_character_list(list: Variant) -> void: - GameLog.debug("fetch_character_list") + var list_loaded: Callable = func list_loaded(names: Array[String]) -> void: + GameLog.debug("list_loaded") Settings.delete_children(character_grid) - if typeof(list) == TYPE_ARRAY: - for character_name: String in list: - GameLog.debug("character_name ", character_name) - var character_box: CharacterCard = character.instantiate() - character_grid.add_child(character_box) - character_box.set_character_name(character_name) - character_box.new_character_selected.connect(_on_character_selected) - MusicServerClient.fetch_character_list(fetch_character_list) + for character_name: String in names: + GameLog.debug("character_name ", character_name) + var character_box: CharacterCard = character.instantiate() + character_grid.add_child(character_box) + character_box.set_character_name(character_name, character_cache.fetch_image) + character_box.new_character_selected.connect(_on_character_selected) + character_cache.load_list(list_loaded) func show_grid(player_name: String) -> void: diff --git a/quiz/quiz.gd b/quiz/quiz.gd index 70197aa..1617a42 100644 --- a/quiz/quiz.gd +++ b/quiz/quiz.gd @@ -161,9 +161,7 @@ func song_object_clicked(song_no: int) -> void: if Playlist.get_song(song_no) == null: GameLog.debug("has no song, need to download") var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void: - var sound: AudioStream = AudioStreamMP3.new() - sound.data = body - Playlist.add_song_to_song_object(song_no, sound) + Playlist.add_song_to_song_object(song_no, body) music_player.play_song_object(song_no) MusicServerClient.fetch_song(song_no, song_downloaded) else: diff --git a/quiz/song_loader.gd b/quiz/song_loader.gd index f3ced59..b7df69d 100644 --- a/quiz/song_loader.gd +++ b/quiz/song_loader.gd @@ -65,9 +65,7 @@ func fill_cache() -> void: func _download_random_song(new_song_object: SongObject) -> void: var song_downloaded: Callable = func song_downloaded(body: PackedByteArray) -> void: GameLog.debug("song_downloaded") - var sound: AudioStream = AudioStreamMP3.new() - sound.data = body - new_song_object.song = sound + Playlist.store_song(new_song_object, body) _add_song_to_queue(new_song_object) MusicServerClient.fetch_random_song(Settings.use_low_played_mode, song_downloaded, _retry_later.bind(_download_random_song.bind(new_song_object))) diff --git a/server/generated/character_details.gd b/server/generated/character_details.gd new file mode 100644 index 0000000..6547200 --- /dev/null +++ b/server/generated/character_details.gd @@ -0,0 +1,26 @@ +# Generated by tools/generate_api_types.gd from server/openapi.json. +# Do not edit; change the server and generate again. +class_name CharacterDetails +extends RefCounted +## The [code]CharacterDetails[/code] schema of the MusicServer API. + +## When the image was last changed, in Unix seconds +var modified: int +## File name of the character image +var name: String + + +## Reads the fields from parsed JSON. Missing fields keep their defaults. +static func from_dict(data: Dictionary) -> CharacterDetails: + var result: CharacterDetails = CharacterDetails.new() + result.modified = ApiJson.get_int(data, "modified", 0) + result.name = ApiJson.get_string(data, "name", "") + return result + + +## Returns the fields under their JSON names, ready for [method JSON.stringify]. +func to_dict() -> Dictionary: + return { + "modified": modified, + "name": name, + } diff --git a/server/generated/character_details.gd.uid b/server/generated/character_details.gd.uid new file mode 100644 index 0000000..ac6cea9 --- /dev/null +++ b/server/generated/character_details.gd.uid @@ -0,0 +1 @@ +uid://cu6wgjxv120en diff --git a/server/music_server_client.gd b/server/music_server_client.gd index c7132bf..40e21d5 100644 --- a/server/music_server_client.gd +++ b/server/music_server_client.gd @@ -216,6 +216,12 @@ func fetch_character_list(on_done: Callable, on_failed: Callable = Callable()) - get_json("/character/list", on_done, on_failed) +## Fetches every character image with when it was last changed, as an +## [code]Array[CharacterDetails][/code]. Servers older than this endpoint answer 404. +func fetch_character_details(on_done: Callable, on_failed: Callable = Callable()) -> void: + get_json_list("/character/details", CharacterDetails, on_done, on_failed) + + ## Downloads a character image as PNG bytes. func fetch_character_image( character_name: String, on_done: Callable, on_failed: Callable = Callable()) -> void: diff --git a/server/openapi.json b/server/openapi.json index 8030f58..162d600 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -17,6 +17,25 @@ ], "type": "object" }, + "CharacterDetails": { + "additionalProperties": false, + "properties": { + "modified": { + "description": "When the image was last changed, in Unix seconds", + "format": "int64", + "type": "integer" + }, + "name": { + "description": "File name of the character image", + "type": "string" + } + }, + "required": [ + "name", + "modified" + ], + "type": "object" + }, "ProgressResponse": { "additionalProperties": false, "properties": { @@ -437,7 +456,7 @@ } }, "info": { - "description": "API for the MusicServer application.\n\nGet a token with `POST /api/v1/token` and send it as `Authorization: Bearer `.\n\nThe same endpoints without the `/api/v1` prefix (legacy routes) are deprecated, need no token and are not documented here. They will be removed.", + "description": "API for the MusicServer application.\n\nGet a token with `POST /api/v1/token` and send it as `Authorization: Bearer `.\n\nEvery client has its own play queue. The music endpoints return its ID in the `X-Queue-Id` header; send it back in the same header to keep using that queue. Without it, or when the queue has ended (after a reset or a sync), a new queue is started.", "title": "MusicServer API", "version": "5.0.0" }, @@ -561,6 +580,56 @@ ] } }, + "/api/v1/character/details": { + "get": { + "description": "Returns every character image with the time it was last changed, so clients can cache images and download only the changed ones.", + "operationId": "list-character-details", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/CharacterDetails" + }, + "type": "array" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "List characters with change times", + "tags": [ + "characters" + ] + } + }, "/api/v1/character/list": { "get": { "description": "Returns the file names of all character images.", @@ -904,6 +973,15 @@ "description": "Jumps to the song at the given queue position (clamped to the queue) and returns it.", "operationId": "get-queue-song", "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "description": "Position of the song in the queue (SongNo)", "explode": false, @@ -928,7 +1006,16 @@ } } }, - "description": "The song (MP3)" + "description": "The song (MP3)", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "206": { "content": { @@ -940,7 +1027,16 @@ } } }, - "description": "Part of the file, for a Range request" + "description": "Part of the file, for a Range request", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1008,9 +1104,29 @@ "get": { "description": "Increments the play counters of the current song and its soundtrack.", "operationId": "mark-current-played", + "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { - "description": "OK" + "description": "OK", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1058,9 +1174,29 @@ "get": { "description": "Adds the song returned by the last random song request to the end of the queue.", "operationId": "add-latest-to-queue", + "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { - "description": "OK" + "description": "OK", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1288,6 +1424,17 @@ "get": { "description": "Returns the current song in the queue.", "operationId": "get-song-info", + "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -1297,7 +1444,16 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1335,6 +1491,17 @@ "get": { "description": "Returns all songs in the queue.", "operationId": "get-played-songs", + "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -1347,7 +1514,16 @@ } } }, - "description": "OK" + "description": "OK", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1385,6 +1561,17 @@ "get": { "description": "Moves to the next song in the queue and returns it. Stays on the last song at the end.", "operationId": "get-next-song", + "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -1396,7 +1583,16 @@ } } }, - "description": "The song (MP3)" + "description": "The song (MP3)", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "206": { "content": { @@ -1408,7 +1604,16 @@ } } }, - "description": "Part of the file, for a Range request" + "description": "Part of the file, for a Range request", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1467,6 +1672,15 @@ "description": "Increments the play counters of the song at the given queue position. Positions outside the queue are ignored.", "operationId": "mark-song-played", "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "description": "Position of the song in the queue (SongNo)", "explode": false, @@ -1482,7 +1696,16 @@ ], "responses": { "200": { - "description": "OK" + "description": "OK", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1540,6 +1763,17 @@ "get": { "description": "Moves to the previous song in the queue and returns it. Stays on the first song at the start.", "operationId": "get-previous-song", + "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -1551,7 +1785,16 @@ } } }, - "description": "The song (MP3)" + "description": "The song (MP3)", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "206": { "content": { @@ -1563,7 +1806,16 @@ } } }, - "description": "Part of the file, for a Range request" + "description": "Part of the file, for a Range request", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1621,6 +1873,17 @@ "get": { "description": "Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", "operationId": "get-random-song", + "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -1632,7 +1895,16 @@ } } }, - "description": "The song (MP3)" + "description": "The song (MP3)", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "206": { "content": { @@ -1644,7 +1916,16 @@ } } }, - "description": "Part of the file, for a Range request" + "description": "Part of the file, for a Range request", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1702,6 +1983,17 @@ "get": { "description": "Picks a random song where every song has the same chance, regardless of soundtrack.", "operationId": "get-random-song-classic", + "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -1713,7 +2005,16 @@ } } }, - "description": "The song (MP3)" + "description": "The song (MP3)", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "206": { "content": { @@ -1725,7 +2026,16 @@ } } }, - "description": "Part of the file, for a Range request" + "description": "Part of the file, for a Range request", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1783,6 +2093,17 @@ "get": { "description": "Picks a random song, favouring soundtracks that have been played less than average.", "operationId": "get-random-song-low-chance", + "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -1794,7 +2115,16 @@ } } }, - "description": "The song (MP3)" + "description": "The song (MP3)", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "206": { "content": { @@ -1806,7 +2136,16 @@ } } }, - "description": "Part of the file, for a Range request" + "description": "Part of the file, for a Range request", + "headers": { + "X-Queue-Id": { + "description": "ID of the client's play queue. Send it with the following requests.", + "schema": { + "format": "uuid", + "type": "string" + } + } + } }, "401": { "content": { @@ -1862,11 +2201,30 @@ }, "/api/v1/music/reset": { "get": { - "description": "Clears the queue and reloads the soundtrack list.", + "description": "Ends the queue, starts a new one and reloads the soundtrack list. The new queue's ID is returned in the X-Queue-Id header; send it with the following requests.", "operationId": "reset-queue", + "parameters": [ + { + "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", + "in": "header", + "name": "X-Queue-Id", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], "responses": { "200": { - "description": "OK" + "description": "OK", + "headers": { + "X-Queue-Id": { + "schema": { + "description": "ID of the new queue", + "type": "string" + } + } + } }, "401": { "content": { diff --git a/tests/integration/test_song_loader.gd b/tests/integration/test_song_loader.gd index 9a4feb2..c59bbf5 100644 --- a/tests/integration/test_song_loader.gd +++ b/tests/integration/test_song_loader.gd @@ -64,7 +64,9 @@ func test_fetch_song_downloads_queues_and_adds_the_song() -> void: .contains_exactly(["/music/rand", "/music/addQue", "/music/info"]) var song: SongObject = Playlist.song_object_array[0] assert_str(song.song_title).is_equal("Corridors of Time") - assert_object(song.song).is_not_null() + assert_object(song.song).override_failure_message("Songs are kept on disk").is_null() + assert_str(song.song_path.get_base_dir()).is_equal(Playlist.songs_folder()) + assert_object(Playlist.get_song(0)).is_not_null() assert_bool(Playlist.is_song_downloading).is_false() diff --git a/tests/unit/test_character_cache.gd b/tests/unit/test_character_cache.gd new file mode 100644 index 0000000..20a304a --- /dev/null +++ b/tests/unit/test_character_cache.gd @@ -0,0 +1,191 @@ +extends GdUnitTestSuite +## Character pictures cached on disk: downloaded once, then only again when they changed +## on the server, or when they are old and the server can't tell. + +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("character_cache")) + if DirAccess.dir_exists_absolute(CharacterCache.folder()): + for file_name: String in DirAccess.get_files_at(CharacterCache.folder()): + DirAccess.remove_absolute(CharacterCache.folder().path_join(file_name)) + var image: Image = Image.create(4, 4, false, Image.FORMAT_RGB8) + image.fill(Color.ORANGE) + _png = image.save_png_to_buffer() + + +func after_test() -> void: + OS.set_environment(Settings.CONFIG_DIR_ENV, _config_dir_before) + _server.uninstall() + + +func _serve(characters: Dictionary) -> void: + var details: Array = [] + for character_name: String in characters: + details.append({"name": character_name, "modified": characters[character_name]}) + _server.respond_bytes(_image_path(character_name), _png) + _server.respond_json("/character/details", details) + + +# Serves the names only, like a server older than /character/details +func _serve_names_only(names: Array[String]) -> void: + _server.respond_json("/character/list", names) + for character_name: String in names: + _server.respond_bytes(_image_path(character_name), _png) + + +func _image_path(character_name: String) -> String: + return "/character?name=" + character_name.uri_encode() + + +# Loads the list and every picture with a new cache, like starting the game +func _load_all() -> Array[Image]: + var cache: CharacterCache = CharacterCache.new() + var images: Array[Image] = [] + cache.load_list(func(names: Array[String]) -> void: + for character_name: String in names: + cache.fetch_image(character_name, func(image: Image) -> void: images.append(image))) + return images + + +func _index() -> Dictionary: + return JSON.parse_string(FileAccess.get_file_as_string( + CharacterCache.folder().path_join(CharacterCache.INDEX_FILE_NAME))) + + +func _set_index(index: Dictionary) -> void: + FileAccess.open(CharacterCache.folder().path_join(CharacterCache.INDEX_FILE_NAME), + FileAccess.WRITE).store_string(JSON.stringify(index)) + + +func test_first_load_downloads_and_keeps_every_picture() -> void: + _serve({"Crono.png": 100, "Marle.png": 200}) + + var images: Array[Image] = _load_all() + + assert_int(images.size()).is_equal(2) + assert_int(_server.count(_image_path("Crono.png"))).is_equal(1) + assert_bool(FileAccess.file_exists(CharacterCache.folder().path_join("Crono.png"))).is_true() + assert_int(int(_index()["Marle.png"]["modified"])).is_equal(200) + + +func test_unchanged_pictures_come_from_disk() -> void: + _serve({"Crono.png": 100, "Marle.png": 200}) + _load_all() + + var images: Array[Image] = _load_all() + + assert_int(images.size()).is_equal(2) + assert_int(_server.count(_image_path("Crono.png"))).is_equal(1) + assert_int(_server.count(_image_path("Marle.png"))).is_equal(1) + + +func test_only_changed_pictures_are_downloaded_again() -> void: + _serve({"Crono.png": 100, "Marle.png": 200}) + _load_all() + + _serve({"Crono.png": 100, "Marle.png": 300}) + _load_all() + + assert_int(_server.count(_image_path("Crono.png"))).is_equal(1) + assert_int(_server.count(_image_path("Marle.png"))).is_equal(2) + assert_int(int(_index()["Marle.png"]["modified"])).is_equal(300) + + +func test_new_character_is_downloaded() -> void: + _serve({"Crono.png": 100}) + _load_all() + + _serve({"Crono.png": 100, "Lucca.png": 100}) + var images: Array[Image] = _load_all() + + assert_int(images.size()).is_equal(2) + assert_int(_server.count(_image_path("Lucca.png"))).is_equal(1) + + +func test_characters_removed_on_the_server_are_deleted() -> void: + _serve({"Crono.png": 100, "Magus.png": 100}) + _load_all() + + _serve({"Crono.png": 100}) + _load_all() + + assert_bool(FileAccess.file_exists(CharacterCache.folder().path_join("Magus.png"))) \ + .is_false() + assert_bool(_index().has("Magus.png")).is_false() + + +func test_broken_cached_picture_is_downloaded_again() -> void: + _serve({"Crono.png": 100}) + _load_all() + FileAccess.open(CharacterCache.folder().path_join("Crono.png"), FileAccess.WRITE) \ + .store_string("not a picture") + + var images: Array[Image] = _load_all() + + assert_int(images.size()).is_equal(1) + assert_int(_server.count(_image_path("Crono.png"))).is_equal(2) + + +func test_older_server_lists_names_and_young_pictures_come_from_disk() -> void: + _serve_names_only(["Crono.png"]) + _load_all() + + var images: Array[Image] = _load_all() + + assert_int(images.size()).is_equal(1) + assert_int(_server.count("/character/details")).is_equal(2) + assert_int(_server.count(_image_path("Crono.png"))).is_equal(1) + assert_int(int(_index()["Crono.png"]["modified"])).is_equal(CharacterCache.UNKNOWN_TIME) + + +func test_older_server_pictures_are_downloaded_again_when_old() -> void: + _serve_names_only(["Crono.png", "Marle.png"]) + _load_all() + var index: Dictionary = _index() + var too_old: int = int(Time.get_unix_time_from_system()) - CharacterCache.MAX_AGE_SECONDS - 1 + index["Marle.png"]["downloaded"] = too_old + _set_index(index) + + _load_all() + + assert_int(_server.count(_image_path("Crono.png"))).is_equal(1) + assert_int(_server.count(_image_path("Marle.png"))).is_equal(2) + + +func test_picture_cached_without_a_time_is_downloaded_once_the_server_has_times() -> void: + _serve_names_only(["Crono.png"]) + _load_all() + + _serve({"Crono.png": 100}) + _load_all() + _load_all() + + assert_int(_server.count(_image_path("Crono.png"))).is_equal(2) + + +func test_picture_that_cannot_be_read_is_not_cached() -> void: + _server.respond_json("/character/details", [{"name": "Crono.png", "modified": 100}]) + _server.respond_bytes(_image_path("Crono.png"), "not a picture".to_utf8_buffer()) + + var images: Array[Image] = _load_all() + + assert_array(images).is_empty() + assert_bool(FileAccess.file_exists(CharacterCache.folder().path_join("Crono.png"))).is_false() + + +func test_broken_index_starts_over() -> void: + _serve({"Crono.png": 100}) + _load_all() + FileAccess.open(CharacterCache.folder().path_join(CharacterCache.INDEX_FILE_NAME), + FileAccess.WRITE).store_string("{broken") + + var images: Array[Image] = _load_all() + + assert_int(images.size()).is_equal(1) + assert_int(_server.count(_image_path("Crono.png"))).is_equal(2) diff --git a/tests/unit/test_character_cache.gd.uid b/tests/unit/test_character_cache.gd.uid new file mode 100644 index 0000000..ed9ecc2 --- /dev/null +++ b/tests/unit/test_character_cache.gd.uid @@ -0,0 +1 @@ +uid://bb74d4gbslmcf diff --git a/tests/unit/test_song_files.gd b/tests/unit/test_song_files.gd new file mode 100644 index 0000000..30d2c99 --- /dev/null +++ b/tests/unit/test_song_files.gd @@ -0,0 +1,85 @@ +extends GdUnitTestSuite +## Downloaded songs are kept in files in the songs folder, not in memory. + +var _snapshot: AutoloadSnapshot +var _config_dir_before: String +var _mp3: PackedByteArray + + +func before_test() -> void: + _snapshot = AutoloadSnapshot.take([Playlist]) + _config_dir_before = OS.get_environment(Settings.CONFIG_DIR_ENV) + OS.set_environment(Settings.CONFIG_DIR_ENV, create_temp_dir("song_files")) + Playlist.clear_song_files() + Playlist.song_object_array.clear() + _mp3 = FileAccess.get_file_as_bytes("res://tests/data/short.mp3") + + +func after_test() -> void: + Playlist.clear_song_files() + OS.set_environment(Settings.CONFIG_DIR_ENV, _config_dir_before) + _snapshot.restore() + + +func _add_song() -> SongObject: + var song_object: SongObject = SongObject.new() + Playlist.store_song(song_object, _mp3) + Playlist.add_to_playlist(song_object) + return song_object + + +func test_stored_song_is_a_file_and_plays_back() -> void: + var song_object: SongObject = _add_song() + + assert_object(song_object.song).is_null() + assert_bool(FileAccess.file_exists(song_object.song_path)).is_true() + var stream: AudioStream = Playlist.get_song(0) + assert_object(stream).is_instanceof(AudioStreamMP3) + assert_float(stream.get_length()).is_greater(0.0) + + +func test_every_song_gets_a_file_of_its_own() -> void: + var first: SongObject = _add_song() + var second: SongObject = _add_song() + + assert_str(first.song_path).is_not_equal(second.song_path) + assert_array(DirAccess.get_files_at(Playlist.songs_folder())).has_size(2) + + +func test_downloading_a_song_again_replaces_its_file() -> void: + var song_object: SongObject = _add_song() + var old_path: String = song_object.song_path + + Playlist.add_song_to_song_object(0, _mp3) + + assert_str(song_object.song_path).is_not_equal(old_path) + assert_object(Playlist.get_song(0)).is_not_null() + + +func test_resetting_the_playlist_deletes_the_files() -> void: + _add_song() + _add_song() + + Playlist.reset_playlist() + + assert_array(DirAccess.get_files_at(Playlist.songs_folder())).is_empty() + + +func test_missing_file_gives_no_song_so_it_is_downloaded_again() -> void: + var song_object: SongObject = _add_song() + + DirAccess.remove_absolute(song_object.song_path) + + assert_object(Playlist.get_song(0)).is_null() + + +func test_song_stays_in_memory_when_it_cannot_be_saved() -> void: + # A file where the folder should be, so the folder can't be made + var blocker: String = create_temp_dir("song_files_blocked").path_join("not_a_folder") + FileAccess.open(blocker, FileAccess.WRITE).store_string("x") + OS.set_environment(Settings.CONFIG_DIR_ENV, blocker) + + var song_object: SongObject = _add_song() + + assert_str(song_object.song_path).is_empty() + assert_object(Playlist.get_song(0)).is_instanceof(AudioStreamMP3) diff --git a/tests/unit/test_song_files.gd.uid b/tests/unit/test_song_files.gd.uid new file mode 100644 index 0000000..2d04e9c --- /dev/null +++ b/tests/unit/test_song_files.gd.uid @@ -0,0 +1 @@ +uid://b0x7lfm0d5kmc