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) // Beat Sage API가 인정하는 난이도: Normal, Hard, Expert, ExpertPlus (Easy 없음) private static readonly Dictionary DiffNames = new() { { "normal", "Normal" }, { "hard", "Hard" }, { "expert", "Expert" }, { "expertplus", "ExpertPlus" }, }; // Beat Sage .zip 내 .dat 파일명 매핑 (내부 → zip 내 파일명) 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 API ─────────────────────────────────────────── public IEnumerator Upload( string audioPath, List difficulties, float bpm, Action onProgress, Action>> onComplete, Action onError) { // 1단계: 레벨 생성 요청 SetStatus("[1/4] 음원 업로드 중..."); Debug.Log($"[BeatSage] 업로드 시작 — 파일: {audioPath}"); string levelId = null; yield return CreateLevel(audioPath, difficulties, id => levelId = id, onError); Debug.Log($"[BeatSage] CreateLevel 완료 — levelId: {levelId}"); if (levelId == null) yield break; onProgress?.Invoke(0.15f); // 2단계: 생성 완료 폴링 SetStatus("[2/4] 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 = string.Equals(status, "generated", System.StringComparison.OrdinalIgnoreCase) || string.Equals(status, "done", System.StringComparison.OrdinalIgnoreCase); error = string.Equals(status, "error", System.StringComparison.OrdinalIgnoreCase); }, 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($"[2/4] AI 맵 생성 중... {(int)elapsed}초 경과"); } if (!ready) { onError?.Invoke("Beat Sage 타임아웃 (5분 초과)"); yield break; } // 3단계: .zip 다운로드 SetStatus("[3/4] 결과 다운로드 중..."); byte[] zipBytes = null; yield return DownloadZip(levelId, bytes => zipBytes = bytes, onError); if (zipBytes == null) yield break; onProgress?.Invoke(0.9f); // 4단계: .zip 해제 + BeatSageConverter 변환 SetStatus("[3/4] 맵 데이터 변환 중..."); Dictionary> maps = null; try { maps = ExtractAndConvert(zipBytes, difficulties, bpm); } catch (Exception e) { onError?.Invoke($"변환 실패: {e.Message}"); yield break; } onProgress?.Invoke(1f); SetStatus("[3/4] 변환 완료"); 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 등)은 건너뜀, 쉼표 구분 단일 필드로 전송 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("Beat Sage가 지원하지 않는 난이도입니다. Normal/Hard/Expert/ExpertPlus 중 선택하세요."); yield break; } string diffStr = string.Join(",", mappedDiffs); Debug.Log($"[BeatSage] 전송 difficulties: '{diffStr}'"); 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) { string body = req.downloadHandler?.text ?? "(응답 없음)"; Debug.LogError($"[BeatSage] HTTP {req.responseCode} — {req.error}\n응답 본문: {body}"); 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; }