using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Text; using UnityEngine; using UnityEngine.Networking; // Beat Sage API 연동 (beatsage.com 역분석 기반) // 출처: BadgerHobbs/BeatSage-Downloader 소스코드 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}"; // {0} = levelId private const string DOWNLOAD_EP = "/beatsaber_custom_level_download/{0}"; // {0} = levelId private const float POLL_INTERVAL = 5f; private const float POLL_TIMEOUT = 300f; // Beat Sage 난이도 이름 매핑 (내부 → API) private static readonly Dictionary DiffNames = new() { { "easy", "Easy" }, { "normal", "Normal" }, { "hard", "Hard" }, { "expert", "Expert" }, }; // Beat Sage .zip 내 .dat 파일명 매핑 (내부 → zip 내 파일명) private static readonly Dictionary DatFileNames = new() { { "easy", "Easy.dat" }, { "normal", "Normal.dat" }, { "hard", "Hard.dat" }, { "expert", "Expert.dat" }, }; public string CurrentStatus { get; private set; } = ""; // ── Public API ─────────────────────────────────────────── public IEnumerator Upload( string audioPath, List difficulties, float bpm, Action onProgress, Action>> onComplete, Action onError) { // 1단계: 레벨 생성 요청 SetStatus("Beat Sage에 음원 전송 중..."); string levelId = null; yield return CreateLevel(audioPath, difficulties, id => levelId = id, onError); if (levelId == null) yield break; onProgress?.Invoke(0.15f); // 2단계: 생성 완료 폴링 SetStatus("Beat Sage AI 맵 생성 중..."); 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 = status == "generated"; error = status == "error"; }, onError); if (error) { onError?.Invoke("Beat Sage 생성 실패 (error 상태)"); yield break; } float progress = Mathf.Clamp01(elapsed / POLL_TIMEOUT); onProgress?.Invoke(0.15f + progress * 0.6f); SetStatus($"Beat Sage AI 맵 생성 중... ({(int)elapsed}s)"); } if (!ready) { onError?.Invoke("Beat Sage 타임아웃 (5분 초과)"); yield break; } // 3단계: .zip 다운로드 SetStatus("결과 다운로드 중..."); byte[] zipBytes = null; yield return DownloadZip(levelId, bytes => zipBytes = bytes, onError); if (zipBytes == null) yield break; onProgress?.Invoke(0.9f); // 4단계: .zip 해제 + BeatSageConverter 변환 SetStatus("맵 데이터 변환 중..."); Dictionary> maps = null; try { maps = ExtractAndConvert(zipBytes, difficulties, bpm); } catch (Exception e) { onError?.Invoke($"변환 실패: {e.Message}"); yield break; } onProgress?.Invoke(1f); SetStatus("변환 완료"); onComplete?.Invoke(maps); } // ── Beat Sage API 요청 ──────────────────────────────────── private IEnumerator CreateLevel(string audioPath, List difficulties, Action onSuccess, Action onError) { byte[] audioBytes = File.ReadAllBytes(audioPath); string fileName = Path.GetFileName(audioPath); // 난이도 문자열 변환: ["easy","hard"] → "Easy,Hard" var diffStr = string.Join(",", difficulties.ConvertAll(d => DiffNames.TryGetValue(d, out var n) ? n : d)); 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", diffStr), 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($"레벨 생성 요청 실패: {req.error}"); yield break; } string json = req.downloadHandler.text; string levelId = ParseJsonString(json, "id"); Debug.Log($"[BeatSageUploader] 생성 응답: {json}"); if (string.IsNullOrEmpty(levelId)) { onError?.Invoke($"levelId 파싱 실패. 응답: {json}"); yield break; } onSuccess?.Invoke(levelId); } private IEnumerator PollHeartbeat(string levelId, Action onStatus, Action onError) { string url = BASE_URL + string.Format(HEARTBEAT_EP, levelId); using var req = UnityWebRequest.Get(url); yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { onError?.Invoke($"상태 확인 실패: {req.error}"); yield break; } // 응답 예: { "status": "generated" } | { "status": "pending" } | { "status": "error" } string status = ParseJsonString(req.downloadHandler.text, "status"); Debug.Log($"[BeatSageUploader] 상태: {status}"); onStatus?.Invoke(status ?? ""); } private IEnumerator DownloadZip(string levelId, Action onSuccess, Action onError) { string url = BASE_URL + string.Format(DOWNLOAD_EP, levelId); using var req = UnityWebRequest.Get(url); yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { onError?.Invoke($"다운로드 실패: {req.error}"); yield break; } onSuccess?.Invoke(req.downloadHandler.data); } // ── .zip 해제 + 변환 ────────────────────────────────────── private static Dictionary> ExtractAndConvert( byte[] zipBytes, List difficulties, float bpm) { var result = new Dictionary>(); using var ms = new MemoryStream(zipBytes); using var archive = new ZipArchive(ms, ZipArchiveMode.Read); 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} 없음 — 건너뜀"); 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; }