Files
MusicPlayer/data/upload_folder.gd
T
SansanandClaude Opus 5.5 c86dacb890
Build / test (pull_request) Successful in 2m3s
Build / build (pull_request) Skipped
Add a soundtrack by picking or dropping a folder (#50)
"Add soundtrack…" in the statistics toolbar, or a folder dropped anywhere on
the window, opens a popup that lists the folder's songs and uploads them to
the server, which adds the soundtrack at once (Sansan/MusicServer#33).

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:43:59 +02:00

120 lines
4.8 KiB
GDScript

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