Add a soundtrack by picking or dropping a folder (#50)
Build / test (pull_request) Successful in 2m3s
Build / build (pull_request) Skipped

"Add soundtrack…" in the statistics toolbar, or a folder dropped anywhere on
the window, opens a popup that lists the folder's songs and uploads them to
the server, which adds the soundtrack at once (Sansan/MusicServer#33).

- The name comes from the folder and can be changed; characters the server
  refuses are replaced.
- Only MP3s are uploaded, as the server takes no other; other song files
  are listed greyed out as left out. Hidden files are skipped.
- Disc folders are flattened with the disc's number first ("1-01 Title.mp3"),
  as the server wants one folder. With one disc folder, names stay as
  they are.
- Songs are sent one at a time with a progress bar, reading one file at a
  time. A failed song is sent again twice, then the upload is cancelled
  and the server's reason shown. Closing the popup cancels it too.
- Not offered while the server syncs, offline, or without MP3s.
- Afterwards search, the inspiration list and the statistics reload.

The API types come from the server's spec (Upload, UploadFile,
UploadRequest).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SansanandClaude Opus 5.5 committed 2026-10-15 21:43:59 +02:00
1 parent f39865fffc
commit c86dacb890
25 files changed
+3306 -4314

No files matched your search

+38
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
uid://diemnuinawufa
+24
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
uid://c5a5ig8cy44hy
+22
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
uid://bmqrxhvdnss3j
+53
View File
@@ -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()
+1 -4282
View File
File diff suppressed because it is too large. Load diff