From c86dacb8907e5dc838847614eb0510b8c3964b14 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 15 Oct 2026 21:43:59 +0200 Subject: [PATCH] Add a soundtrack by picking or dropping a folder (#50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Add soundtrack…" in the statistics toolbar, or a folder dropped anywhere on the window, opens a popup that lists the folder's songs and uploads them to the server, which adds the soundtrack at once (Sansan/MusicServer#33). - The name comes from the folder and can be changed; characters the server refuses are replaced. - Only MP3s are uploaded, as the server takes no other; other song files are listed greyed out as left out. Hidden files are skipped. - Disc folders are flattened with the disc's number first ("1-01 Title.mp3"), as the server wants one folder. With one disc folder, names stay as they are. - Songs are sent one at a time with a progress bar, reading one file at a time. A failed song is sent again twice, then the upload is cancelled and the server's reason shown. Closing the popup cancels it too. - Not offered while the server syncs, offline, or without MP3s. - Afterwards search, the inspiration list and the statistics reload. The API types come from the server's spec (Upload, UploadFile, UploadRequest). Co-Authored-By: Claude Opus 5.5 --- data/upload_folder.gd | 119 + data/upload_folder.gd.uid | 1 + main/MainWindow.tscn | 10 + main/main_window.gd | 29 +- popups/UploadPopup.tscn | 122 + popups/upload_popup.gd | 220 + popups/upload_popup.gd.uid | 1 + server/generated/upload.gd | 38 + server/generated/upload.gd.uid | 1 + server/generated/upload_file.gd | 24 + server/generated/upload_file.gd.uid | 1 + server/generated/upload_request.gd | 22 + server/generated/upload_request.gd.uid | 1 + server/music_server_client.gd | 53 + server/openapi.json | 4283 +------------------- statistics/StatisticsView.tscn | 11 + statistics/statistics_view.gd | 4 + tests/data/layout_baseline.json | 2305 ++++++++++- tests/fakes/fake_http_transport.gd | 10 + tests/fakes/ui_tour.gd | 35 + tests/integration/test_scene_split.gd | 2 +- tests/integration/test_upload_popup.gd | 204 + tests/integration/test_upload_popup.gd.uid | 1 + tests/unit/test_upload_folder.gd | 122 + tests/unit/test_upload_folder.gd.uid | 1 + 25 files changed, 3306 insertions(+), 4314 deletions(-) create mode 100644 data/upload_folder.gd create mode 100644 data/upload_folder.gd.uid create mode 100644 popups/UploadPopup.tscn create mode 100644 popups/upload_popup.gd create mode 100644 popups/upload_popup.gd.uid create mode 100644 server/generated/upload.gd create mode 100644 server/generated/upload.gd.uid create mode 100644 server/generated/upload_file.gd create mode 100644 server/generated/upload_file.gd.uid create mode 100644 server/generated/upload_request.gd create mode 100644 server/generated/upload_request.gd.uid create mode 100644 tests/integration/test_upload_popup.gd create mode 100644 tests/integration/test_upload_popup.gd.uid create mode 100644 tests/unit/test_upload_folder.gd create mode 100644 tests/unit/test_upload_folder.gd.uid 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 585d1f4..26528b9 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1,4282 +1 @@ -{ - "components": { - "schemas": { - "APIError": { - "additionalProperties": false, - "properties": { - "error": { - "description": "What went wrong", - "examples": [ - "Not Found" - ], - "type": "string" - } - }, - "required": [ - "error" - ], - "type": "object" - }, - "AlternativeNames": { - "additionalProperties": false, - "properties": { - "names": { - "description": "The new list of alternative names. Replaces the old list; send [] to remove them all.", - "items": { - "type": "string" - }, - "maxItems": 50, - "type": "array" - } - }, - "required": [ - "names" - ], - "type": "object" - }, - "CharacterDetails": { - "additionalProperties": false, - "properties": { - "modified": { - "description": "When the image was last changed, in Unix seconds", - "format": "int64", - "type": "integer" - }, - "name": { - "description": "File name of the character image", - "type": "string" - } - }, - "required": [ - "name", - "modified" - ], - "type": "object" - }, - "CoverSource": { - "additionalProperties": false, - "properties": { - "id": { - "description": "The game's ID on SteamGridDB, or the anime's ID on AniList", - "maxLength": 50, - "minLength": 1, - "type": "string" - }, - "source": { - "description": "Where the cover comes from", - "enum": [ - "steamgriddb", - "anilist" - ], - "type": "string" - } - }, - "required": [ - "source", - "id" - ], - "type": "object" - }, - "ProgressResponse": { - "additionalProperties": false, - "properties": { - "progress": { - "type": "string" - }, - "time_spent": { - "type": "string" - } - }, - "required": [ - "progress", - "time_spent" - ], - "type": "object" - }, - "Song": { - "additionalProperties": false, - "properties": { - "file_name": { - "type": "string" - }, - "name": { - "type": "string" - }, - "soundtrack_name": { - "type": "string" - }, - "soundtrack_uuid": { - "type": "string" - }, - "times_played": { - "format": "int32", - "type": "integer" - }, - "uuid": { - "type": "string" - } - }, - "required": [ - "uuid", - "name", - "times_played", - "soundtrack_uuid", - "soundtrack_name" - ], - "type": "object" - }, - "SongInfo": { - "additionalProperties": false, - "properties": { - "CurrentlyPlaying": { - "type": "boolean" - }, - "Song": { - "type": "string" - }, - "SongNo": { - "format": "int64", - "type": "integer" - }, - "SongPlayed": { - "format": "int32", - "type": "integer" - }, - "SongUuid": { - "type": "string" - }, - "Soundtrack": { - "type": "string" - }, - "SoundtrackPlayed": { - "format": "int32", - "type": "integer" - }, - "SoundtrackUuid": { - "type": "string" - } - }, - "required": [ - "SoundtrackUuid", - "Soundtrack", - "SoundtrackPlayed", - "SongUuid", - "Song", - "SongPlayed", - "CurrentlyPlaying", - "SongNo" - ], - "type": "object" - }, - "SongInfoForStats": { - "additionalProperties": false, - "properties": { - "file_name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "song_name": { - "type": "string" - }, - "song_uuid": { - "type": "string" - }, - "soundtrack_id": { - "format": "int32", - "type": "integer" - }, - "soundtrack_name": { - "type": "string" - }, - "soundtrack_uuid": { - "type": "string" - }, - "times_played": { - "format": "int32", - "type": "integer" - } - }, - "required": [ - "soundtrack_id", - "soundtrack_uuid", - "song_uuid", - "soundtrack_name", - "song_name", - "path", - "times_played" - ], - "type": "object" - }, - "Soundtrack": { - "additionalProperties": false, - "properties": { - "added": { - "format": "date-time", - "type": "string" - }, - "alternative_names": { - "description": "Other names the soundtrack is known by", - "items": { - "type": "string" - }, - "type": "array" - }, - "cover_modified": { - "description": "When the cover last changed, in Unix seconds. Missing when there is no cover.", - "format": "int64", - "type": "integer" - }, - "last_played": { - "format": "date-time", - "type": "string" - }, - "name": { - "type": "string" - }, - "times_played": { - "format": "int32", - "type": "integer" - }, - "uuid": { - "type": "string" - } - }, - "required": [ - "uuid", - "name", - "alternative_names", - "times_played", - "added" - ], - "type": "object" - }, - "SoundtrackWithSongList": { - "additionalProperties": false, - "properties": { - "added": { - "format": "date-time", - "type": "string" - }, - "alternative_names": { - "description": "Other names the soundtrack is known by", - "items": { - "type": "string" - }, - "type": "array" - }, - "cover_modified": { - "description": "When the cover last changed, in Unix seconds. Missing when there is no cover.", - "format": "int64", - "type": "integer" - }, - "last_played": { - "format": "date-time", - "type": "string" - }, - "name": { - "type": "string" - }, - "songs": { - "items": { - "$ref": "#/components/schemas/Song" - }, - "type": "array" - }, - "times_played": { - "format": "int32", - "type": "integer" - }, - "uuid": { - "type": "string" - } - }, - "required": [ - "songs", - "uuid", - "name", - "alternative_names", - "times_played", - "added" - ], - "type": "object" - }, - "SoundtrackWithSongs": { - "additionalProperties": false, - "properties": { - "alternative_names": { - "description": "Other names the soundtrack is known by", - "items": { - "type": "string" - }, - "type": "array" - }, - "cover_modified": { - "description": "When the cover last changed, in Unix seconds. Missing when there is no cover.", - "format": "int64", - "type": "integer" - }, - "songs": { - "items": { - "$ref": "#/components/schemas/SongInfoForStats" - }, - "type": "array" - }, - "soundtrack_id": { - "format": "int32", - "type": "integer" - }, - "soundtrack_last_played": { - "format": "date-time", - "type": "string" - }, - "soundtrack_name": { - "type": "string" - }, - "soundtrack_played": { - "format": "int32", - "type": "integer" - }, - "soundtrack_uuid": { - "type": "string" - } - }, - "required": [ - "soundtrack_id", - "soundtrack_uuid", - "soundtrack_name", - "alternative_names", - "soundtrack_played", - "songs" - ], - "type": "object" - }, - "StatisticsSummary": { - "additionalProperties": false, - "properties": { - "avg_soundtrack_plays": { - "format": "double", - "type": "number" - }, - "max_soundtrack_plays": { - "format": "int64", - "type": "integer" - }, - "min_soundtrack_plays": { - "format": "int64", - "type": "integer" - }, - "never_played_soundtracks": { - "format": "int64", - "type": "integer" - }, - "played_soundtracks": { - "format": "int64", - "type": "integer" - }, - "total_soundtrack_plays": { - "format": "int64", - "type": "integer" - }, - "total_soundtracks": { - "format": "int64", - "type": "integer" - } - }, - "required": [ - "total_soundtracks", - "played_soundtracks", - "never_played_soundtracks", - "total_soundtrack_plays", - "avg_soundtrack_plays", - "max_soundtrack_plays", - "min_soundtrack_plays" - ], - "type": "object" - }, - "StatusMessage": { - "additionalProperties": false, - "properties": { - "status": { - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" - }, - "SyncResponse": { - "additionalProperties": false, - "properties": { - "catched_errors": { - "items": { - "type": "string" - }, - "type": "array" - }, - "soundtracks_added": { - "items": { - "type": "string" - }, - "type": "array" - }, - "soundtracks_changed_content": { - "items": { - "type": "string" - }, - "type": "array" - }, - "soundtracks_changed_title": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - "soundtracks_re_added": { - "items": { - "type": "string" - }, - "type": "array" - }, - "soundtracks_removed": { - "items": { - "type": "string" - }, - "type": "array" - }, - "total_time": { - "type": "string" - } - }, - "required": [ - "soundtracks_added", - "soundtracks_re_added", - "soundtracks_changed_title", - "soundtracks_changed_content", - "soundtracks_removed", - "catched_errors", - "total_time" - ], - "type": "object" - }, - "TokenRequest": { - "additionalProperties": false, - "properties": { - "client_type": { - "default": "web", - "description": "Kind of client, for example web, godot or api", - "type": "string" - }, - "password": { - "description": "API password, required when the server has one configured", - "type": "string" - } - }, - "type": "object" - }, - "TokenResponse": { - "additionalProperties": false, - "properties": { - "client_type": { - "type": "string" - }, - "expires_at": { - "format": "date-time", - "type": "string" - }, - "token": { - "description": "Send as Authorization: Bearer ", - "type": "string" - } - }, - "required": [ - "token", - "expires_at", - "client_type" - ], - "type": "object" - }, - "VersionData": { - "additionalProperties": false, - "properties": { - "changelog": { - "examples": [ - [ - "Initial release", - "Bug fixes" - ] - ], - "items": { - "type": "string" - }, - "type": "array" - }, - "version": { - "examples": [ - "1.0.0" - ], - "type": "string" - } - }, - "required": [ - "version", - "changelog" - ], - "type": "object" - } - }, - "securitySchemes": { - "bearer": { - "description": "Token from POST /api/v1/token", - "scheme": "bearer", - "type": "http" - } - } - }, - "info": { - "description": "API for the MusicServer application.\n\nGet a token with `POST /api/v1/token` and send it as `Authorization: Bearer `.\n\nEvery client has its own play queue. The music endpoints return its ID in the `X-Queue-Id` header; send it back in the same header to keep using that queue. Without it, or when the queue has ended (after a reset or a sync), a new queue is started.", - "title": "MusicServer API", - "version": "5.0.0" - }, - "openapi": "3.1.0", - "paths": { - "/api/v1/character": { - "get": { - "description": "Returns a character image.", - "operationId": "get-character", - "parameters": [ - { - "description": "File name of the character image, from the character list", - "explode": false, - "in": "query", - "name": "name", - "required": true, - "schema": { - "description": "File name of the character image, from the character list", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "image/jpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - }, - "image/png": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "The image" - }, - "206": { - "content": { - "image/jpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - }, - "image/png": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "Part of the file, for a Range request" - }, - "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" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unprocessable Entity" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Character image", - "tags": [ - "characters" - ] - } - }, - "/api/v1/character/details": { - "get": { - "description": "Returns every character image with the time it was last changed, so clients can cache images and download only the changed ones.", - "operationId": "list-character-details", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/CharacterDetails" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "List characters with change times", - "tags": [ - "characters" - ] - } - }, - "/api/v1/character/list": { - "get": { - "description": "Returns the file names of all character images.", - "operationId": "list-characters", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "List characters", - "tags": [ - "characters" - ] - } - }, - "/api/v1/download": { - "get": { - "description": "Returns the name of the latest MusicPlayer release.", - "operationId": "get-latest-client-version", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - }, - "502": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Bad Gateway" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Latest client version", - "tags": [ - "download" - ] - } - }, - "/api/v1/download/linux": { - "get": { - "description": "Redirects to the download URL of the latest MusicPlayer release for Linux.", - "operationId": "download-client-linux", - "responses": { - "302": { - "description": "Found", - "headers": { - "Location": { - "schema": { - "description": "Download URL of the asset", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Not Found" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - }, - "502": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Bad Gateway" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Download the latest client for Linux", - "tags": [ - "download" - ] - } - }, - "/api/v1/download/list": { - "get": { - "description": "Returns the file names of the latest MusicPlayer release.", - "operationId": "list-client-assets", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - }, - "502": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Bad Gateway" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Latest client assets", - "tags": [ - "download" - ] - } - }, - "/api/v1/download/windows": { - "get": { - "description": "Redirects to the download URL of the latest MusicPlayer release for Windows.", - "operationId": "download-client-windows", - "responses": { - "302": { - "description": "Found", - "headers": { - "Location": { - "schema": { - "description": "Download URL of the asset", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Not Found" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - }, - "502": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Bad Gateway" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Download the latest client for Windows", - "tags": [ - "download" - ] - } - }, - "/api/v1/health": { - "get": { - "description": "Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error.", - "operationId": "get-health", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "string" - }, - "type": "object" - } - } - }, - "description": "OK" - }, - "default": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Error" - } - }, - "summary": "Health check", - "tags": [ - "server" - ] - } - }, - "/api/v1/music": { - "get": { - "description": "Jumps to the song at the given queue position (clamped to the queue) and returns it.", - "operationId": "get-queue-song", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "description": "Position of the song in the queue (SongNo)", - "explode": false, - "in": "query", - "name": "song", - "required": true, - "schema": { - "description": "Position of the song in the queue (SongNo)", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "The song (MP3)", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "206": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "Part of the file, for a Range request", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "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": "Play a song from the queue", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/addPlayed": { - "get": { - "description": "Increments the play counters of the current song and its soundtrack.", - "operationId": "mark-current-played", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "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": "Mark the current song as played", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/addQue": { - "get": { - "description": "Adds the song returned by the last random song request to the end of the queue.", - "operationId": "add-latest-to-queue", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "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": "Add the last random song to the queue", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/all": { - "get": { - "description": "Returns the names of all soundtracks in random order.", - "operationId": "get-soundtrack-names-random", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "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": "Soundtrack names, random order", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/all/order": { - "get": { - "description": "Returns the names of all soundtracks in alphabetical order.", - "operationId": "get-soundtrack-names", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "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": "Soundtrack names, sorted", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/all/random": { - "get": { - "description": "Same as /music/all.", - "operationId": "get-soundtrack-names-random-alias", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "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": "Soundtrack names, random order", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/info": { - "get": { - "description": "Returns the current song in the queue.", - "operationId": "get-song-info", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SongInfo" - } - } - }, - "description": "OK", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Current song", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/list": { - "get": { - "description": "Returns all songs in the queue.", - "operationId": "get-played-songs", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SongInfo" - }, - "type": "array" - } - } - }, - "description": "OK", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Queue", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/next": { - "get": { - "description": "Moves to the next song in the queue and returns it. Stays on the last song at the end.", - "operationId": "get-next-song", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "The song (MP3)", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "206": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "Part of the file, for a Range request", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Not Found" - }, - "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": "Next song in the queue", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/played": { - "put": { - "description": "Increments the play counters of the song at the given queue position. Positions outside the queue are ignored.", - "operationId": "mark-song-played", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "description": "Position of the song in the queue (SongNo)", - "explode": false, - "in": "query", - "name": "song", - "required": true, - "schema": { - "description": "Position of the song in the queue (SongNo)", - "format": "int64", - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "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": "Mark a queued song as played", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/previous": { - "get": { - "description": "Moves to the previous song in the queue and returns it. Stays on the first song at the start.", - "operationId": "get-previous-song", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "The song (MP3)", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "206": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "Part of the file, for a Range request", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Not Found" - }, - "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": "Previous song in the queue", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/rand": { - "get": { - "description": "Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.", - "operationId": "get-random-song", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "The song (MP3)", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "206": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "Part of the file, for a Range request", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Not Found" - }, - "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": "Random song", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/rand/classic": { - "get": { - "description": "Picks a random song where every song has the same chance, regardless of soundtrack.", - "operationId": "get-random-song-classic", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "The song (MP3)", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "206": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "Part of the file, for a Range request", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Not Found" - }, - "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": "Random song, every song equal", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/rand/low": { - "get": { - "description": "Picks a random song, favouring soundtracks that have been played less than average.", - "operationId": "get-random-song-low-chance", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "The song (MP3)", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "206": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "Part of the file, for a Range request", - "headers": { - "X-Queue-Id": { - "description": "ID of the client's play queue. Send it with the following requests.", - "schema": { - "format": "uuid", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Not Found" - }, - "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": "Random song, favouring rarely played", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/reset": { - "get": { - "description": "Ends the queue, starts a new one and reloads the soundtrack list. The new queue's ID is returned in the X-Queue-Id header; send it with the following requests.", - "operationId": "reset-queue", - "parameters": [ - { - "description": "ID of the client's play queue, from an earlier response. Omit it to start a new queue.", - "in": "header", - "name": "X-Queue-Id", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "OK", - "headers": { - "X-Queue-Id": { - "schema": { - "description": "ID of the new queue", - "type": "string" - } - } - } - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "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": "Reset the queue", - "tags": [ - "music" - ] - } - }, - "/api/v1/music/soundTest": { - "get": { - "description": "Returns a random song for checking the volume.", - "operationId": "get-sound-test-song", - "responses": { - "200": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "The song (MP3)" - }, - "206": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "Part of the file, for a Range request" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Not Found" - }, - "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": "Sound check song", - "tags": [ - "music" - ] - } - }, - "/api/v1/songs/{uuid}": { - "get": { - "description": "Returns information about a song.", - "operationId": "get-song", - "parameters": [ - { - "description": "UUID of the soundtrack or song", - "in": "path", - "name": "uuid", - "required": true, - "schema": { - "description": "UUID of the soundtrack or song", - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Song" - } - } - }, - "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" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Get song", - "tags": [ - "library" - ] - } - }, - "/api/v1/songs/{uuid}/file": { - "get": { - "description": "Returns the audio file of a song.", - "operationId": "get-song-file", - "parameters": [ - { - "description": "UUID of the soundtrack or song", - "in": "path", - "name": "uuid", - "required": true, - "schema": { - "description": "UUID of the soundtrack or song", - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "The song (MP3)" - }, - "206": { - "content": { - "audio/mpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "Part of the file, for a Range request" - }, - "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": "Stream song", - "tags": [ - "library" - ] - } - }, - "/api/v1/soundtracks": { - "get": { - "description": "Returns all soundtracks, sorted by name.", - "operationId": "list-soundtracks", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/Soundtrack" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "List soundtracks", - "tags": [ - "library" - ] - } - }, - "/api/v1/soundtracks/{uuid}": { - "get": { - "description": "Returns a soundtrack with its songs, sorted by name.", - "operationId": "get-soundtrack", - "parameters": [ - { - "description": "UUID of the soundtrack or song", - "in": "path", - "name": "uuid", - "required": true, - "schema": { - "description": "UUID of the soundtrack or song", - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SoundtrackWithSongList" - } - } - }, - "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" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Get soundtrack", - "tags": [ - "library" - ] - } - }, - "/api/v1/soundtracks/{uuid}/alternative-names": { - "put": { - "description": "Replaces the soundtrack's alternative names, which search and the cover search also use. Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.", - "operationId": "set-alternative-names", - "parameters": [ - { - "description": "UUID of the soundtrack", - "in": "path", - "name": "uuid", - "required": true, - "schema": { - "description": "UUID of the soundtrack", - "format": "uuid", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AlternativeNames" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Soundtrack" - } - } - }, - "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" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Set alternative names", - "tags": [ - "library" - ] - } - }, - "/api/v1/soundtracks/{uuid}/cover": { - "delete": { - "description": "Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.", - "operationId": "delete-soundtrack-cover", - "parameters": [ - { - "description": "UUID of the soundtrack or song", - "in": "path", - "name": "uuid", - "required": true, - "schema": { - "description": "UUID of the soundtrack or song", - "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" - }, - "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": "Remove the cover", - "tags": [ - "covers" - ] - }, - "get": { - "description": "Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.", - "operationId": "get-soundtrack-cover", - "parameters": [ - { - "description": "UUID of the soundtrack or song", - "in": "path", - "name": "uuid", - "required": true, - "schema": { - "description": "UUID of the soundtrack or song", - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "image/jpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - }, - "image/png": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - }, - "image/webp": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "The cover image" - }, - "206": { - "content": { - "image/jpeg": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - }, - "image/png": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - }, - "image/webp": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "description": "Part of the file, for a Range request" - }, - "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" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Soundtrack cover", - "tags": [ - "covers" - ] - }, - "put": { - "description": "Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.", - "operationId": "upload-soundtrack-cover", - "parameters": [ - { - "description": "UUID of the soundtrack", - "in": "path", - "name": "uuid", - "required": true, - "schema": { - "description": "UUID of the soundtrack", - "format": "uuid", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "image/*": { - "schema": { - "contentMediaType": "application/octet-stream", - "format": "binary", - "type": "string" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "No Content" - }, - "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" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unprocessable Entity" - }, - "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": "Upload a cover", - "tags": [ - "covers" - ] - } - }, - "/api/v1/soundtracks/{uuid}/cover/source": { - "put": { - "description": "Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.", - "operationId": "set-soundtrack-cover-source", - "parameters": [ - { - "description": "UUID of the soundtrack", - "in": "path", - "name": "uuid", - "required": true, - "schema": { - "description": "UUID of the soundtrack", - "format": "uuid", - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CoverSource" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "No Content" - }, - "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" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unprocessable Entity" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - }, - "502": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Bad Gateway" - }, - "503": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Service Unavailable" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Pick a cover", - "tags": [ - "covers" - ] - } - }, - "/api/v1/statistics/songs/least-played": { - "get": { - "description": "Returns the least played songs with their soundtrack.", - "operationId": "get-least-played-songs", - "parameters": [ - { - "description": "Number of results. Values above 100 are capped to 100.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 10, - "description": "Number of results. Values above 100 are capped to 100.", - "format": "int64", - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SongInfoForStats" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unprocessable Entity" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Least played songs", - "tags": [ - "statistics" - ] - } - }, - "/api/v1/statistics/songs/most-played": { - "get": { - "description": "Returns the most played songs with their soundtrack.", - "operationId": "get-most-played-songs", - "parameters": [ - { - "description": "Number of results. Values above 100 are capped to 100.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 10, - "description": "Number of results. Values above 100 are capped to 100.", - "format": "int64", - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SongInfoForStats" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unprocessable Entity" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Most played songs", - "tags": [ - "statistics" - ] - } - }, - "/api/v1/statistics/soundtracks": { - "get": { - "description": "Returns every soundtrack with its songs and play counts, sorted by name.", - "operationId": "get-all-soundtrack-statistics", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SoundtrackWithSongs" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "All soundtracks with statistics", - "tags": [ - "statistics" - ] - } - }, - "/api/v1/statistics/soundtracks/last-played": { - "get": { - "description": "Returns the most recently played soundtracks with their songs.", - "operationId": "get-last-played-soundtracks", - "parameters": [ - { - "description": "Number of results. Values above 100 are capped to 100.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 10, - "description": "Number of results. Values above 100 are capped to 100.", - "format": "int64", - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SoundtrackWithSongs" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unprocessable Entity" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Recently played soundtracks", - "tags": [ - "statistics" - ] - } - }, - "/api/v1/statistics/soundtracks/least-played": { - "get": { - "description": "Returns the least played soundtracks with their songs.", - "operationId": "get-least-played-soundtracks", - "parameters": [ - { - "description": "Number of results. Values above 100 are capped to 100.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 10, - "description": "Number of results. Values above 100 are capped to 100.", - "format": "int64", - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SoundtrackWithSongs" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unprocessable Entity" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Least played soundtracks", - "tags": [ - "statistics" - ] - } - }, - "/api/v1/statistics/soundtracks/most-played": { - "get": { - "description": "Returns the most played soundtracks with their songs.", - "operationId": "get-most-played-soundtracks", - "parameters": [ - { - "description": "Number of results. Values above 100 are capped to 100.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 10, - "description": "Number of results. Values above 100 are capped to 100.", - "format": "int64", - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SoundtrackWithSongs" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unprocessable Entity" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Most played soundtracks", - "tags": [ - "statistics" - ] - } - }, - "/api/v1/statistics/soundtracks/never-played": { - "get": { - "description": "Returns all soundtracks that have never been played, with their songs.", - "operationId": "get-never-played-soundtracks", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SoundtrackWithSongs" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Never played soundtracks", - "tags": [ - "statistics" - ] - } - }, - "/api/v1/statistics/soundtracks/oldest-played": { - "get": { - "description": "Returns the soundtracks played longest ago (played at least once) with their songs.", - "operationId": "get-oldest-played-soundtracks", - "parameters": [ - { - "description": "Number of results. Values above 100 are capped to 100.", - "explode": false, - "in": "query", - "name": "limit", - "schema": { - "default": 10, - "description": "Number of results. Values above 100 are capped to 100.", - "format": "int64", - "minimum": 1, - "type": "integer" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/SoundtrackWithSongs" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unprocessable Entity" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Least recently played soundtracks", - "tags": [ - "statistics" - ] - } - }, - "/api/v1/statistics/summary": { - "get": { - "description": "Returns overall play statistics.", - "operationId": "get-statistics-summary", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StatisticsSummary" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Statistics summary", - "tags": [ - "statistics" - ] - } - }, - "/api/v1/sync": { - "post": { - "description": "Starts syncing the music folder in the background, skipping soundtracks that have not changed. Poll /sync/progress for the result.", - "operationId": "start-sync", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "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": "Sync changed soundtracks", - "tags": [ - "sync" - ] - } - }, - "/api/v1/sync/full": { - "post": { - "description": "Starts a full sync in the background that re-reads every soundtrack and removes songs whose files are gone. Poll /sync/progress for the result.", - "operationId": "start-full-sync", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "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": "Sync all soundtracks", - "tags": [ - "sync" - ] - } - }, - "/api/v1/sync/progress": { - "get": { - "description": "While a sync is running, returns its progress. Otherwise returns the result of the last sync.", - "operationId": "get-sync-progress", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "oneOf": [ - { - "$ref": "#/components/schemas/ProgressResponse" - }, - { - "$ref": "#/components/schemas/SyncResponse" - } - ] - } - } - }, - "description": "Progress of the running sync, or the result of the last sync" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Sync progress or result", - "tags": [ - "sync" - ] - } - }, - "/api/v1/token": { - "delete": { - "description": "Deletes the token used for this request.", - "operationId": "delete-token", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StatusMessage" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Invalidate session token", - "tags": [ - "auth" - ] - }, - "post": { - "description": "Returns a token for the other /api/v1 endpoints, valid for 24 hours. Requires the API password when the server has one. Limited to 10 requests per minute per client.", - "operationId": "create-token", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TokenRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TokenResponse" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unprocessable Entity" - }, - "429": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Too Many Requests" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "summary": "Create session token", - "tags": [ - "auth" - ] - } - }, - "/api/v1/token/cleanup": { - "post": { - "description": "Deletes all expired session tokens.", - "operationId": "cleanup-tokens", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StatusMessage" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Remove expired tokens", - "tags": [ - "auth" - ] - } - }, - "/api/v1/version": { - "get": { - "description": "Returns the server version and its changelog.", - "operationId": "get-version", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VersionData" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Latest server version", - "tags": [ - "server" - ] - } - }, - "/api/v1/version/history": { - "get": { - "description": "Returns all server versions with their changelogs, newest first.", - "operationId": "get-version-history", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/VersionData" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Unauthorized" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/APIError" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "Version history", - "tags": [ - "server" - ] - } - } - } -} +{"components":{"schemas":{"APIError":{"additionalProperties":false,"properties":{"error":{"description":"What went wrong","examples":["Not Found"],"type":"string"}},"required":["error"],"type":"object"},"AlternativeNames":{"additionalProperties":false,"properties":{"names":{"description":"The new list of alternative names. Replaces the old list; send [] to remove them all.","items":{"type":"string"},"maxItems":50,"type":"array"}},"required":["names"],"type":"object"},"CharacterDetails":{"additionalProperties":false,"properties":{"modified":{"description":"When the image was last changed, in Unix seconds","format":"int64","type":"integer"},"name":{"description":"File name of the character image","type":"string"}},"required":["name","modified"],"type":"object"},"CoverSource":{"additionalProperties":false,"properties":{"id":{"description":"The game's ID on SteamGridDB, or the anime's ID on AniList","maxLength":50,"minLength":1,"type":"string"},"source":{"description":"Where the cover comes from","enum":["steamgriddb","anilist"],"type":"string"}},"required":["source","id"],"type":"object"},"ProgressResponse":{"additionalProperties":false,"properties":{"progress":{"type":"string"},"time_spent":{"type":"string"}},"required":["progress","time_spent"],"type":"object"},"Song":{"additionalProperties":false,"properties":{"file_name":{"type":"string"},"name":{"type":"string"},"soundtrack_name":{"type":"string"},"soundtrack_uuid":{"type":"string"},"times_played":{"format":"int32","type":"integer"},"uuid":{"type":"string"}},"required":["uuid","name","times_played","soundtrack_uuid","soundtrack_name"],"type":"object"},"SongInfo":{"additionalProperties":false,"properties":{"CurrentlyPlaying":{"type":"boolean"},"Song":{"type":"string"},"SongNo":{"format":"int64","type":"integer"},"SongPlayed":{"format":"int32","type":"integer"},"SongUuid":{"type":"string"},"Soundtrack":{"type":"string"},"SoundtrackPlayed":{"format":"int32","type":"integer"},"SoundtrackUuid":{"type":"string"}},"required":["SoundtrackUuid","Soundtrack","SoundtrackPlayed","SongUuid","Song","SongPlayed","CurrentlyPlaying","SongNo"],"type":"object"},"SongInfoForStats":{"additionalProperties":false,"properties":{"file_name":{"type":"string"},"path":{"type":"string"},"song_name":{"type":"string"},"song_uuid":{"type":"string"},"soundtrack_id":{"format":"int32","type":"integer"},"soundtrack_name":{"type":"string"},"soundtrack_uuid":{"type":"string"},"times_played":{"format":"int32","type":"integer"}},"required":["soundtrack_id","soundtrack_uuid","song_uuid","soundtrack_name","song_name","path","times_played"],"type":"object"},"Soundtrack":{"additionalProperties":false,"properties":{"added":{"format":"date-time","type":"string"},"alternative_names":{"description":"Other names the soundtrack is known by","items":{"type":"string"},"type":"array"},"cover_modified":{"description":"When the cover last changed, in Unix seconds. Missing when there is no cover.","format":"int64","type":"integer"},"last_played":{"format":"date-time","type":"string"},"name":{"type":"string"},"times_played":{"format":"int32","type":"integer"},"uuid":{"type":"string"}},"required":["uuid","name","alternative_names","times_played","added"],"type":"object"},"SoundtrackWithSongList":{"additionalProperties":false,"properties":{"added":{"format":"date-time","type":"string"},"alternative_names":{"description":"Other names the soundtrack is known by","items":{"type":"string"},"type":"array"},"cover_modified":{"description":"When the cover last changed, in Unix seconds. Missing when there is no cover.","format":"int64","type":"integer"},"last_played":{"format":"date-time","type":"string"},"name":{"type":"string"},"songs":{"items":{"$ref":"#/components/schemas/Song"},"type":"array"},"times_played":{"format":"int32","type":"integer"},"uuid":{"type":"string"}},"required":["songs","uuid","name","alternative_names","times_played","added"],"type":"object"},"SoundtrackWithSongs":{"additionalProperties":false,"properties":{"alternative_names":{"description":"Other names the soundtrack is known by","items":{"type":"string"},"type":"array"},"cover_modified":{"description":"When the cover last changed, in Unix seconds. Missing when there is no cover.","format":"int64","type":"integer"},"songs":{"items":{"$ref":"#/components/schemas/SongInfoForStats"},"type":"array"},"soundtrack_id":{"format":"int32","type":"integer"},"soundtrack_last_played":{"format":"date-time","type":"string"},"soundtrack_name":{"type":"string"},"soundtrack_played":{"format":"int32","type":"integer"},"soundtrack_uuid":{"type":"string"}},"required":["soundtrack_id","soundtrack_uuid","soundtrack_name","alternative_names","soundtrack_played","songs"],"type":"object"},"StatisticsSummary":{"additionalProperties":false,"properties":{"avg_soundtrack_plays":{"format":"double","type":"number"},"max_soundtrack_plays":{"format":"int64","type":"integer"},"min_soundtrack_plays":{"format":"int64","type":"integer"},"never_played_soundtracks":{"format":"int64","type":"integer"},"played_soundtracks":{"format":"int64","type":"integer"},"total_soundtrack_plays":{"format":"int64","type":"integer"},"total_soundtracks":{"format":"int64","type":"integer"}},"required":["total_soundtracks","played_soundtracks","never_played_soundtracks","total_soundtrack_plays","avg_soundtrack_plays","max_soundtrack_plays","min_soundtrack_plays"],"type":"object"},"StatusMessage":{"additionalProperties":false,"properties":{"status":{"type":"string"}},"required":["status"],"type":"object"},"SyncResponse":{"additionalProperties":false,"properties":{"catched_errors":{"items":{"type":"string"},"type":"array"},"soundtracks_added":{"items":{"type":"string"},"type":"array"},"soundtracks_changed_content":{"items":{"type":"string"},"type":"array"},"soundtracks_changed_title":{"additionalProperties":{"type":"string"},"type":"object"},"soundtracks_re_added":{"items":{"type":"string"},"type":"array"},"soundtracks_removed":{"items":{"type":"string"},"type":"array"},"total_time":{"type":"string"}},"required":["soundtracks_added","soundtracks_re_added","soundtracks_changed_title","soundtracks_changed_content","soundtracks_removed","catched_errors","total_time"],"type":"object"},"TokenRequest":{"additionalProperties":false,"properties":{"client_type":{"default":"web","description":"Kind of client, for example web, godot or api","type":"string"},"password":{"description":"API password, required when the server has one configured","type":"string"}},"type":"object"},"TokenResponse":{"additionalProperties":false,"properties":{"client_type":{"type":"string"},"expires_at":{"format":"date-time","type":"string"},"token":{"description":"Send as Authorization: Bearer \u003ctoken\u003e","type":"string"}},"required":["token","expires_at","client_type"],"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":{"changelog":{"examples":[["Initial release","Bug fixes"]],"items":{"type":"string"},"type":"array"},"version":{"examples":["1.0.0"],"type":"string"}},"required":["version","changelog"],"type":"object"}},"securitySchemes":{"bearer":{"description":"Token from POST /api/v1/token","scheme":"bearer","type":"http"}}},"info":{"description":"API for the MusicServer application.\n\nGet a token with `POST /api/v1/token` and send it as `Authorization: Bearer \u003ctoken\u003e`.\n\nEvery client has its own play queue. The music endpoints return its ID in the `X-Queue-Id` header; send it back in the same header to keep using that queue. Without it, or when the queue has ended (after a reset or a sync), a new queue is started.","title":"MusicServer API","version":"5.0.0"},"openapi":"3.1.0","paths":{"/api/v1/character":{"get":{"description":"Returns a character image.","operationId":"get-character","parameters":[{"description":"File name of the character image, from the character list","explode":false,"in":"query","name":"name","required":true,"schema":{"description":"File name of the character image, from the character list","type":"string"}}],"responses":{"200":{"content":{"image/jpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}},"image/png":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The image"},"206":{"content":{"image/jpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}},"image/png":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"Part of the file, for a Range request"},"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"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Character image","tags":["characters"]}},"/api/v1/character/details":{"get":{"description":"Returns every character image with the time it was last changed, so clients can cache images and download only the changed ones.","operationId":"list-character-details","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/CharacterDetails"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"List characters with change times","tags":["characters"]}},"/api/v1/character/list":{"get":{"description":"Returns the file names of all character images.","operationId":"list-characters","responses":{"200":{"content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"List characters","tags":["characters"]}},"/api/v1/download":{"get":{"description":"Returns the name of the latest MusicPlayer release.","operationId":"get-latest-client-version","responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"},"502":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Bad Gateway"}},"security":[{"bearer":[]}],"summary":"Latest client version","tags":["download"]}},"/api/v1/download/linux":{"get":{"description":"Redirects to the download URL of the latest MusicPlayer release for Linux.","operationId":"download-client-linux","responses":{"302":{"description":"Found","headers":{"Location":{"schema":{"description":"Download URL of the asset","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"},"502":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Bad Gateway"}},"security":[{"bearer":[]}],"summary":"Download the latest client for Linux","tags":["download"]}},"/api/v1/download/linux-package":{"get":{"description":"Redirects to the download URL of the latest MusicPlayer release for Linux (package with installer).","operationId":"download-client-linux-package","responses":{"302":{"description":"Found","headers":{"Location":{"schema":{"description":"Download URL of the asset","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"},"502":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Bad Gateway"}},"security":[{"bearer":[]}],"summary":"Download the latest client for Linux (package with installer)","tags":["download"]}},"/api/v1/download/list":{"get":{"description":"Returns the file names of the latest MusicPlayer release.","operationId":"list-client-assets","responses":{"200":{"content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"},"502":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Bad Gateway"}},"security":[{"bearer":[]}],"summary":"Latest client assets","tags":["download"]}},"/api/v1/download/windows":{"get":{"description":"Redirects to the download URL of the latest MusicPlayer release for Windows.","operationId":"download-client-windows","responses":{"302":{"description":"Found","headers":{"Location":{"schema":{"description":"Download URL of the asset","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"},"502":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Bad Gateway"}},"security":[{"bearer":[]}],"summary":"Download the latest client for Windows","tags":["download"]}},"/api/v1/health":{"get":{"description":"Returns {\"status\": \"up\"} when the server and database work, otherwise status \"down\" and an error.","operationId":"get-health","responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"OK"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Error"}},"summary":"Health check","tags":["server"]}},"/api/v1/music":{"get":{"description":"Jumps to the song at the given queue position (clamped to the queue) and returns it.","operationId":"get-queue-song","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}},{"description":"Position of the song in the queue (SongNo)","explode":false,"in":"query","name":"song","required":true,"schema":{"description":"Position of the song in the queue (SongNo)","format":"int64","type":"integer"}}],"responses":{"200":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The song (MP3)","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"206":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"Part of the file, for a Range request","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"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":"Play a song from the queue","tags":["music"]}},"/api/v1/music/addPlayed":{"get":{"description":"Increments the play counters of the current song and its soundtrack.","operationId":"mark-current-played","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"OK","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"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":"Mark the current song as played","tags":["music"]}},"/api/v1/music/addQue":{"get":{"description":"Adds the song returned by the last random song request to the end of the queue.","operationId":"add-latest-to-queue","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"OK","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"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":"Add the last random song to the queue","tags":["music"]}},"/api/v1/music/all":{"get":{"description":"Returns the names of all soundtracks in random order.","operationId":"get-soundtrack-names-random","responses":{"200":{"content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"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":"Soundtrack names, random order","tags":["music"]}},"/api/v1/music/all/order":{"get":{"description":"Returns the names of all soundtracks in alphabetical order.","operationId":"get-soundtrack-names","responses":{"200":{"content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"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":"Soundtrack names, sorted","tags":["music"]}},"/api/v1/music/all/random":{"get":{"description":"Same as /music/all.","operationId":"get-soundtrack-names-random-alias","responses":{"200":{"content":{"application/json":{"schema":{"items":{"type":"string"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"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":"Soundtrack names, random order","tags":["music"]}},"/api/v1/music/info":{"get":{"description":"Returns the current song in the queue.","operationId":"get-song-info","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SongInfo"}}},"description":"OK","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Current song","tags":["music"]}},"/api/v1/music/list":{"get":{"description":"Returns all songs in the queue.","operationId":"get-played-songs","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SongInfo"},"type":"array"}}},"description":"OK","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Queue","tags":["music"]}},"/api/v1/music/next":{"get":{"description":"Moves to the next song in the queue and returns it. Stays on the last song at the end.","operationId":"get-next-song","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The song (MP3)","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"206":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"Part of the file, for a Range request","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Not Found"},"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":"Next song in the queue","tags":["music"]}},"/api/v1/music/played":{"put":{"description":"Increments the play counters of the song at the given queue position. Positions outside the queue are ignored.","operationId":"mark-song-played","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}},{"description":"Position of the song in the queue (SongNo)","explode":false,"in":"query","name":"song","required":true,"schema":{"description":"Position of the song in the queue (SongNo)","format":"int64","type":"integer"}}],"responses":{"200":{"description":"OK","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"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":"Mark a queued song as played","tags":["music"]}},"/api/v1/music/previous":{"get":{"description":"Moves to the previous song in the queue and returns it. Stays on the first song at the start.","operationId":"get-previous-song","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The song (MP3)","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"206":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"Part of the file, for a Range request","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Not Found"},"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":"Previous song in the queue","tags":["music"]}},"/api/v1/music/rand":{"get":{"description":"Picks a random song from a random soundtrack. Add it to the queue with /music/addQue.","operationId":"get-random-song","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The song (MP3)","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"206":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"Part of the file, for a Range request","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Not Found"},"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":"Random song","tags":["music"]}},"/api/v1/music/rand/classic":{"get":{"description":"Picks a random song where every song has the same chance, regardless of soundtrack.","operationId":"get-random-song-classic","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The song (MP3)","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"206":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"Part of the file, for a Range request","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Not Found"},"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":"Random song, every song equal","tags":["music"]}},"/api/v1/music/rand/low":{"get":{"description":"Picks a random song, favouring soundtracks that have been played less than average.","operationId":"get-random-song-low-chance","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The song (MP3)","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"206":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"Part of the file, for a Range request","headers":{"X-Queue-Id":{"description":"ID of the client's play queue. Send it with the following requests.","schema":{"format":"uuid","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Not Found"},"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":"Random song, favouring rarely played","tags":["music"]}},"/api/v1/music/reset":{"get":{"description":"Ends the queue, starts a new one and reloads the soundtrack list. The new queue's ID is returned in the X-Queue-Id header; send it with the following requests.","operationId":"reset-queue","parameters":[{"description":"ID of the client's play queue, from an earlier response. Omit it to start a new queue.","in":"header","name":"X-Queue-Id","schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"OK","headers":{"X-Queue-Id":{"schema":{"description":"ID of the new queue","type":"string"}}}},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"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":"Reset the queue","tags":["music"]}},"/api/v1/music/soundTest":{"get":{"description":"Returns a random song for checking the volume.","operationId":"get-sound-test-song","responses":{"200":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The song (MP3)"},"206":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"Part of the file, for a Range request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Not Found"},"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":"Sound check song","tags":["music"]}},"/api/v1/songs/{uuid}":{"get":{"description":"Returns information about a song.","operationId":"get-song","parameters":[{"description":"UUID of the soundtrack or song","in":"path","name":"uuid","required":true,"schema":{"description":"UUID of the soundtrack or song","format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Song"}}},"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"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Get song","tags":["library"]}},"/api/v1/songs/{uuid}/file":{"get":{"description":"Returns the audio file of a song.","operationId":"get-song-file","parameters":[{"description":"UUID of the soundtrack or song","in":"path","name":"uuid","required":true,"schema":{"description":"UUID of the soundtrack or song","format":"uuid","type":"string"}}],"responses":{"200":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The song (MP3)"},"206":{"content":{"audio/mpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"Part of the file, for a Range request"},"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":"Stream song","tags":["library"]}},"/api/v1/soundtracks":{"get":{"description":"Returns all soundtracks, sorted by name.","operationId":"list-soundtracks","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Soundtrack"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"List soundtracks","tags":["library"]}},"/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.","operationId":"get-soundtrack","parameters":[{"description":"UUID of the soundtrack or song","in":"path","name":"uuid","required":true,"schema":{"description":"UUID of the soundtrack or song","format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SoundtrackWithSongList"}}},"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"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Get soundtrack","tags":["library"]}},"/api/v1/soundtracks/{uuid}/alternative-names":{"put":{"description":"Replaces the soundtrack's alternative names, which search and the cover search also use. Names are trimmed, and empty names, duplicates and the soundtrack's own name (ignoring case) are dropped.","operationId":"set-alternative-names","parameters":[{"description":"UUID of the soundtrack","in":"path","name":"uuid","required":true,"schema":{"description":"UUID of the soundtrack","format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlternativeNames"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Soundtrack"}}},"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"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Set alternative names","tags":["library"]}},"/api/v1/soundtracks/{uuid}/cover":{"delete":{"description":"Deletes the soundtrack's cover, including one set by hand, so the automatic search looks for a new one.","operationId":"delete-soundtrack-cover","parameters":[{"description":"UUID of the soundtrack or song","in":"path","name":"uuid","required":true,"schema":{"description":"UUID of the soundtrack or song","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"},"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":"Remove the cover","tags":["covers"]},"get":{"description":"Returns the soundtrack's cover image. cover_modified in the soundtrack responses tells when it last changed.","operationId":"get-soundtrack-cover","parameters":[{"description":"UUID of the soundtrack or song","in":"path","name":"uuid","required":true,"schema":{"description":"UUID of the soundtrack or song","format":"uuid","type":"string"}}],"responses":{"200":{"content":{"image/jpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}},"image/png":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}},"image/webp":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"The cover image"},"206":{"content":{"image/jpeg":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}},"image/png":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}},"image/webp":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"description":"Part of the file, for a Range request"},"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"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Soundtrack cover","tags":["covers"]},"put":{"description":"Stores the image as the soundtrack's cover. The automatic search never replaces it; delete it to go back to automatic.","operationId":"upload-soundtrack-cover","parameters":[{"description":"UUID of the soundtrack","in":"path","name":"uuid","required":true,"schema":{"description":"UUID of the soundtrack","format":"uuid","type":"string"}}],"requestBody":{"content":{"image/*":{"schema":{"contentMediaType":"application/octet-stream","format":"binary","type":"string"}}},"required":true},"responses":{"204":{"description":"No Content"},"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"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unprocessable Entity"},"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":"Upload a cover","tags":["covers"]}},"/api/v1/soundtracks/{uuid}/cover/source":{"put":{"description":"Downloads the cover of the given SteamGridDB game or AniList anime and stores it as the soundtrack's cover. The automatic search never replaces it. SteamGridDB needs STEAMGRIDDB_API_KEY on the server.","operationId":"set-soundtrack-cover-source","parameters":[{"description":"UUID of the soundtrack","in":"path","name":"uuid","required":true,"schema":{"description":"UUID of the soundtrack","format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CoverSource"}}},"required":true},"responses":{"204":{"description":"No Content"},"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"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"},"502":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Bad Gateway"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Service Unavailable"}},"security":[{"bearer":[]}],"summary":"Pick a cover","tags":["covers"]}},"/api/v1/statistics/songs/least-played":{"get":{"description":"Returns the least played songs with their soundtrack.","operationId":"get-least-played-songs","parameters":[{"description":"Number of results. Values above 100 are capped to 100.","explode":false,"in":"query","name":"limit","schema":{"default":10,"description":"Number of results. Values above 100 are capped to 100.","format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SongInfoForStats"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Least played songs","tags":["statistics"]}},"/api/v1/statistics/songs/most-played":{"get":{"description":"Returns the most played songs with their soundtrack.","operationId":"get-most-played-songs","parameters":[{"description":"Number of results. Values above 100 are capped to 100.","explode":false,"in":"query","name":"limit","schema":{"default":10,"description":"Number of results. Values above 100 are capped to 100.","format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SongInfoForStats"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Most played songs","tags":["statistics"]}},"/api/v1/statistics/soundtracks":{"get":{"description":"Returns every soundtrack with its songs and play counts, sorted by name.","operationId":"get-all-soundtrack-statistics","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SoundtrackWithSongs"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"All soundtracks with statistics","tags":["statistics"]}},"/api/v1/statistics/soundtracks/last-played":{"get":{"description":"Returns the most recently played soundtracks with their songs.","operationId":"get-last-played-soundtracks","parameters":[{"description":"Number of results. Values above 100 are capped to 100.","explode":false,"in":"query","name":"limit","schema":{"default":10,"description":"Number of results. Values above 100 are capped to 100.","format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SoundtrackWithSongs"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Recently played soundtracks","tags":["statistics"]}},"/api/v1/statistics/soundtracks/least-played":{"get":{"description":"Returns the least played soundtracks with their songs.","operationId":"get-least-played-soundtracks","parameters":[{"description":"Number of results. Values above 100 are capped to 100.","explode":false,"in":"query","name":"limit","schema":{"default":10,"description":"Number of results. Values above 100 are capped to 100.","format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SoundtrackWithSongs"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Least played soundtracks","tags":["statistics"]}},"/api/v1/statistics/soundtracks/most-played":{"get":{"description":"Returns the most played soundtracks with their songs.","operationId":"get-most-played-soundtracks","parameters":[{"description":"Number of results. Values above 100 are capped to 100.","explode":false,"in":"query","name":"limit","schema":{"default":10,"description":"Number of results. Values above 100 are capped to 100.","format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SoundtrackWithSongs"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Most played soundtracks","tags":["statistics"]}},"/api/v1/statistics/soundtracks/never-played":{"get":{"description":"Returns all soundtracks that have never been played, with their songs.","operationId":"get-never-played-soundtracks","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SoundtrackWithSongs"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Never played soundtracks","tags":["statistics"]}},"/api/v1/statistics/soundtracks/oldest-played":{"get":{"description":"Returns the soundtracks played longest ago (played at least once) with their songs.","operationId":"get-oldest-played-soundtracks","parameters":[{"description":"Number of results. Values above 100 are capped to 100.","explode":false,"in":"query","name":"limit","schema":{"default":10,"description":"Number of results. Values above 100 are capped to 100.","format":"int64","minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/SoundtrackWithSongs"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unprocessable Entity"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Least recently played soundtracks","tags":["statistics"]}},"/api/v1/statistics/summary":{"get":{"description":"Returns overall play statistics.","operationId":"get-statistics-summary","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatisticsSummary"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Statistics summary","tags":["statistics"]}},"/api/v1/sync":{"post":{"description":"Starts syncing the music folder in the background, skipping soundtracks that have not changed. Poll /sync/progress for the result.","operationId":"start-sync","responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"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":"Sync changed soundtracks","tags":["sync"]}},"/api/v1/sync/full":{"post":{"description":"Starts a full sync in the background that re-reads every soundtrack and removes songs whose files are gone. Poll /sync/progress for the result.","operationId":"start-full-sync","responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"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":"Sync all soundtracks","tags":["sync"]}},"/api/v1/sync/progress":{"get":{"description":"While a sync is running, returns its progress. Otherwise returns the result of the last sync.","operationId":"get-sync-progress","responses":{"200":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ProgressResponse"},{"$ref":"#/components/schemas/SyncResponse"}]}}},"description":"Progress of the running sync, or the result of the last sync"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Sync progress or result","tags":["sync"]}},"/api/v1/token":{"delete":{"description":"Deletes the token used for this request.","operationId":"delete-token","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusMessage"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Invalidate session token","tags":["auth"]},"post":{"description":"Returns a token for the other /api/v1 endpoints, valid for 24 hours. Requires the API password when the server has one. Limited to 10 requests per minute per client.","operationId":"create-token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TokenResponse"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"422":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unprocessable Entity"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"summary":"Create session token","tags":["auth"]}},"/api/v1/token/cleanup":{"post":{"description":"Deletes all expired session tokens.","operationId":"cleanup-tokens","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatusMessage"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Remove expired tokens","tags":["auth"]}},"/api/v1/version":{"get":{"description":"Returns the server version and its changelog.","operationId":"get-version","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VersionData"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Latest server version","tags":["server"]}},"/api/v1/version/history":{"get":{"description":"Returns all server versions with their changelogs, newest first.","operationId":"get-version-history","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/VersionData"},"type":"array"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIError"}}},"description":"Internal Server Error"}},"security":[{"bearer":[]}],"summary":"Version history","tags":["server"]}}}} \ No newline at end of file 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 5207b55..e9cfd61 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, @@ -4382,6 +4400,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": { @@ -11612,6 +11801,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, @@ -11625,7 +11832,7 @@ "rect": [ 882.0, 80.0, - 564.0, + 376.0, 23.0 ], "visible": false @@ -11650,7 +11857,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -11659,7 +11866,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -11677,7 +11884,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -11686,7 +11893,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -12295,6 +12502,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": { @@ -16267,6 +16807,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, @@ -16860,6 +17418,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": { @@ -20949,6 +21678,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, @@ -21542,6 +22289,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": { @@ -26870,6 +27788,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": [ @@ -31692,6 +33280,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, @@ -32285,6 +33891,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": { @@ -33989,6 +35766,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, @@ -34002,7 +35797,7 @@ "rect": [ 882.0, 80.0, - 564.0, + 376.0, 23.0 ], "visible": true @@ -34027,7 +35822,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -34036,7 +35831,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -34054,7 +35849,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -34063,7 +35858,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -35782,6 +37577,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, @@ -35795,7 +37608,7 @@ "rect": [ 882.0, 80.0, - 564.0, + 376.0, 23.0 ], "visible": true @@ -35820,7 +37633,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -35829,7 +37642,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -35847,7 +37660,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -35856,7 +37669,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -37575,6 +39388,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, @@ -37588,7 +39419,7 @@ "rect": [ 882.0, 80.0, - 564.0, + 376.0, 23.0 ], "visible": true @@ -37613,7 +39444,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -37622,7 +39453,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -37640,7 +39471,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -37649,7 +39480,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -40673,6 +42504,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, @@ -40686,7 +42535,7 @@ "rect": [ 882.0, 80.0, - 790.0, + 602.0, 23.0 ], "visible": true @@ -40711,7 +42560,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -40720,7 +42569,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -40738,7 +42587,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -40747,7 +42596,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -43771,6 +45620,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, @@ -43784,7 +45651,7 @@ "rect": [ 882.0, 80.0, - 790.0, + 602.0, 23.0 ], "visible": true @@ -43809,7 +45676,7 @@ }, "StatisticsView/Toolbar/ShowLabel": { "rect": [ - 1458.0, + 1270.0, 80.0, 42.0, 23.0 @@ -43818,7 +45685,7 @@ }, "StatisticsView/Toolbar/ShowOption": { "rect": [ - 1512.0, + 1324.0, 72.0, 160.0, 40.0 @@ -43836,7 +45703,7 @@ }, "StatisticsView/Toolbar/SortLabel": { "rect": [ - 1684.0, + 1496.0, 80.0, 32.0, 23.0 @@ -43845,7 +45712,7 @@ }, "StatisticsView/Toolbar/SortOption": { "rect": [ - 1728.0, + 1540.0, 72.0, 160.0, 40.0 @@ -48012,6 +49879,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, @@ -48605,6 +50490,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": { @@ -53292,6 +55348,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, @@ -53975,6 +56049,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 763d524..b042ea3 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 103b6c2..9a6c2f4 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