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
@@ -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"])
|
||||
Reference in new issue
Block a user