diff --git a/data/upload_folder.gd b/data/upload_folder.gd new file mode 100644 index 0000000..d83d5e1 --- /dev/null +++ b/data/upload_folder.gd @@ -0,0 +1,119 @@ +class_name UploadFolder +extends RefCounted +## A folder picked or dropped to upload as a new soundtrack: its MP3s, ready to send, and +## the other song files, which are left out because the server only takes MP3. +## +## The server wants one folder of songs, so songs in subfolders, such as disc folders, +## are taken too, with the disc's number in front of their names: "Disc 1/01 Title.mp3" +## is sent as "1-01 Title.mp3". With only one disc folder, its songs keep their names. +## Hidden files and folders are left out, and names get rid of the characters the server +## refuses. + +## The only kind of song the server takes. +const UPLOAD_EXTENSION: String = "mp3" +## Other song files, listed as left out so the user knows. +const OTHER_SONG_EXTENSIONS: Array[String] = [ + "ogg", "wav", "flac", "m4a", "aac", "opus", "wma", "ape", "aif", "aiff", +] +## Characters the server refuses in soundtrack and file names. +const INVALID_NAME_CHARACTERS: String = "/\\:*?\"<>|" + +## The folder. +var path: String = "" +## The soundtrack's name to suggest: the folder's name. +var suggested_name: String = "" +## The songs to upload, in order: [code]{"path": String, "name": String, "size": int}[/code], +## with the name to send each one as. +var songs: Array[Dictionary] = [] +## The song files left out, as paths in the folder, e.g. [code]"Disc 2/03 Song.flac"[/code]. +var skipped: Array[String] = [] +## The size of the songs to upload, in bytes. +var total_size: int = 0 + + +## Reads the folder at [param folder]. +static func scan(folder: String) -> UploadFolder: + var result: UploadFolder = UploadFolder.new() + result.path = folder.trim_suffix("/").trim_suffix("\\") + result.suggested_name = clean_name(result.path.get_file()) + result._add_files(result.path, "", "") + var discs: Array[String] = [] + for disc: String in _visible(DirAccess.get_directories_at(result.path)): + if _has_songs(result.path.path_join(disc)): + discs.append(disc) + for disc: int in discs.size(): + result._add_folder(result.path.path_join(discs[disc]), discs[disc], + "%d-" % (disc + 1) if discs.size() > 1 else "") + return result + + +## [param name] without the characters the server refuses, a leading dot, or a trailing +## dot or space. +static func clean_name(name: String) -> String: + var cleaned: String = "" + for character: String in name: + var control: bool = character.unicode_at(0) < 32 || character.unicode_at(0) == 127 + cleaned += "-" if INVALID_NAME_CHARACTERS.contains(character) || control \ + else character + cleaned = cleaned.strip_edges().lstrip(".") + while cleaned.ends_with(".") || cleaned.ends_with(" "): + cleaned = cleaned.left(-1) + return cleaned + + +## Whether there is anything to upload. +func has_songs() -> bool: + return !songs.is_empty() + + +# Adds the files of [param folder] and of the folders in it, all with [param prefix] +func _add_folder(folder: String, relative: String, prefix: String) -> void: + _add_files(folder, relative, prefix) + for inner: String in _visible(DirAccess.get_directories_at(folder)): + _add_folder(folder.path_join(inner), relative.path_join(inner), prefix) + + +func _add_files(folder: String, relative: String, prefix: String) -> void: + for file_name: String in _visible(DirAccess.get_files_at(folder)): + var extension: String = file_name.get_extension().to_lower() + if extension == UPLOAD_EXTENSION: + var file_path: String = folder.path_join(file_name) + var size: int = FileAccess.get_size(file_path) + songs.append({"path": file_path, "name": _free_name(prefix + clean_name(file_name)), + "size": size}) + total_size += size + elif extension in OTHER_SONG_EXTENSIONS: + skipped.append(relative.path_join(file_name) if !relative.is_empty() else file_name) + + +# [param name], or with " (2)" and so on before its extension when a song already has it +func _free_name(name: String) -> String: + var taken: Array = songs.map(func(song: Dictionary) -> String: return song.name.to_lower()) + var candidate: String = name + var number: int = 2 + while taken.has(candidate.to_lower()): + candidate = "%s (%d).%s" % [name.get_basename(), number, name.get_extension()] + number += 1 + return candidate + + +# Whether [param folder] or a folder in it has a song, to upload or to list as left out +static func _has_songs(folder: String) -> bool: + for file_name: String in _visible(DirAccess.get_files_at(folder)): + var extension: String = file_name.get_extension().to_lower() + if extension == UPLOAD_EXTENSION || extension in OTHER_SONG_EXTENSIONS: + return true + for inner: String in _visible(DirAccess.get_directories_at(folder)): + if _has_songs(folder.path_join(inner)): + return true + return false + + +# The names that are not hidden, in natural order, so "2" comes before "10" +static func _visible(names: PackedStringArray) -> Array[String]: + var shown: Array[String] = [] + for name: String in names: + if !name.begins_with("."): + shown.append(name) + shown.sort_custom(func(a: String, b: String) -> bool: return a.naturalnocasecmp_to(b) < 0) + return shown diff --git a/data/upload_folder.gd.uid b/data/upload_folder.gd.uid new file mode 100644 index 0000000..c379af1 --- /dev/null +++ b/data/upload_folder.gd.uid @@ -0,0 +1 @@ +uid://3amk1cte2idb diff --git a/main/MainWindow.tscn b/main/MainWindow.tscn index b802a33..e78e49b 100644 --- a/main/MainWindow.tscn +++ b/main/MainWindow.tscn @@ -13,6 +13,7 @@ [ext_resource type="PackedScene" path="res://popups/SoundtrackStatsPopup.tscn" id="soundtrack_stats_popup_scene"] [ext_resource type="PackedScene" uid="uid://dldpeo5y3l5hq" path="res://popups/SettingsPopup.tscn" id="settings_popup_scene"] [ext_resource type="PackedScene" path="res://popups/UpdatePopup.tscn" id="update_popup_scene"] +[ext_resource type="PackedScene" path="res://popups/UploadPopup.tscn" id="upload_popup_scene"] [node name="MainWindow" type="Control" unique_id=407149696] layout_mode = 3 @@ -119,3 +120,12 @@ offset_left = 640.0 offset_top = 390.0 offset_right = 640.0 offset_bottom = 390.0 + +[node name="UploadPopup" parent="." instance=ExtResource("upload_popup_scene")] +unique_name_in_owner = true +visible = false +layout_mode = 1 +offset_left = 580.0 +offset_top = 200.0 +offset_right = 580.0 +offset_bottom = 200.0 diff --git a/main/main_window.gd b/main/main_window.gd index 229a9f0..4de4fbb 100644 --- a/main/main_window.gd +++ b/main/main_window.gd @@ -21,6 +21,7 @@ var popup_backdrop: PopupBackdrop @onready var log_popup: LogPopup = %LogPopup @onready var settings_popup: SettingsPopup = %SettingsPopup @onready var update_popup: UpdatePopup = %UpdatePopup +@onready var upload_popup: UploadPopup = %UploadPopup @onready var statistics_view: StatisticsView = $StatisticsView @onready var soundtrack_stats_popup: SoundtrackStatsPopup = $SoundtrackStatsPopup @@ -40,6 +41,9 @@ func _ready() -> void: top_bar.settings_pressed.connect(settings_popup.open) top_bar.update_pressed.connect(update_popup.show_update) update_popup.restarting.connect(quiz.save_now) + statistics_view.add_soundtrack_pressed.connect(upload_popup.choose_folder) + upload_popup.soundtrack_added.connect(_on_soundtrack_added) + get_window().files_dropped.connect(on_files_dropped) settings_popup.server_changed.connect(quiz.on_server_changed) settings_popup.characters_changed.connect(quiz.character_select_popup.load_characters) settings_popup.clear_cache_requested.connect(clear_cache) @@ -78,6 +82,29 @@ func clear_cache() -> void: quiz.character_select_popup.load_characters() +## Offers to upload a folder dropped on the window as a new soundtrack. Dropped songs +## stand for the folder they are in. +func on_files_dropped(paths: PackedStringArray) -> void: + if paths.is_empty(): + return + var folder: String = paths[0] + for path: String in paths: + if DirAccess.dir_exists_absolute(path): + folder = path + break + if !DirAccess.dir_exists_absolute(folder): + folder = folder.get_base_dir() + upload_popup.show_folder(folder) + + +# A new soundtrack is found by search, listed for inspiration and in the statistics +func _on_soundtrack_added(_soundtrack: SoundtrackWithSongList) -> void: + search_popup.get_list_of_soundtracks() + quiz.inspiration_list.load_soundtracks() + if statistics_view.visible: + statistics_view.refresh() + + # The quiz finds a soundtrack's cover by the cover times in the soundtrack list func _remember_cover_times(soundtracks: Array[Soundtrack]) -> void: var times: Dictionary = {} @@ -116,7 +143,7 @@ func _input(event: InputEvent) -> void: func _popups() -> Array[BasePopup]: return [search_popup, sync_popup, qr_popup, shortcuts_popup, about_popup, log_popup, - soundtrack_stats_popup, settings_popup, update_popup] + soundtrack_stats_popup, settings_popup, update_popup, upload_popup] # How many songs to download ahead can be changed in the settings. Waiting until the diff --git a/popups/UploadPopup.tscn b/popups/UploadPopup.tscn new file mode 100644 index 0000000..6918b14 --- /dev/null +++ b/popups/UploadPopup.tscn @@ -0,0 +1,122 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://popups/upload_popup.gd" id="popup_script"] + +[node name="UploadPopup" type="Control" node_paths=PackedStringArray("panel")] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("popup_script") +panel = NodePath("UploadPanel") + +[node name="UploadPanel" type="Panel" parent="."] +unique_name_in_owner = true +layout_mode = 0 +offset_right = 760.0 +offset_bottom = 680.0 +theme_type_variation = &"PopupBackground" + +[node name="Rows" type="VBoxContainer" parent="UploadPanel"] +layout_mode = 0 +offset_left = 24.0 +offset_top = 16.0 +offset_right = 736.0 +offset_bottom = 664.0 +theme_override_constants/separation = 10 + +[node name="TitleLabel" type="Label" parent="UploadPanel/Rows"] +layout_mode = 2 +theme_type_variation = &"Title" +text = "Add a soundtrack" +horizontal_alignment = 1 + +[node name="HelpLabel" type="Label" parent="UploadPanel/Rows"] +layout_mode = 2 +autowrap_mode = 3 +text = "The songs are uploaded to the server, which adds them as a new soundtrack right away. Songs in disc folders are added too, with the disc's number first." + +[node name="NameRow" type="HBoxContainer" parent="UploadPanel/Rows"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="NameTitle" type="Label" parent="UploadPanel/Rows/NameRow"] +layout_mode = 2 +theme_type_variation = &"SettingsRowLabel" +text = "Name" + +[node name="NameField" type="LineEdit" parent="UploadPanel/Rows/NameRow"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Soundtrack name" +max_length = 200 + +[node name="ChooseFolderButton" type="Button" parent="UploadPanel/Rows/NameRow"] +unique_name_in_owner = true +layout_mode = 2 +focus_mode = 0 +text = "Other folder…" + +[node name="FolderLabel" type="Label" parent="UploadPanel/Rows"] +unique_name_in_owner = true +layout_mode = 2 +theme_type_variation = &"TextMedium" +text_overrun_behavior = 3 + +[node name="SongScroll" type="ScrollContainer" parent="UploadPanel/Rows"] +layout_mode = 2 +size_flags_vertical = 3 +horizontal_scroll_mode = 0 + +[node name="SongList" type="VBoxContainer" parent="UploadPanel/Rows/SongScroll"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="SummaryLabel" type="Label" parent="UploadPanel/Rows"] +unique_name_in_owner = true +layout_mode = 2 +autowrap_mode = 3 + +[node name="UploadProgress" type="ProgressBar" parent="UploadPanel/Rows"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +show_percentage = false + +[node name="StatusLabel" type="Label" parent="UploadPanel/Rows"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +theme_type_variation = &"HighlightMedium" +autowrap_mode = 3 + +[node name="Buttons" type="HBoxContainer" parent="UploadPanel/Rows"] +layout_mode = 2 +theme_override_constants/separation = 12 +alignment = 2 + +[node name="CancelButton" type="Button" parent="UploadPanel/Rows/Buttons"] +unique_name_in_owner = true +layout_mode = 2 +focus_mode = 0 +text = "Cancel" + +[node name="UploadButton" type="Button" parent="UploadPanel/Rows/Buttons"] +unique_name_in_owner = true +layout_mode = 2 +focus_mode = 0 +text = "Upload" + +[node name="FolderDialog" type="FileDialog" parent="."] +unique_name_in_owner = true +title = "Choose a soundtrack folder" +initial_position = 2 +size = Vector2i(800, 500) +ok_button_text = "Select Current Folder" +file_mode = 2 +access = 2 +use_native_dialog = true diff --git a/popups/upload_popup.gd b/popups/upload_popup.gd new file mode 100644 index 0000000..06172a8 --- /dev/null +++ b/popups/upload_popup.gd @@ -0,0 +1,220 @@ +class_name UploadPopup +extends BasePopup +## Adds a new soundtrack to the server from a folder: picked with Add soundtrack… in the +## statistics, or dropped on the window. +## +## Lists the folder's songs (see [UploadFolder]), lets the name be changed, and uploads +## the songs one at a time with a progress bar. A song that fails is sent again a couple +## of times before the upload is cancelled. The server then adds the soundtrack at once, +## without a sync. + +## Emitted when the server has added the soundtrack. +signal soundtrack_added(soundtrack: SoundtrackWithSongList) + +## How many times a song that failed is sent again before the upload is given up. +const RETRIES: int = 2 + +## The folder shown. +var folder: UploadFolder + +# The upload on the server, while there is one +var _upload_id: String = "" +# The song being sent, and how often it failed +var _next: int = 0 +var _failures: int = 0 +var _uploading: bool = false + +@onready var name_field: LineEdit = %NameField +@onready var choose_folder_button: Button = %ChooseFolderButton +@onready var folder_label: Label = %FolderLabel +@onready var song_list: VBoxContainer = %SongList +@onready var summary_label: Label = %SummaryLabel +@onready var upload_progress: ProgressBar = %UploadProgress +@onready var status_label: Label = %StatusLabel +@onready var cancel_button: Button = %CancelButton +@onready var upload_button: Button = %UploadButton +@onready var folder_dialog: FileDialog = %FolderDialog + + +func _ready() -> void: + choose_folder_button.pressed.connect(folder_dialog.popup_centered) + folder_dialog.dir_selected.connect(show_folder) + cancel_button.pressed.connect(close) + upload_button.pressed.connect(upload) + name_field.text_changed.connect(func(_text: String) -> void: _update_buttons()) + name_field.text_submitted.connect(func(_text: String) -> void: upload()) + + +## Asks for a folder, then shows it. +func choose_folder() -> void: + folder_dialog.popup_centered() + + +## Lists the songs in the folder at [param path] and opens the popup. +func show_folder(path: String) -> void: + folder = UploadFolder.scan(path) + name_field.text = folder.suggested_name + folder_label.text = "From " + short_path(folder.path) + folder_label.tooltip_text = folder.path + _fill_song_list() + summary_label.text = summary_text(folder) + upload_progress.visible = false + status_label.visible = false + upload_button.visible = true + cancel_button.text = "Cancel" + if Settings.is_offline(): + _show_status("Adding a soundtrack needs the server, not a music folder.") + elif !folder.has_songs(): + _show_status("There are no MP3 files in %s." % folder.path.get_file()) + _set_uploading(false) + open() + + +## Starts uploading the songs as a soundtrack with the name in the name field. +func upload() -> void: + if upload_button.disabled || !upload_button.visible: + return + if Settings.currently_syncing: + _show_status("The server is syncing. Try again when it is done.") + return + _set_uploading(true) + upload_progress.max_value = folder.songs.size() + upload_progress.value = 0 + upload_progress.visible = true + _show_status("Starting the upload…") + MusicServerClient.start_upload(UploadFolder.clean_name(name_field.text), _on_started, + func() -> void: _stop("Could not start the upload")) + + +## Stops an upload under way, and closes the popup. +func close() -> void: + if _uploading: + _uploading = false + _cancel_on_server() + super() + + +## The last two parts of [param path], e.g. "…/Soundtracks/Chrono Trigger", since the +## start of a long path says the least. +static func short_path(path: String) -> String: + var parts: PackedStringArray = path.replace("\\", "/").split("/", false) + if parts.size() <= 2: + return path + return "…/" + "/".join(parts.slice(-2)) + + +## "30 songs, 245.1 MB", and how many files are left out. +static func summary_text(shown: UploadFolder) -> String: + var text: String = "%d %s, %s" % [shown.songs.size(), + "song" if shown.songs.size() == 1 else "songs", + SettingsPopup.size_text(shown.total_size)] + if !shown.skipped.is_empty(): + text += ". %d %s left out: only MP3 files can be uploaded." % [shown.skipped.size(), + "file" if shown.skipped.size() == 1 else "files"] + return text + + +func _on_started(upload_info: Upload) -> void: + if !_uploading: + # Closed while the server was answering + MusicServerClient.cancel_upload(upload_info.id) + return + _upload_id = upload_info.id + _next = 0 + _failures = 0 + _send_next() + + +func _send_next() -> void: + if !_uploading: + return + if _next >= folder.songs.size(): + _show_status("Adding the soundtrack…") + MusicServerClient.finish_upload(_upload_id, _on_finished, + func() -> void: _stop("Could not add the soundtrack")) + return + var song: Dictionary = folder.songs[_next] + _show_status("Uploading %d of %d: %s" % [_next + 1, folder.songs.size(), song.name]) + # One song at a time, so only one is held in memory + var bytes: PackedByteArray = FileAccess.get_file_as_bytes(song.path) + if bytes.is_empty(): + MusicServerClient.last_error = "the file could not be read" + _stop("Could not upload " + song.name) + return + MusicServerClient.upload_song(_upload_id, song.name, bytes, _on_song_sent, _on_song_failed) + + +func _on_song_sent() -> void: + _next += 1 + _failures = 0 + upload_progress.value = _next + _send_next() + + +func _on_song_failed() -> void: + _failures += 1 + if _failures <= RETRIES && _uploading: + _send_next() + return + _stop("Could not upload " + folder.songs[_next].name) + + +func _on_finished(soundtrack: SoundtrackWithSongList) -> void: + _upload_id = "" + _uploading = false + upload_progress.value = upload_progress.max_value + _show_status("Added %s with %d %s." % [soundtrack.name, soundtrack.songs.size(), + "song" if soundtrack.songs.size() == 1 else "songs"]) + upload_button.visible = false + cancel_button.text = "Close" + _set_uploading(false) + soundtrack_added.emit(soundtrack) + + +# Gives up, says why with the server's reason, and removes what was uploaded +func _stop(what: String) -> void: + var reason: String = MusicServerClient.last_error + _cancel_on_server() + _uploading = false + upload_progress.visible = false + _set_uploading(false) + _show_status("%s: %s." % [what, + reason if !reason.is_empty() else "the server could not be reached"]) + + +func _cancel_on_server() -> void: + if !_upload_id.is_empty(): + MusicServerClient.cancel_upload(_upload_id) + _upload_id = "" + + +func _set_uploading(uploading: bool) -> void: + _uploading = uploading + name_field.editable = !uploading + choose_folder_button.disabled = uploading + _update_buttons() + + +func _update_buttons() -> void: + upload_button.disabled = _uploading || folder == null || !folder.has_songs() \ + || UploadFolder.clean_name(name_field.text).is_empty() || Settings.is_offline() + + +func _fill_song_list() -> void: + Settings.delete_children(song_list) + var row: int = 0 + for song: Dictionary in folder.songs: + song_list.add_child(ListRow.create("%s · %s" % [song.name, + SettingsPopup.size_text(song.size)], row, &"TextMedium")) + row += 1 + for skipped: String in folder.skipped: + var list_row: ListRow = ListRow.create("%s · not MP3, left out" % skipped, row, + &"TextMedium") + list_row.label.self_modulate.a = 0.5 + song_list.add_child(list_row) + row += 1 + + +func _show_status(message: String) -> void: + status_label.text = message + status_label.visible = true diff --git a/popups/upload_popup.gd.uid b/popups/upload_popup.gd.uid new file mode 100644 index 0000000..a92335c --- /dev/null +++ b/popups/upload_popup.gd.uid @@ -0,0 +1 @@ +uid://utn0m7614g61 diff --git a/server/generated/upload.gd b/server/generated/upload.gd new file mode 100644 index 0000000..4f73cb1 --- /dev/null +++ b/server/generated/upload.gd @@ -0,0 +1,38 @@ +# Generated by tools/generate_api_types.gd from server/openapi.json. +# Do not edit; change the server and generate again. +class_name Upload +extends RefCounted +## The [code]Upload[/code] schema of the MusicServer API. + +## The songs received so far, sorted by name +var files: Array[UploadFile] +var id: String +## Name of the new soundtrack, also its folder name +var name: String +var started: String +## Size of all songs received so far, in bytes +var total_size: int + + +## Reads the fields from parsed JSON. Missing fields keep their defaults. +static func from_dict(data: Dictionary) -> Upload: + var result: Upload = Upload.new() + for item: Variant in ApiJson.get_array(data, "files"): + if item is Dictionary: + result.files.append(UploadFile.from_dict(item)) + result.id = ApiJson.get_string(data, "id", "") + result.name = ApiJson.get_string(data, "name", "") + result.started = ApiJson.get_string(data, "started", "") + result.total_size = ApiJson.get_int(data, "total_size", 0) + return result + + +## Returns the fields under their JSON names, ready for [method JSON.stringify]. +func to_dict() -> Dictionary: + return { + "files": files.map(func(item: UploadFile) -> Dictionary: return item.to_dict()), + "id": id, + "name": name, + "started": started, + "total_size": total_size, + } diff --git a/server/generated/upload.gd.uid b/server/generated/upload.gd.uid new file mode 100644 index 0000000..2e0f36e --- /dev/null +++ b/server/generated/upload.gd.uid @@ -0,0 +1 @@ +uid://diemnuinawufa diff --git a/server/generated/upload_file.gd b/server/generated/upload_file.gd new file mode 100644 index 0000000..5ea75f6 --- /dev/null +++ b/server/generated/upload_file.gd @@ -0,0 +1,24 @@ +# Generated by tools/generate_api_types.gd from server/openapi.json. +# Do not edit; change the server and generate again. +class_name UploadFile +extends RefCounted +## The [code]UploadFile[/code] schema of the MusicServer API. + +var name: String +var size: int + + +## Reads the fields from parsed JSON. Missing fields keep their defaults. +static func from_dict(data: Dictionary) -> UploadFile: + var result: UploadFile = UploadFile.new() + result.name = ApiJson.get_string(data, "name", "") + result.size = ApiJson.get_int(data, "size", 0) + return result + + +## Returns the fields under their JSON names, ready for [method JSON.stringify]. +func to_dict() -> Dictionary: + return { + "name": name, + "size": size, + } diff --git a/server/generated/upload_file.gd.uid b/server/generated/upload_file.gd.uid new file mode 100644 index 0000000..ff77b71 --- /dev/null +++ b/server/generated/upload_file.gd.uid @@ -0,0 +1 @@ +uid://c5a5ig8cy44hy diff --git a/server/generated/upload_request.gd b/server/generated/upload_request.gd new file mode 100644 index 0000000..df51102 --- /dev/null +++ b/server/generated/upload_request.gd @@ -0,0 +1,22 @@ +# Generated by tools/generate_api_types.gd from server/openapi.json. +# Do not edit; change the server and generate again. +class_name UploadRequest +extends RefCounted +## The [code]UploadRequest[/code] schema of the MusicServer API. + +## Name of the new soundtrack, also its folder name in the music folder +var name: String + + +## Reads the fields from parsed JSON. Missing fields keep their defaults. +static func from_dict(data: Dictionary) -> UploadRequest: + var result: UploadRequest = UploadRequest.new() + 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 { + "name": name, + } diff --git a/server/generated/upload_request.gd.uid b/server/generated/upload_request.gd.uid new file mode 100644 index 0000000..a2eedcc --- /dev/null +++ b/server/generated/upload_request.gd.uid @@ -0,0 +1 @@ +uid://bmqrxhvdnss3j diff --git a/server/music_server_client.gd b/server/music_server_client.gd index 229dfa4..3019354 100644 --- a/server/music_server_client.gd +++ b/server/music_server_client.gd @@ -30,6 +30,8 @@ const CLIENT_TYPE: String = "godot" const QUEUE_HEADER: String = "X-Queue-Id" ## The type of the JSON bodies sent. const JSON_TYPE: String = "application/json" +## The type of uploaded songs. +const MP3_TYPE: String = "audio/mpeg" ## The files of a MusicPlayer release that can be downloaded: the Windows game, the Linux ## game, and the Linux package with an installer. const DOWNLOAD_ASSETS: Array[String] = ["windows", "linux", "linux-package"] @@ -179,6 +181,43 @@ func delete_soundtrack_cover( on_failed) +## Starts uploading a new soundtrack called [param name], which is also its folder name on +## the server, and calls [param on_done] with the [Upload]. Refused when the name is taken +## or not a valid folder name, or while the server syncs; see [member last_error]. +func start_upload(name: String, on_done: Callable, on_failed: Callable = Callable()) -> void: + var request: UploadRequest = UploadRequest.new() + request.name = name + var path: String = "/soundtracks/uploads" + _request(path, HTTPClient.METHOD_POST, + _parse_json.bind(path, _as_object.bind(Upload, on_done, on_failed), on_failed), + on_failed, true, JSON.stringify(request.to_dict()).to_utf8_buffer()) + + +## Sends the MP3 [param song] as [param file_name] in the upload [param upload_id]. A song +## of the same name is replaced, so a failed song can be sent again. +func upload_song(upload_id: String, file_name: String, song: PackedByteArray, + on_done: Callable, on_failed: Callable = Callable()) -> void: + _request(_upload_path(upload_id) + "/files/" + file_name.uri_encode(), + HTTPClient.METHOD_PUT, _call_ignoring_body.bind(on_done), on_failed, true, song, + MP3_TYPE) + + +## Adds the uploaded songs to the server's library as a new soundtrack and calls +## [param on_done] with it as a [SoundtrackWithSongList]. +func finish_upload(upload_id: String, on_done: Callable, + on_failed: Callable = Callable()) -> void: + var path: String = _upload_path(upload_id) + "/finish" + _request(path, HTTPClient.METHOD_POST, _parse_json.bind(path, + _as_object.bind(SoundtrackWithSongList, on_done, on_failed), on_failed), on_failed) + + +## Deletes the unfinished upload [param upload_id] and its songs. +func cancel_upload(upload_id: String, on_done: Callable = Callable(), + on_failed: Callable = Callable()) -> void: + _request(_upload_path(upload_id), HTTPClient.METHOD_DELETE, + _call_ignoring_body.bind(on_done), on_failed) + + ## Replaces the alternative names of the soundtrack with [param uuid] with ## [param names]. The server trims them, drops duplicates and the soundtrack's own name, ## and calls [param on_done] with the updated [Soundtrack]. @@ -637,6 +676,20 @@ static func header_value(headers: PackedStringArray, header_name: String) -> Str return "" +static func _upload_path(upload_id: String) -> String: + return "/soundtracks/uploads/" + upload_id.uri_encode() + + +# Calls [param on_done] with [param data] read as [param type], or [param on_failed] when +# it is not a JSON object +static func _as_object(data: Variant, type: GDScript, on_done: Callable, + on_failed: Callable) -> void: + if !(data is Dictionary): + _call_if_valid(on_failed) + return + on_done.call(type.from_dict(data)) + + static func _cover_path(uuid: String) -> String: return "/soundtracks/%s/cover" % uuid.uri_encode() diff --git a/server/openapi.json b/server/openapi.json index e6ad95e..52d289a 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -500,6 +500,74 @@ ], "type": "object" }, + "Upload": { + "additionalProperties": false, + "properties": { + "files": { + "description": "The songs received so far, sorted by name", + "items": { + "$ref": "#/components/schemas/UploadFile" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "name": { + "description": "Name of the new soundtrack, also its folder name", + "type": "string" + }, + "started": { + "format": "date-time", + "type": "string" + }, + "total_size": { + "description": "Size of all songs received so far, in bytes", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "id", + "name", + "started", + "files", + "total_size" + ], + "type": "object" + }, + "UploadFile": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "size": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "name", + "size" + ], + "type": "object" + }, + "UploadRequest": { + "additionalProperties": false, + "properties": { + "name": { + "description": "Name of the new soundtrack, also its folder name in the music folder", + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "VersionData": { "additionalProperties": false, "properties": { @@ -2733,6 +2801,526 @@ ] } }, + "/api/v1/soundtracks/uploads": { + "post": { + "description": "Starts adding a new soundtrack. Send its songs with PUT /soundtracks/uploads/{id}/files/{file}, then POST /soundtracks/uploads/{id}/finish. The name must be usable as a folder name, and no soundtrack, folder or other upload may have it, ignoring case. Unfinished uploads are removed after a day.", + "operationId": "start-upload", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Upload" + } + } + }, + "description": "Created" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Service Unavailable" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Start uploading a soundtrack", + "tags": [ + "uploads" + ] + } + }, + "/api/v1/soundtracks/uploads/{id}": { + "delete": { + "description": "Deletes an unfinished upload and its songs.", + "operationId": "cancel-upload", + "parameters": [ + { + "description": "ID of the upload, from POST /soundtracks/uploads", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "ID of the upload, from POST /soundtracks/uploads", + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Cancel an upload", + "tags": [ + "uploads" + ] + }, + "get": { + "description": "Returns an unfinished upload with the songs received so far, for example to resume it.", + "operationId": "get-upload", + "parameters": [ + { + "description": "ID of the upload, from POST /soundtracks/uploads", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "ID of the upload, from POST /soundtracks/uploads", + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Upload" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Get an upload", + "tags": [ + "uploads" + ] + } + }, + "/api/v1/soundtracks/uploads/{id}/files/{file}": { + "put": { + "description": "Stores one song of the upload, replacing a song of the same name, so a failed song can be sent again. Only MP3 files are accepted. The size limits are set on the server with UPLOAD_MAX_FILE_MB and UPLOAD_MAX_TOTAL_MB.", + "operationId": "upload-song", + "parameters": [ + { + "description": "ID of the upload, from POST /soundtracks/uploads", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "ID of the upload, from POST /soundtracks/uploads", + "format": "uuid", + "type": "string" + } + }, + { + "description": "File name of the song, e.g. \"01 Title.mp3\". Plain names only: put songs from disc folders in one folder, e.g. \"1-01 Title.mp3\".", + "in": "path", + "name": "file", + "required": true, + "schema": { + "description": "File name of the song, e.g. \"01 Title.mp3\". Plain names only: put songs from disc folders in one folder, e.g. \"1-01 Title.mp3\".", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "audio/mpeg": { + "schema": { + "contentMediaType": "application/octet-stream", + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadFile" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Request Entity Too Large" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Upload a song", + "tags": [ + "uploads" + ] + } + }, + "/api/v1/soundtracks/uploads/{id}/finish": { + "post": { + "description": "Moves the songs into a new folder in the music folder, adds the soundtrack to the library and returns it. Refused when a soundtrack with the same songs exists.", + "operationId": "finish-upload", + "parameters": [ + { + "description": "ID of the upload, from POST /soundtracks/uploads", + "in": "path", + "name": "id", + "required": true, + "schema": { + "description": "ID of the upload, from POST /soundtracks/uploads", + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SoundtrackWithSongList" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unauthorized" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Unprocessable Entity" + }, + "423": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Locked" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/APIError" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Finish an upload", + "tags": [ + "uploads" + ] + } + }, "/api/v1/soundtracks/{uuid}": { "get": { "description": "Returns a soundtrack with its songs, sorted by name.", diff --git a/statistics/StatisticsView.tscn b/statistics/StatisticsView.tscn index 6fa67e3..5e52c2e 100644 --- a/statistics/StatisticsView.tscn +++ b/statistics/StatisticsView.tscn @@ -107,6 +107,17 @@ popup/item_2/id = 2 popup/item_3/text = "Least played" popup/item_3/id = 3 +[node name="AddSeparator" type="VSeparator" parent="Toolbar"] +layout_mode = 2 + +[node name="AddSoundtrackButton" type="Button" parent="Toolbar"] +unique_name_in_owner = true +layout_mode = 2 +tooltip_text = "Upload a folder of songs to the server as a new soundtrack. +You can also drop a folder anywhere on the window." +focus_mode = 0 +text = "Add soundtrack…" + [node name="ScrollContainer" type="ScrollContainer" parent="." unique_id=1212000010] layout_mode = 1 anchors_preset = 15 diff --git a/statistics/statistics_view.gd b/statistics/statistics_view.gd index 9a4f256..e0db360 100644 --- a/statistics/statistics_view.gd +++ b/statistics/statistics_view.gd @@ -11,6 +11,8 @@ extends Panel ## Emitted when a card is clicked, to show all songs of [param soundtrack]. signal soundtrack_selected(soundtrack: SoundtrackWithSongs) +## Emitted when Add soundtrack… is pressed. +signal add_soundtrack_pressed ## The orders the cards can be sorted in, in the order of the sort menu. enum SortOrder { A_TO_Z, Z_TO_A, MOST_PLAYED, LEAST_PLAYED } @@ -57,6 +59,7 @@ var _songs_ready: bool = false @onready var soundtracks_mode_button: Button = %SoundtracksModeButton @onready var songs_mode_button: Button = %SongsModeButton @onready var search_delay: Timer = %SearchDelay +@onready var add_soundtrack_button: Button = %AddSoundtrackButton func _ready() -> void: @@ -69,6 +72,7 @@ func _ready() -> void: show_option.item_selected.connect(func(_index: int) -> void: apply_filter()) sort_option.item_selected.connect(func(_index: int) -> void: apply_sort()) retry_button.pressed.connect(refresh) + add_soundtrack_button.pressed.connect(add_soundtrack_pressed.emit) retry_button.visible = false # The cards have moved by the end of the frame card_scroll.get_v_scroll_bar().value_changed.connect( diff --git a/tests/data/layout_baseline.json b/tests/data/layout_baseline.json index 9c96c4c..92330b1 100644 --- a/tests/data/layout_baseline.json +++ b/tests/data/layout_baseline.json @@ -3789,6 +3789,24 @@ ], "visible": false }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 32.0, + 72.0, + 4.0, + 3.0 + ], + "visible": false + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 32.0, + 72.0, + 160.0, + 31.0 + ], + "visible": false + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 32.0, @@ -4409,6 +4427,177 @@ 35.0 ], "visible": false + }, + "UploadPopup": { + "rect": [ + 580.0, + 200.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UploadPopup/FolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel": { + "rect": [ + 580.0, + 200.0, + 760.0, + 680.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows": { + "rect": [ + 604.0, + 216.0, + 712.0, + 3453.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons": { + "rect": [ + 604.0, + 216.0, + 168.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/CancelButton": { + "rect": [ + 604.0, + 216.0, + 76.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/UploadButton": { + "rect": [ + 604.0, + 216.0, + 80.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/FolderLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/HelpLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 3247.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow": { + "rect": [ + 604.0, + 216.0, + 281.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/ChooseFolderButton": { + "rect": [ + 604.0, + 216.0, + 133.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameField": { + "rect": [ + 604.0, + 216.0, + 68.5, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameTitle": { + "rect": [ + 604.0, + 216.0, + 59.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/StatusLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SummaryLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/TitleLabel": { + "rect": [ + 604.0, + 216.0, + 212.0, + 35.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/UploadProgress": { + "rect": [ + 604.0, + 216.0, + 4.0, + 4.0 + ], + "visible": false } }, "offline": { @@ -11639,6 +11828,24 @@ ], "visible": false }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 1712.0, + 72.0, + 4.0, + 40.0 + ], + "visible": false + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 1728.0, + 72.0, + 160.0, + 40.0 + ], + "visible": false + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 805.0, @@ -11652,7 +11859,7 @@ "rect": [ 882.0, 80.0, - 564.0, + 376.0, 23.0 ], "visible": false @@ -11677,7 +11884,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -11686,7 +11893,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -11704,7 +11911,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -11713,7 +11920,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -12349,6 +12556,339 @@ 35.0 ], "visible": false + }, + "UploadPopup": { + "rect": [ + 580.0, + 200.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UploadPopup/FolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel": { + "rect": [ + 580.0, + 200.0, + 760.0, + 680.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows": { + "rect": [ + 604.0, + 216.0, + 712.0, + 648.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons": { + "rect": [ + 604.0, + 833.0, + 712.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/CancelButton": { + "rect": [ + 1148.0, + 833.0, + 76.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/UploadButton": { + "rect": [ + 1236.0, + 833.0, + 80.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/FolderLabel": { + "rect": [ + 604.0, + 361.0, + 712.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/HelpLabel": { + "rect": [ + 604.0, + 261.0, + 712.0, + 49.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow": { + "rect": [ + 604.0, + 320.0, + 712.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/ChooseFolderButton": { + "rect": [ + 1183.0, + 320.0, + 133.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameField": { + "rect": [ + 673.0, + 320.0, + 500.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameTitle": { + "rect": [ + 604.0, + 320.0, + 59.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll": { + "rect": [ + 604.0, + 397.0, + 712.0, + 343.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList": { + "rect": [ + 604.0, + 397.0, + 712.0, + 302.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#0": { + "rect": [ + 604.0, + 397.0, + 712.0, + 30.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#0/#0": { + "rect": [ + 612.0, + 399.0, + 696.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#1": { + "rect": [ + 604.0, + 431.0, + 712.0, + 30.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#1/#0": { + "rect": [ + 612.0, + 433.0, + 696.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#2": { + "rect": [ + 604.0, + 465.0, + 712.0, + 30.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#2/#0": { + "rect": [ + 612.0, + 467.0, + 696.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#3": { + "rect": [ + 604.0, + 499.0, + 712.0, + 30.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#3/#0": { + "rect": [ + 612.0, + 501.0, + 696.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#4": { + "rect": [ + 604.0, + 533.0, + 712.0, + 30.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#4/#0": { + "rect": [ + 612.0, + 535.0, + 696.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#5": { + "rect": [ + 604.0, + 567.0, + 712.0, + 30.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#5/#0": { + "rect": [ + 612.0, + 569.0, + 696.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#6": { + "rect": [ + 604.0, + 601.0, + 712.0, + 30.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#6/#0": { + "rect": [ + 612.0, + 603.0, + 696.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#7": { + "rect": [ + 604.0, + 635.0, + 712.0, + 30.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#7/#0": { + "rect": [ + 612.0, + 637.0, + 696.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#8": { + "rect": [ + 604.0, + 669.0, + 712.0, + 30.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#8/#0": { + "rect": [ + 612.0, + 671.0, + 696.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/StatusLabel": { + "rect": [ + 604.0, + 797.0, + 712.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SummaryLabel": { + "rect": [ + 604.0, + 750.0, + 712.0, + 23.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/TitleLabel": { + "rect": [ + 604.0, + 216.0, + 712.0, + 35.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/UploadProgress": { + "rect": [ + 604.0, + 783.0, + 712.0, + 4.0 + ], + "visible": false } }, "players_added": { @@ -16321,6 +16861,24 @@ ], "visible": false }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 32.0, + 72.0, + 4.0, + 3.0 + ], + "visible": false + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 32.0, + 72.0, + 160.0, + 31.0 + ], + "visible": false + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 32.0, @@ -16941,6 +17499,177 @@ 35.0 ], "visible": false + }, + "UploadPopup": { + "rect": [ + 580.0, + 200.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UploadPopup/FolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel": { + "rect": [ + 580.0, + 200.0, + 760.0, + 680.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows": { + "rect": [ + 604.0, + 216.0, + 712.0, + 3453.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons": { + "rect": [ + 604.0, + 216.0, + 168.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/CancelButton": { + "rect": [ + 604.0, + 216.0, + 76.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/UploadButton": { + "rect": [ + 604.0, + 216.0, + 80.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/FolderLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/HelpLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 3247.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow": { + "rect": [ + 604.0, + 216.0, + 281.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/ChooseFolderButton": { + "rect": [ + 604.0, + 216.0, + 133.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameField": { + "rect": [ + 604.0, + 216.0, + 68.5, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameTitle": { + "rect": [ + 604.0, + 216.0, + 59.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/StatusLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SummaryLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/TitleLabel": { + "rect": [ + 604.0, + 216.0, + 212.0, + 35.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/UploadProgress": { + "rect": [ + 604.0, + 216.0, + 4.0, + 4.0 + ], + "visible": false } }, "playing": { @@ -21030,6 +21759,24 @@ ], "visible": false }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 32.0, + 72.0, + 4.0, + 3.0 + ], + "visible": false + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 32.0, + 72.0, + 160.0, + 31.0 + ], + "visible": false + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 32.0, @@ -21650,6 +22397,177 @@ 35.0 ], "visible": false + }, + "UploadPopup": { + "rect": [ + 580.0, + 200.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UploadPopup/FolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel": { + "rect": [ + 580.0, + 200.0, + 760.0, + 680.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows": { + "rect": [ + 604.0, + 216.0, + 712.0, + 3453.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons": { + "rect": [ + 604.0, + 216.0, + 168.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/CancelButton": { + "rect": [ + 604.0, + 216.0, + 76.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/UploadButton": { + "rect": [ + 604.0, + 216.0, + 80.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/FolderLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/HelpLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 3247.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow": { + "rect": [ + 604.0, + 216.0, + 281.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/ChooseFolderButton": { + "rect": [ + 604.0, + 216.0, + 133.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameField": { + "rect": [ + 604.0, + 216.0, + 68.5, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameTitle": { + "rect": [ + 604.0, + 216.0, + 59.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/StatusLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SummaryLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/TitleLabel": { + "rect": [ + 604.0, + 216.0, + 212.0, + 35.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/UploadProgress": { + "rect": [ + 604.0, + 216.0, + 4.0, + 4.0 + ], + "visible": false } }, "popup_AboutWindow": { @@ -27005,6 +27923,676 @@ "visible": true } }, + "popup_UploadWindow": { + "UploadPopup": { + "rect": [ + 580.0, + 200.0, + 1920.0, + 1080.0 + ], + "visible": true + }, + "UploadPopup/FolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel": { + "rect": [ + 580.0, + 200.0, + 760.0, + 680.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows": { + "rect": [ + 604.0, + 216.0, + 712.0, + 648.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/Buttons": { + "rect": [ + 604.0, + 833.0, + 712.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/Buttons/CancelButton": { + "rect": [ + 1148.0, + 833.0, + 76.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/Buttons/UploadButton": { + "rect": [ + 1236.0, + 833.0, + 80.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/FolderLabel": { + "rect": [ + 604.0, + 361.0, + 712.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/HelpLabel": { + "rect": [ + 604.0, + 261.0, + 712.0, + 49.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/NameRow": { + "rect": [ + 604.0, + 320.0, + 712.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/NameRow/ChooseFolderButton": { + "rect": [ + 1183.0, + 320.0, + 133.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameField": { + "rect": [ + 673.0, + 320.0, + 500.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameTitle": { + "rect": [ + 604.0, + 320.0, + 59.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll": { + "rect": [ + 604.0, + 397.0, + 712.0, + 393.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList": { + "rect": [ + 604.0, + 397.0, + 712.0, + 302.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#0": { + "rect": [ + 604.0, + 397.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#0/#0": { + "rect": [ + 612.0, + 399.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#1": { + "rect": [ + 604.0, + 431.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#1/#0": { + "rect": [ + 612.0, + 433.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#2": { + "rect": [ + 604.0, + 465.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#2/#0": { + "rect": [ + 612.0, + 467.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#3": { + "rect": [ + 604.0, + 499.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#3/#0": { + "rect": [ + 612.0, + 501.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#4": { + "rect": [ + 604.0, + 533.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#4/#0": { + "rect": [ + 612.0, + 535.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#5": { + "rect": [ + 604.0, + 567.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#5/#0": { + "rect": [ + 612.0, + 569.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#6": { + "rect": [ + 604.0, + 601.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#6/#0": { + "rect": [ + 612.0, + 603.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#7": { + "rect": [ + 604.0, + 635.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#7/#0": { + "rect": [ + 612.0, + 637.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#8": { + "rect": [ + 604.0, + 669.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#8/#0": { + "rect": [ + 612.0, + 671.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/StatusLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SummaryLabel": { + "rect": [ + 604.0, + 800.0, + 712.0, + 23.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/TitleLabel": { + "rect": [ + 604.0, + 216.0, + 712.0, + 35.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/UploadProgress": { + "rect": [ + 604.0, + 216.0, + 4.0, + 4.0 + ], + "visible": false + } + }, + "popup_UploadWindow_uploading": { + "UploadPopup": { + "rect": [ + 580.0, + 200.0, + 1920.0, + 1080.0 + ], + "visible": true + }, + "UploadPopup/FolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel": { + "rect": [ + 580.0, + 200.0, + 760.0, + 680.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows": { + "rect": [ + 604.0, + 216.0, + 712.0, + 648.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/Buttons": { + "rect": [ + 604.0, + 833.0, + 712.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/Buttons/CancelButton": { + "rect": [ + 1148.0, + 833.0, + 76.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/Buttons/UploadButton": { + "rect": [ + 1236.0, + 833.0, + 80.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/FolderLabel": { + "rect": [ + 604.0, + 361.0, + 712.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/HelpLabel": { + "rect": [ + 604.0, + 261.0, + 712.0, + 49.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/NameRow": { + "rect": [ + 604.0, + 320.0, + 712.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/NameRow/ChooseFolderButton": { + "rect": [ + 1183.0, + 320.0, + 133.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameField": { + "rect": [ + 673.0, + 320.0, + 500.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameTitle": { + "rect": [ + 604.0, + 320.0, + 59.0, + 31.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll": { + "rect": [ + 604.0, + 397.0, + 712.0, + 343.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList": { + "rect": [ + 604.0, + 397.0, + 712.0, + 302.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#0": { + "rect": [ + 604.0, + 397.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#0/#0": { + "rect": [ + 612.0, + 399.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#1": { + "rect": [ + 604.0, + 431.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#1/#0": { + "rect": [ + 612.0, + 433.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#2": { + "rect": [ + 604.0, + 465.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#2/#0": { + "rect": [ + 612.0, + 467.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#3": { + "rect": [ + 604.0, + 499.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#3/#0": { + "rect": [ + 612.0, + 501.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#4": { + "rect": [ + 604.0, + 533.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#4/#0": { + "rect": [ + 612.0, + 535.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#5": { + "rect": [ + 604.0, + 567.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#5/#0": { + "rect": [ + 612.0, + 569.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#6": { + "rect": [ + 604.0, + 601.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#6/#0": { + "rect": [ + 612.0, + 603.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#7": { + "rect": [ + 604.0, + 635.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#7/#0": { + "rect": [ + 612.0, + 637.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#8": { + "rect": [ + 604.0, + 669.0, + 712.0, + 30.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList/#8/#0": { + "rect": [ + 612.0, + 671.0, + 696.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/StatusLabel": { + "rect": [ + 604.0, + 797.0, + 712.0, + 26.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/SummaryLabel": { + "rect": [ + 604.0, + 750.0, + 712.0, + 23.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/TitleLabel": { + "rect": [ + 604.0, + 216.0, + 712.0, + 35.0 + ], + "visible": true + }, + "UploadPopup/UploadPanel/Rows/UploadProgress": { + "rect": [ + 604.0, + 783.0, + 712.0, + 4.0 + ], + "visible": true + } + }, "popup_WinnerWindow": { "Quiz/WinnerPopup": { "rect": [ @@ -31827,6 +33415,24 @@ ], "visible": false }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 32.0, + 72.0, + 4.0, + 3.0 + ], + "visible": false + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 32.0, + 72.0, + 160.0, + 31.0 + ], + "visible": false + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 32.0, @@ -32447,6 +34053,177 @@ 35.0 ], "visible": false + }, + "UploadPopup": { + "rect": [ + 580.0, + 200.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UploadPopup/FolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel": { + "rect": [ + 580.0, + 200.0, + 760.0, + 680.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows": { + "rect": [ + 604.0, + 216.0, + 712.0, + 3453.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons": { + "rect": [ + 604.0, + 216.0, + 168.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/CancelButton": { + "rect": [ + 604.0, + 216.0, + 76.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/UploadButton": { + "rect": [ + 604.0, + 216.0, + 80.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/FolderLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/HelpLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 3247.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow": { + "rect": [ + 604.0, + 216.0, + 281.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/ChooseFolderButton": { + "rect": [ + 604.0, + 216.0, + 133.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameField": { + "rect": [ + 604.0, + 216.0, + 68.5, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameTitle": { + "rect": [ + 604.0, + 216.0, + 59.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/StatusLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SummaryLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/TitleLabel": { + "rect": [ + 604.0, + 216.0, + 212.0, + 35.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/UploadProgress": { + "rect": [ + 604.0, + 216.0, + 4.0, + 4.0 + ], + "visible": false } }, "statistics": { @@ -34151,6 +35928,24 @@ ], "visible": true }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 1712.0, + 72.0, + 4.0, + 40.0 + ], + "visible": true + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 1728.0, + 72.0, + 160.0, + 40.0 + ], + "visible": true + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 805.0, @@ -34164,7 +35959,7 @@ "rect": [ 882.0, 80.0, - 564.0, + 376.0, 23.0 ], "visible": true @@ -34189,7 +35984,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -34198,7 +35993,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -34216,7 +36011,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -34225,7 +36020,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -35944,6 +37739,24 @@ ], "visible": true }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 1712.0, + 72.0, + 4.0, + 40.0 + ], + "visible": true + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 1728.0, + 72.0, + 160.0, + 40.0 + ], + "visible": true + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 805.0, @@ -35957,7 +37770,7 @@ "rect": [ 882.0, 80.0, - 564.0, + 376.0, 23.0 ], "visible": true @@ -35982,7 +37795,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -35991,7 +37804,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -36009,7 +37822,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -36018,7 +37831,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -37737,6 +39550,24 @@ ], "visible": true }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 1712.0, + 72.0, + 4.0, + 40.0 + ], + "visible": true + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 1728.0, + 72.0, + 160.0, + 40.0 + ], + "visible": true + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 805.0, @@ -37750,7 +39581,7 @@ "rect": [ 882.0, 80.0, - 564.0, + 376.0, 23.0 ], "visible": true @@ -37775,7 +39606,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -37784,7 +39615,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -37802,7 +39633,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -37811,7 +39642,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -40835,6 +42666,24 @@ ], "visible": true }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 1712.0, + 72.0, + 4.0, + 40.0 + ], + "visible": true + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 1728.0, + 72.0, + 160.0, + 40.0 + ], + "visible": true + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 805.0, @@ -40848,7 +42697,7 @@ "rect": [ 882.0, 80.0, - 790.0, + 602.0, 23.0 ], "visible": true @@ -40873,7 +42722,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -40882,7 +42731,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -40900,7 +42749,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -40909,7 +42758,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -43933,6 +45782,24 @@ ], "visible": true }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 1712.0, + 72.0, + 4.0, + 40.0 + ], + "visible": true + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 1728.0, + 72.0, + 160.0, + 40.0 + ], + "visible": true + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 805.0, @@ -43946,7 +45813,7 @@ "rect": [ 882.0, 80.0, - 790.0, + 602.0, 23.0 ], "visible": true @@ -43971,7 +45838,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -43980,7 +45847,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -43998,7 +45865,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -44007,7 +45874,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -48174,6 +50041,24 @@ ], "visible": false }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 32.0, + 72.0, + 4.0, + 3.0 + ], + "visible": false + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 32.0, + 72.0, + 160.0, + 31.0 + ], + "visible": false + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 32.0, @@ -48794,6 +50679,177 @@ 35.0 ], "visible": false + }, + "UploadPopup": { + "rect": [ + 580.0, + 200.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UploadPopup/FolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel": { + "rect": [ + 580.0, + 200.0, + 760.0, + 680.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows": { + "rect": [ + 604.0, + 216.0, + 712.0, + 3453.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons": { + "rect": [ + 604.0, + 216.0, + 168.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/CancelButton": { + "rect": [ + 604.0, + 216.0, + 76.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/UploadButton": { + "rect": [ + 604.0, + 216.0, + 80.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/FolderLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/HelpLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 3247.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow": { + "rect": [ + 604.0, + 216.0, + 281.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/ChooseFolderButton": { + "rect": [ + 604.0, + 216.0, + 133.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameField": { + "rect": [ + 604.0, + 216.0, + 68.5, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameTitle": { + "rect": [ + 604.0, + 216.0, + 59.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/StatusLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SummaryLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/TitleLabel": { + "rect": [ + 604.0, + 216.0, + 212.0, + 35.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/UploadProgress": { + "rect": [ + 604.0, + 216.0, + 4.0, + 4.0 + ], + "visible": false } }, "sync_in_progress": { @@ -53535,6 +55591,24 @@ ], "visible": false }, + "StatisticsView/Toolbar/AddSeparator": { + "rect": [ + 32.0, + 72.0, + 4.0, + 3.0 + ], + "visible": false + }, + "StatisticsView/Toolbar/AddSoundtrackButton": { + "rect": [ + 32.0, + 72.0, + 160.0, + 31.0 + ], + "visible": false + }, "StatisticsView/Toolbar/ClearButton": { "rect": [ 32.0, @@ -54245,6 +56319,177 @@ 35.0 ], "visible": false + }, + "UploadPopup": { + "rect": [ + 580.0, + 200.0, + 1920.0, + 1080.0 + ], + "visible": false + }, + "UploadPopup/FolderDialog": { + "rect": [ + 0.0, + 0.0, + 800.0, + 500.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel": { + "rect": [ + 580.0, + 200.0, + 760.0, + 680.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows": { + "rect": [ + 604.0, + 216.0, + 712.0, + 3453.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons": { + "rect": [ + 604.0, + 216.0, + 168.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/CancelButton": { + "rect": [ + 604.0, + 216.0, + 76.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/Buttons/UploadButton": { + "rect": [ + 604.0, + 216.0, + 80.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/FolderLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/HelpLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 3247.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow": { + "rect": [ + 604.0, + 216.0, + 281.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/ChooseFolderButton": { + "rect": [ + 604.0, + 216.0, + 133.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameField": { + "rect": [ + 604.0, + 216.0, + 68.5, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/NameRow/NameTitle": { + "rect": [ + 604.0, + 216.0, + 59.0, + 31.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SongScroll/SongList": { + "rect": [ + 604.0, + 216.0, + 0.0, + 0.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/StatusLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 26.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/SummaryLabel": { + "rect": [ + 604.0, + 216.0, + 1.0, + 23.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/TitleLabel": { + "rect": [ + 604.0, + 216.0, + 212.0, + 35.0 + ], + "visible": false + }, + "UploadPopup/UploadPanel/Rows/UploadProgress": { + "rect": [ + 604.0, + 216.0, + 4.0, + 4.0 + ], + "visible": false } } } \ No newline at end of file diff --git a/tests/fakes/fake_http_transport.gd b/tests/fakes/fake_http_transport.gd index 7e295a9..6140158 100644 --- a/tests/fakes/fake_http_transport.gd +++ b/tests/fakes/fake_http_transport.gd @@ -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) diff --git a/tests/fakes/ui_tour.gd b/tests/fakes/ui_tour.gd index 6c53177..f75b098 100644 --- a/tests/fakes/ui_tour.gd +++ b/tests/fakes/ui_tour.gd @@ -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() diff --git a/tests/integration/test_scene_split.gd b/tests/integration/test_scene_split.gd index f820976..0c9758c 100644 --- a/tests/integration/test_scene_split.gd +++ b/tests/integration/test_scene_split.gd @@ -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. diff --git a/tests/integration/test_upload_popup.gd b/tests/integration/test_upload_popup.gd new file mode 100644 index 0000000..4432e99 --- /dev/null +++ b/tests/integration/test_upload_popup.gd @@ -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) diff --git a/tests/integration/test_upload_popup.gd.uid b/tests/integration/test_upload_popup.gd.uid new file mode 100644 index 0000000..117bb65 --- /dev/null +++ b/tests/integration/test_upload_popup.gd.uid @@ -0,0 +1 @@ +uid://bcr68ps41ex1r diff --git a/tests/unit/test_upload_folder.gd b/tests/unit/test_upload_folder.gd new file mode 100644 index 0000000..32682d2 --- /dev/null +++ b/tests/unit/test_upload_folder.gd @@ -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? ", "What- -Live-"], + ["AC/DC", "AC-DC"], + [" .hidden name. ", "hidden name"], + ["クロノ・トリガー", "クロノ・トリガー"], +]) -> void: + assert_str(UploadFolder.clean_name(name)).is_equal(cleaned) diff --git a/tests/unit/test_upload_folder.gd.uid b/tests/unit/test_upload_folder.gd.uid new file mode 100644 index 0000000..21652fd --- /dev/null +++ b/tests/unit/test_upload_folder.gd.uid @@ -0,0 +1 @@ +uid://dlaocvvknrr73