Add a soundtrack by picking or dropping a folder (#50)
"Add soundtrack…" in the statistics toolbar, or a folder dropped anywhere on the window, opens a popup that lists the folder's songs and uploads them to the server, which adds the soundtrack at once (Sansan/MusicServer#33). - The name comes from the folder and can be changed; characters the server refuses are replaced. - Only MP3s are uploaded, as the server takes no other; other song files are listed greyed out as left out. Hidden files are skipped. - Disc folders are flattened with the disc's number first ("1-01 Title.mp3"), as the server wants one folder. With one disc folder, names stay as they are. - Songs are sent one at a time with a progress bar, reading one file at a time. A failed song is sent again twice, then the upload is cancelled and the server's reason shown. Closing the popup cancels it too. - Not offered while the server syncs, offline, or without MP3s. - Afterwards search, the inspiration list and the statistics reload. The API types come from the server's spec (Upload, UploadFile, UploadRequest). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f39865fffc
commit
c86dacb890
25 files changed
+3306
-4314
No files matched your search
+2275
-30
File diff suppressed because it is too large.
Load diff
@@ -43,6 +43,7 @@ var queue_id: String = ""
|
||||
|
||||
var _responses: Dictionary = {}
|
||||
var _successes_left: Dictionary = {}
|
||||
var _failures_left: Dictionary = {}
|
||||
var _held_paths: Dictionary = {}
|
||||
var _previous_transport: HttpTransport
|
||||
var _previous_queue_ids: Dictionary[String, String]
|
||||
@@ -89,6 +90,11 @@ func respond_bytes(path: String, body: PackedByteArray, response_code: int = 200
|
||||
_successes_left.erase(path)
|
||||
|
||||
|
||||
## Answers the next [param failures] requests to [param path] with a 503, then as usual.
|
||||
func fail_first(path: String, failures: int) -> void:
|
||||
_failures_left[path] = failures
|
||||
|
||||
|
||||
## Lets [param successes] more requests to [param path] succeed, then answers 503 until
|
||||
## a new response is registered for the path.
|
||||
func fail_after(path: String, successes: int) -> void:
|
||||
@@ -156,6 +162,10 @@ func _answer(path: String, on_complete: Callable, follow_redirects: bool = true)
|
||||
var headers: PackedStringArray = []
|
||||
if !queue_id.is_empty() && path != "/token":
|
||||
headers.append("X-Queue-Id: " + queue_id)
|
||||
if _failures_left.get(path, 0) > 0:
|
||||
_failures_left[path] -= 1
|
||||
on_complete.call(503, PackedByteArray(), headers)
|
||||
return
|
||||
if _successes_left.has(path):
|
||||
if _successes_left[path] == 0:
|
||||
on_complete.call(503, PackedByteArray(), headers)
|
||||
|
||||
@@ -72,6 +72,25 @@ func _init(main: Control) -> void:
|
||||
_main = main
|
||||
|
||||
|
||||
## A folder like a ripped two-disc soundtrack, to upload: MP3s in disc folders, and a
|
||||
## FLAC that is left out.
|
||||
static func _upload_folder() -> String:
|
||||
var folder: String = Settings.config_dir().path_join("upload/Chrono Trigger")
|
||||
var mp3: PackedByteArray = FileAccess.get_file_as_bytes("res://tests/data/short.mp3")
|
||||
var files: Array[String] = []
|
||||
for disc: int in [1, 2]:
|
||||
for track: int in 4:
|
||||
files.append("Disc %d/%02d %s.mp3" % [disc, track + 1, JAPANESE_SONGS[track]])
|
||||
files.append("Disc 2/05 Bonus.flac")
|
||||
for relative: String in files:
|
||||
var path: String = folder.path_join(relative)
|
||||
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||
file.store_buffer(mp3)
|
||||
file.close()
|
||||
return folder
|
||||
|
||||
|
||||
## Returns a fake server that answers every request the main window makes on the tour.
|
||||
## Call [method FakeHttpTransport.install] before the main window is created.
|
||||
static func make_server() -> FakeHttpTransport:
|
||||
@@ -343,6 +362,22 @@ func run(on_state: Callable) -> void:
|
||||
await on_state.call("popup_SoundtrackStats_cover", stats_popup)
|
||||
stats_popup.stop_editing()
|
||||
stats_popup.close()
|
||||
await _settle()
|
||||
|
||||
var upload_popup: UploadPopup = _node("UploadPopup")
|
||||
upload_popup.show_folder(_upload_folder())
|
||||
await _settle()
|
||||
await on_state.call("popup_UploadWindow", upload_popup)
|
||||
upload_popup._set_uploading(true)
|
||||
upload_popup.upload_progress.max_value = upload_popup.folder.songs.size()
|
||||
upload_popup.upload_progress.value = 3
|
||||
upload_popup.upload_progress.visible = true
|
||||
upload_popup._show_status("Uploading 4 of %d: %s" % [upload_popup.folder.songs.size(),
|
||||
upload_popup.folder.songs[3].name])
|
||||
await _settle()
|
||||
await on_state.call("popup_UploadWindow_uploading", upload_popup)
|
||||
upload_popup._set_uploading(false)
|
||||
upload_popup.close()
|
||||
_main.show_tab(&"quiz")
|
||||
await _settle()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ extends MainWindowTestSuite
|
||||
const MAIN_WINDOW_CHILDREN: Array[String] = [
|
||||
"Background", "TopBar", "Quiz", "PopupBackdrop", "SearchPopup", "SyncPopup", "QRPopup",
|
||||
"ShortcutsPopup", "StatisticsView", "AboutPopup", "LogPopup", "SoundtrackStatsPopup",
|
||||
"SettingsPopup", "UpdatePopup",
|
||||
"SettingsPopup", "UpdatePopup", "UploadPopup",
|
||||
]
|
||||
|
||||
## The parts of the quiz and the scene each one comes from.
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
extends MainWindowTestSuite
|
||||
## Adding a soundtrack from a folder: picked in the statistics or dropped on the window,
|
||||
## uploaded one song at a time, and found everywhere afterwards.
|
||||
|
||||
const UPLOADS: String = "/soundtracks/uploads"
|
||||
const UPLOAD_ID: String = "0b5e2b1e-3a4f-4d5e-9f00-000000000001"
|
||||
|
||||
var _popup: UploadPopup
|
||||
var _dir: String
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
await super()
|
||||
_popup = _window.get_node("UploadPopup")
|
||||
_dir = create_temp_dir("upload_%d" % randi()).path_join("Chrono Trigger")
|
||||
_song("01 Title.mp3", "ID3 title")
|
||||
_song("Disc 2/01 Ending.mp3", "ID3 ending")
|
||||
_song("02 Theme.flac", "flac")
|
||||
|
||||
|
||||
func after_test() -> void:
|
||||
Settings.currently_syncing = false
|
||||
await super()
|
||||
|
||||
|
||||
func _song(relative: String, content: String) -> void:
|
||||
var path: String = _dir.path_join(relative)
|
||||
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||
file.store_string(content)
|
||||
file.close()
|
||||
|
||||
|
||||
func _file_path(file_name: String) -> String:
|
||||
return "%s/%s/files/%s" % [UPLOADS, UPLOAD_ID, file_name.uri_encode()]
|
||||
|
||||
|
||||
func _server_accepts_everything() -> void:
|
||||
_server.respond_json(UPLOADS, {"id": UPLOAD_ID, "name": "Chrono Trigger", "files": []},
|
||||
201)
|
||||
_server.respond_json(_file_path("01 Title.mp3"), {"name": "01 Title.mp3", "size": 9})
|
||||
_server.respond_json(_file_path("01 Ending.mp3"), {"name": "01 Ending.mp3", "size": 10})
|
||||
_server.respond_json("%s/%s/finish" % [UPLOADS, UPLOAD_ID], {"uuid": "st-new",
|
||||
"name": "Chrono Trigger", "songs": [{"name": "01 Title"}, {"name": "01 Ending"}]})
|
||||
_server.respond_bytes("%s/%s" % [UPLOADS, UPLOAD_ID], PackedByteArray(), 204)
|
||||
|
||||
|
||||
func _upload_requests() -> Array[String]:
|
||||
var paths: Array[String] = []
|
||||
for path: String in _server.requested_paths:
|
||||
if path.begins_with(UPLOADS):
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
func test_folder_lists_its_songs_and_what_is_left_out() -> void:
|
||||
_popup.show_folder(_dir)
|
||||
|
||||
assert_bool(_popup.visible).is_true()
|
||||
assert_str(_popup.name_field.text).is_equal("Chrono Trigger")
|
||||
assert_int(_popup.song_list.get_child_count()).is_equal(3)
|
||||
assert_str(_popup.summary_label.text).starts_with("2 songs, ")
|
||||
assert_str(_popup.summary_label.text).contains("1 file left out")
|
||||
assert_bool(_popup.upload_button.disabled).is_false()
|
||||
|
||||
|
||||
func test_upload_sends_each_song_then_finishes() -> void:
|
||||
_server_accepts_everything()
|
||||
var added: Array[String] = []
|
||||
_popup.soundtrack_added.connect(
|
||||
func(soundtrack: SoundtrackWithSongList) -> void: added.append(soundtrack.name))
|
||||
var searches_before: int = _server.count("/soundtracks")
|
||||
_popup.show_folder(_dir)
|
||||
_popup.name_field.text = "Chrono Trigger (SNES)"
|
||||
|
||||
_popup.upload()
|
||||
|
||||
assert_array(_upload_requests()).contains_exactly([UPLOADS,
|
||||
_file_path("01 Title.mp3"), _file_path("01 Ending.mp3"),
|
||||
"%s/%s/finish" % [UPLOADS, UPLOAD_ID]])
|
||||
var start: int = _server.requested_paths.find(UPLOADS)
|
||||
assert_str(JSON.parse_string(_server.requested_bodies[start]).name) \
|
||||
.is_equal("Chrono Trigger (SNES)")
|
||||
var first_song: int = _server.requested_paths.find(_file_path("01 Title.mp3"))
|
||||
assert_int(_server.requested_methods[first_song]).is_equal(HTTPClient.METHOD_PUT)
|
||||
assert_str(_server.requested_bodies[first_song]).is_equal("ID3 title")
|
||||
assert_array(_server.requested_headers[first_song]).contains(["Content-Type: audio/mpeg"])
|
||||
assert_array(added).contains_exactly(["Chrono Trigger"])
|
||||
assert_str(_popup.status_label.text).is_equal("Added Chrono Trigger with 2 songs.")
|
||||
assert_bool(_popup.upload_button.visible).is_false()
|
||||
assert_str(_popup.cancel_button.text).is_equal("Close")
|
||||
# Search finds the new soundtrack
|
||||
assert_int(_server.count("/soundtracks")).is_greater(searches_before)
|
||||
|
||||
|
||||
func test_song_that_fails_once_is_sent_again() -> void:
|
||||
_server_accepts_everything()
|
||||
_server.fail_first(_file_path("01 Title.mp3"), 1)
|
||||
_popup.show_folder(_dir)
|
||||
|
||||
_popup.upload()
|
||||
|
||||
assert_int(_server.count(_file_path("01 Title.mp3"))).is_equal(2)
|
||||
assert_int(_server.count("%s/%s/finish" % [UPLOADS, UPLOAD_ID])).is_equal(1)
|
||||
|
||||
|
||||
func test_song_that_keeps_failing_cancels_the_upload() -> void:
|
||||
_server_accepts_everything()
|
||||
_server.respond_json(_file_path("01 Title.mp3"),
|
||||
{"error": "\"01 Title.mp3\" is not an MP3 file"}, 400)
|
||||
_popup.show_folder(_dir)
|
||||
|
||||
_popup.upload()
|
||||
|
||||
assert_int(_server.count(_file_path("01 Title.mp3"))).is_equal(UploadPopup.RETRIES + 1)
|
||||
var cancel: int = _server.requested_paths.find("%s/%s" % [UPLOADS, UPLOAD_ID])
|
||||
assert_int(_server.requested_methods[cancel]).is_equal(HTTPClient.METHOD_DELETE)
|
||||
assert_int(_server.count("%s/%s/finish" % [UPLOADS, UPLOAD_ID])).is_equal(0)
|
||||
assert_str(_popup.status_label.text).is_equal(
|
||||
"Could not upload 01 Title.mp3: \"01 Title.mp3\" is not an MP3 file.")
|
||||
assert_bool(_popup.upload_button.disabled).is_false()
|
||||
|
||||
|
||||
func test_taken_name_says_so() -> void:
|
||||
_server.respond_json(UPLOADS,
|
||||
{"error": "already exists: a soundtrack called \"Chrono Trigger\""}, 409)
|
||||
_popup.show_folder(_dir)
|
||||
|
||||
_popup.upload()
|
||||
|
||||
assert_str(_popup.status_label.text).contains("a soundtrack called \"Chrono Trigger\"")
|
||||
assert_array(_upload_requests()).contains_exactly([UPLOADS])
|
||||
assert_bool(_popup.name_field.editable).is_true()
|
||||
|
||||
|
||||
func test_closing_during_the_upload_cancels_it() -> void:
|
||||
_server_accepts_everything()
|
||||
_server.hold(_file_path("01 Title.mp3"))
|
||||
_popup.show_folder(_dir)
|
||||
_popup.upload()
|
||||
|
||||
_popup.close()
|
||||
_server.release(_file_path("01 Title.mp3"))
|
||||
|
||||
assert_int(_server.count("%s/%s" % [UPLOADS, UPLOAD_ID])).is_equal(1)
|
||||
assert_int(_server.count(_file_path("01 Ending.mp3"))).is_equal(0)
|
||||
|
||||
|
||||
func test_no_upload_while_the_server_syncs() -> void:
|
||||
Settings.currently_syncing = true
|
||||
_popup.show_folder(_dir)
|
||||
|
||||
_popup.upload()
|
||||
|
||||
assert_array(_upload_requests()).is_empty()
|
||||
assert_str(_popup.status_label.text).contains("syncing")
|
||||
|
||||
|
||||
func test_folder_without_mp3s_cannot_be_uploaded() -> void:
|
||||
var empty: String = _dir.get_base_dir().path_join("Empty")
|
||||
DirAccess.make_dir_recursive_absolute(empty)
|
||||
|
||||
_popup.show_folder(empty)
|
||||
|
||||
assert_bool(_popup.upload_button.disabled).is_true()
|
||||
assert_str(_popup.status_label.text).contains("no MP3 files")
|
||||
|
||||
|
||||
func test_empty_name_cannot_be_uploaded() -> void:
|
||||
_popup.show_folder(_dir)
|
||||
|
||||
_popup.name_field.text = " .. "
|
||||
_popup.name_field.text_changed.emit(_popup.name_field.text)
|
||||
|
||||
assert_bool(_popup.upload_button.disabled).is_true()
|
||||
|
||||
|
||||
func test_dropping_a_folder_opens_it() -> void:
|
||||
_window.on_files_dropped(PackedStringArray([_dir]))
|
||||
|
||||
assert_bool(_popup.visible).is_true()
|
||||
assert_str(_popup.folder.path).is_equal(_dir)
|
||||
|
||||
|
||||
func test_dropping_songs_opens_their_folder() -> void:
|
||||
_window.on_files_dropped(PackedStringArray([_dir.path_join("01 Title.mp3")]))
|
||||
|
||||
assert_str(_popup.folder.path).is_equal(_dir)
|
||||
|
||||
|
||||
func test_statistics_has_the_button() -> void:
|
||||
var statistics: StatisticsView = _window.get_node("StatisticsView")
|
||||
|
||||
assert_bool(statistics.add_soundtrack_pressed.is_connected(_popup.choose_folder)).is_true()
|
||||
assert_str(statistics.add_soundtrack_button.text).is_equal("Add soundtrack…")
|
||||
|
||||
|
||||
@warning_ignore("unused_parameter")
|
||||
func test_short_path(path: String, shown: String, test_parameters: Array = [
|
||||
["/home/me/Music/Chrono Trigger", "…/Music/Chrono Trigger"],
|
||||
["C:\\Music\\Soundtracks\\Chrono Trigger", "…/Soundtracks/Chrono Trigger"],
|
||||
["/Music", "/Music"],
|
||||
]) -> void:
|
||||
assert_str(UploadPopup.short_path(path)).is_equal(shown)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcr68ps41ex1r
|
||||
@@ -0,0 +1,122 @@
|
||||
extends GdUnitTestSuite
|
||||
## Reading a folder to upload as a soundtrack: only MP3s are sent, disc folders are
|
||||
## flattened with the disc's number first, and other song files are listed as left out.
|
||||
|
||||
var _dir: String
|
||||
|
||||
|
||||
func before_test() -> void:
|
||||
_dir = create_temp_dir("upload_folder_%d" % randi()).path_join("Chrono Trigger")
|
||||
DirAccess.make_dir_recursive_absolute(_dir)
|
||||
|
||||
|
||||
func _file(relative: String, size: int = 10) -> void:
|
||||
var path: String = _dir.path_join(relative)
|
||||
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||
var bytes: PackedByteArray = PackedByteArray()
|
||||
bytes.resize(size)
|
||||
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
||||
file.store_buffer(bytes)
|
||||
file.close()
|
||||
|
||||
|
||||
func _names(folder: UploadFolder) -> Array:
|
||||
return folder.songs.map(func(song: Dictionary) -> String: return song.name)
|
||||
|
||||
|
||||
func test_name_comes_from_the_folder() -> void:
|
||||
_file("01 Title.mp3")
|
||||
|
||||
assert_str(UploadFolder.scan(_dir).suggested_name).is_equal("Chrono Trigger")
|
||||
assert_str(UploadFolder.scan(_dir + "/").suggested_name).is_equal("Chrono Trigger")
|
||||
|
||||
|
||||
func test_songs_in_natural_order_with_sizes() -> void:
|
||||
_file("10 Ending.mp3", 300)
|
||||
_file("2 Peaceful Days.MP3", 200)
|
||||
_file("1 Title.mp3", 100)
|
||||
|
||||
var folder: UploadFolder = UploadFolder.scan(_dir)
|
||||
|
||||
assert_array(_names(folder)).contains_exactly(
|
||||
["1 Title.mp3", "2 Peaceful Days.MP3", "10 Ending.mp3"])
|
||||
assert_int(folder.songs[0].size).is_equal(100)
|
||||
assert_int(folder.total_size).is_equal(600)
|
||||
assert_str(folder.songs[0].path).is_equal(_dir.path_join("1 Title.mp3"))
|
||||
|
||||
|
||||
func test_disc_folders_are_flattened_with_their_number() -> void:
|
||||
_file("Disc 10/01 Last.mp3")
|
||||
_file("Disc 2/01 Middle.mp3")
|
||||
_file("Disc 1/01 First.mp3")
|
||||
_file("Disc 1/Bonus/01 Extra.mp3")
|
||||
|
||||
var folder: UploadFolder = UploadFolder.scan(_dir)
|
||||
|
||||
assert_array(_names(folder)).contains_exactly(
|
||||
["1-01 First.mp3", "1-01 Extra.mp3", "2-01 Middle.mp3", "3-01 Last.mp3"])
|
||||
|
||||
|
||||
func test_one_disc_folder_keeps_the_names() -> void:
|
||||
_file("CD1/01 Title.mp3")
|
||||
_file("CD1/02 Theme.mp3")
|
||||
_file("Scans/cover.jpg")
|
||||
|
||||
assert_array(_names(UploadFolder.scan(_dir))).contains_exactly(
|
||||
["01 Title.mp3", "02 Theme.mp3"])
|
||||
|
||||
|
||||
func test_folders_without_songs_are_not_discs() -> void:
|
||||
_file("Artwork/front.jpg")
|
||||
_file("Disc 1/01 First.mp3")
|
||||
_file("Disc 2/01 Second.mp3")
|
||||
|
||||
assert_array(_names(UploadFolder.scan(_dir))).contains_exactly(
|
||||
["1-01 First.mp3", "2-01 Second.mp3"])
|
||||
|
||||
|
||||
func test_other_songs_are_listed_as_left_out_and_the_rest_ignored() -> void:
|
||||
_file("01 Title.mp3")
|
||||
_file("02 Theme.flac")
|
||||
_file("Disc 2/01 Song.ogg")
|
||||
_file("cover.jpg")
|
||||
_file("notes.txt")
|
||||
|
||||
var folder: UploadFolder = UploadFolder.scan(_dir)
|
||||
|
||||
assert_array(_names(folder)).contains_exactly(["01 Title.mp3"])
|
||||
assert_array(folder.skipped).contains_exactly(["02 Theme.flac", "Disc 2/01 Song.ogg"])
|
||||
|
||||
|
||||
func test_hidden_files_and_folders_are_left_out() -> void:
|
||||
_file("01 Title.mp3")
|
||||
_file("._01 Title.mp3")
|
||||
_file(".sync/01 Copy.mp3")
|
||||
|
||||
assert_array(_names(UploadFolder.scan(_dir))).contains_exactly(["01 Title.mp3"])
|
||||
|
||||
|
||||
func test_same_name_twice_gets_a_number() -> void:
|
||||
_file("Disc 1/01 Title.mp3")
|
||||
_file("Disc 1/Extra/01 Title.mp3")
|
||||
|
||||
assert_array(_names(UploadFolder.scan(_dir))).contains_exactly(
|
||||
["01 Title.mp3", "01 Title (2).mp3"])
|
||||
|
||||
|
||||
func test_empty_folder_has_no_songs() -> void:
|
||||
_file("cover.jpg")
|
||||
|
||||
assert_bool(UploadFolder.scan(_dir).has_songs()).is_false()
|
||||
|
||||
|
||||
@warning_ignore("unused_parameter")
|
||||
func test_clean_name(name: String, cleaned: String, test_parameters: Array = [
|
||||
["Chrono Trigger", "Chrono Trigger"],
|
||||
["Re:Zero", "Re-Zero"],
|
||||
["What? <Live>", "What- -Live-"],
|
||||
["AC/DC", "AC-DC"],
|
||||
[" .hidden name. ", "hidden name"],
|
||||
["クロノ・トリガー", "クロノ・トリガー"],
|
||||
]) -> void:
|
||||
assert_str(UploadFolder.clean_name(name)).is_equal(cleaned)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlaocvvknrr73
|
||||
Reference in new issue
Block a user