321 lines
12 KiB
C#
321 lines
12 KiB
C#
|
|
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<string, string> DiffNames = new()
|
||
|
|
{
|
||
|
|
{ "normal", "Normal" },
|
||
|
|
{ "hard", "Hard" },
|
||
|
|
{ "expert", "Expert" },
|
||
|
|
{ "expertplus", "ExpertPlus" },
|
||
|
|
};
|
||
|
|
|
||
|
|
private static readonly Dictionary<string, string> 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<string> difficulties,
|
||
|
|
float bpm,
|
||
|
|
Action<float> onProgress,
|
||
|
|
Action<Dictionary<string, List<NoteData>>> onComplete,
|
||
|
|
Action<string> 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<string> difficulties,
|
||
|
|
float bpm,
|
||
|
|
Action<float> onProgress,
|
||
|
|
Action<Dictionary<string, List<NoteData>>> onComplete,
|
||
|
|
Action<string> 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<string> difficulties,
|
||
|
|
float bpm,
|
||
|
|
Action<float> onProgress,
|
||
|
|
Action<Dictionary<string, List<NoteData>>> onComplete,
|
||
|
|
Action<string> 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<string, List<NoteData>> 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<string> difficulties,
|
||
|
|
Action<string> onSuccess, Action<string> onError)
|
||
|
|
{
|
||
|
|
var mappedDiffs = new List<string>();
|
||
|
|
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<IMultipartFormSection>
|
||
|
|
{
|
||
|
|
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<string> difficulties,
|
||
|
|
Action<string> onSuccess, Action<string> onError)
|
||
|
|
{
|
||
|
|
byte[] audioBytes = File.ReadAllBytes(audioPath);
|
||
|
|
string fileName = Path.GetFileName(audioPath);
|
||
|
|
|
||
|
|
var mappedDiffs = new List<string>();
|
||
|
|
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<IMultipartFormSection>
|
||
|
|
{
|
||
|
|
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<string> onStatus, Action<string> 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<byte[]> onSuccess, Action<string> 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<string, List<NoteData>> ExtractAndConvert(
|
||
|
|
byte[] zipBytes, List<string> difficulties, float fallbackBpm)
|
||
|
|
{
|
||
|
|
var result = new Dictionary<string, List<NoteData>>();
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|