using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Text; using UnityEngine; using UnityEngine.Networking; public class BeatSageUploader : MonoBehaviour { private const string BASE_URL = "https://beatsage.com"; private const string CREATE_EP = "/beatsaber_custom_level_create"; private const string HEARTBEAT_EP = "/beatsaber_custom_level_heartbeat/{0}"; private const string DOWNLOAD_EP = "/beatsaber_custom_level_download/{0}"; private const float POLL_INTERVAL = 5f; private const float POLL_TIMEOUT = 300f; private static readonly Dictionary DiffNames = new() { { "normal", "Normal" }, { "hard", "Hard" }, { "expert", "Expert" }, { "expertplus", "ExpertPlus" }, }; private static readonly Dictionary DatFileNames = new() { { "normal", "Normal.dat" }, { "hard", "Hard.dat" }, { "expert", "Expert.dat" }, { "expertplus", "ExpertPlus.dat" }, }; public string CurrentStatus { get; private set; } = ""; public SongMetadata LastMetadata { get; private set; } // Upload from local file path public IEnumerator Upload( string audioPath, List difficulties, float bpm, Action onProgress, Action>> onComplete, Action onError) { SetStatus("[1/4] Uploading audio..."); string levelId = null; yield return CreateLevel(audioPath, difficulties, id => levelId = id, onError); if (levelId == null) yield break; onProgress?.Invoke(0.15f); yield return PollAndDownload(levelId, difficulties, bpm, onProgress, onComplete, onError); } // Upload from direct audio URL (Beat Sage downloads it server-side) public IEnumerator UploadFromUrl( string audioUrl, List difficulties, float bpm, Action onProgress, Action>> onComplete, Action onError) { SetStatus("[1/4] Sending URL to Beat Sage..."); string levelId = null; yield return CreateLevelFromUrl(audioUrl, difficulties, id => levelId = id, onError); if (levelId == null) yield break; onProgress?.Invoke(0.15f); yield return PollAndDownload(levelId, difficulties, bpm, onProgress, onComplete, onError); } // Shared poll + download + convert phase private IEnumerator PollAndDownload( string levelId, List difficulties, float bpm, Action onProgress, Action>> onComplete, Action onError) { SetStatus("[2/4] Generating beatmap..."); bool ready = false; float elapsed = 0f; while (!ready && elapsed < POLL_TIMEOUT) { yield return new WaitForSeconds(POLL_INTERVAL); elapsed += POLL_INTERVAL; bool error = false; yield return PollHeartbeat(levelId, status => { ready = string.Equals(status, "generated", StringComparison.OrdinalIgnoreCase) || string.Equals(status, "done", StringComparison.OrdinalIgnoreCase); error = string.Equals(status, "error", StringComparison.OrdinalIgnoreCase); }, onError); if (error) { onError?.Invoke("Beat Sage generation failed (error status)"); yield break; } onProgress?.Invoke(0.15f + Mathf.Clamp01(elapsed / POLL_TIMEOUT) * 0.6f); SetStatus($"[2/4] Generating... {(int)elapsed}s elapsed"); } if (!ready) { onError?.Invoke("Beat Sage timeout (>5 min)"); yield break; } SetStatus("[3/4] Downloading result..."); byte[] zipBytes = null; yield return DownloadZip(levelId, b => zipBytes = b, onError); if (zipBytes == null) yield break; onProgress?.Invoke(0.9f); SetStatus("[3/4] Converting map data..."); Dictionary> maps = null; try { maps = ExtractAndConvert(zipBytes, difficulties, bpm); } catch (Exception e) { onError?.Invoke($"Conversion failed: {e.Message}"); yield break; } onProgress?.Invoke(1f); SetStatus("[3/4] Conversion complete."); onComplete?.Invoke(maps); } private IEnumerator CreateLevelFromUrl(string audioUrl, List difficulties, Action onSuccess, Action onError) { var mappedDiffs = new List(); foreach (string d in difficulties) if (DiffNames.TryGetValue(d, out var n)) mappedDiffs.Add(n); if (mappedDiffs.Count == 0) { onError?.Invoke("No supported difficulties selected."); yield break; } var form = new List { new MultipartFormDataSection("audio_url", audioUrl), new MultipartFormDataSection("audio_metadata_title", " "), new MultipartFormDataSection("audio_metadata_artist", " "), new MultipartFormDataSection("difficulties", string.Join(",", mappedDiffs)), new MultipartFormDataSection("modes", "Standard"), new MultipartFormDataSection("events", "DotBlocks,Obstacles,Bombs"), new MultipartFormDataSection("environment", "DefaultEnvironment"), new MultipartFormDataSection("system_tag", "v2"), }; using var req = UnityWebRequest.Post(BASE_URL + CREATE_EP, form); req.SetRequestHeader("Accept", "*/*"); req.SetRequestHeader("User-Agent", "VRBeatSaber/1.0"); yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { onError?.Invoke($"Level create (URL) failed: {req.error}"); yield break; } string levelId = ParseJsonString(req.downloadHandler.text, "id"); if (string.IsNullOrEmpty(levelId)) { onError?.Invoke($"Failed to parse levelId. Response: {req.downloadHandler.text}"); yield break; } onSuccess?.Invoke(levelId); } private IEnumerator CreateLevel(string audioPath, List difficulties, Action onSuccess, Action onError) { byte[] audioBytes = File.ReadAllBytes(audioPath); string fileName = Path.GetFileName(audioPath); var mappedDiffs = new List(); foreach (string d in difficulties) if (DiffNames.TryGetValue(d, out var n)) mappedDiffs.Add(n); if (mappedDiffs.Count == 0) { onError?.Invoke("No supported difficulties selected (use Normal/Hard/Expert/ExpertPlus)."); yield break; } var form = new List { new MultipartFormFileSection("audio_file", audioBytes, fileName, "audio/mpeg"), new MultipartFormDataSection("audio_metadata_title", " "), new MultipartFormDataSection("audio_metadata_artist", " "), new MultipartFormDataSection("difficulties", string.Join(",", mappedDiffs)), new MultipartFormDataSection("modes", "Standard"), new MultipartFormDataSection("events", "DotBlocks,Obstacles,Bombs"), new MultipartFormDataSection("environment", "DefaultEnvironment"), new MultipartFormDataSection("system_tag", "v2"), }; using var req = UnityWebRequest.Post(BASE_URL + CREATE_EP, form); req.SetRequestHeader("Accept", "*/*"); req.SetRequestHeader("User-Agent", "VRBeatSaber/1.0"); yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { onError?.Invoke($"Level create request failed: {req.error}"); yield break; } string levelId = ParseJsonString(req.downloadHandler.text, "id"); if (string.IsNullOrEmpty(levelId)) { onError?.Invoke($"Failed to parse levelId. Response: {req.downloadHandler.text}"); yield break; } onSuccess?.Invoke(levelId); } private IEnumerator PollHeartbeat(string levelId, Action onStatus, Action onError) { using var req = UnityWebRequest.Get(BASE_URL + string.Format(HEARTBEAT_EP, levelId)); yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { onError?.Invoke($"Heartbeat check failed: {req.error}"); yield break; } onStatus?.Invoke(ParseJsonString(req.downloadHandler.text, "status") ?? ""); } private IEnumerator DownloadZip(string levelId, Action onSuccess, Action onError) { string url = BASE_URL + string.Format(DOWNLOAD_EP, levelId); for (int attempt = 1; attempt <= 3; attempt++) { using var req = UnityWebRequest.Get(url); yield return req.SendWebRequest(); if (req.result == UnityWebRequest.Result.Success) { onSuccess?.Invoke(req.downloadHandler.data); yield break; } // 500 오류는 Beat Sage 처리 지연일 수 있으므로 재시도 if (req.responseCode == 500 && attempt < 3) { SetStatus($"[3/4] Server error, retrying ({attempt}/3)..."); yield return new WaitForSeconds(5f); continue; } onError?.Invoke($"ZIP download failed: {req.error} (HTTP {req.responseCode})"); yield break; } } private Dictionary> ExtractAndConvert( byte[] zipBytes, List difficulties, float fallbackBpm) { var result = new Dictionary>(); using var ms = new MemoryStream(zipBytes); using var archive = new ZipArchive(ms, ZipArchiveMode.Read); // Read info.dat first to get auto-detected BPM and metadata float bpm = fallbackBpm; foreach (var e in archive.Entries) { if (!string.Equals(e.Name, "info.dat", StringComparison.OrdinalIgnoreCase)) continue; using var r = new StreamReader(e.Open(), Encoding.UTF8); var meta = BeatSageConverter.ParseInfoDat(r.ReadToEnd()); if (meta != null) { LastMetadata = meta; if (meta.bpm > 0) bpm = meta.bpm; } break; } foreach (string diff in difficulties) { if (!DatFileNames.TryGetValue(diff, out string datName)) continue; ZipArchiveEntry entry = null; foreach (var e in archive.Entries) if (string.Equals(e.Name, datName, StringComparison.OrdinalIgnoreCase)) { entry = e; break; } if (entry == null) { Debug.LogWarning($"[BeatSageUploader] {datName} not found — skipped."); continue; } using var reader = new StreamReader(entry.Open(), Encoding.UTF8); result[diff] = BeatSageConverter.Convert(reader.ReadToEnd(), bpm); } return result; } private static string ParseJsonString(string json, string key) { string search = $"\"{key}\":\""; int start = json.IndexOf(search, StringComparison.Ordinal); if (start < 0) return null; start += search.Length; int end = json.IndexOf('"', start); return end > start ? json.Substring(start, end - start) : null; } private void SetStatus(string msg) => CurrentStatus = msg; }