Files
BeatSaber/Assets/Script/BeatSageUploader.cs
T
whdwo798 c73ff7f412 노래 만들기 수정 — NAS 업로드 완성, Easy 제거 및 ExpertPlus 추가, 다운로드 버그 수정
- NasPublisher: DSM 7.2 multipart body 수동 구성으로 업로드 401 오류 해결
- NasPublisher: 비밀번호 StreamingAssets/nas_config.json 분리, .gitignore 등록
- NasPublisher: staticBaseUrl 포트 8180 → 80 수정
- BeatSageUploader: Easy 난이도 제거, ExpertPlus(.dat) 추가
- NoteData: DifficultyMap에서 easy 제거, expertplus 추가
- SongCreatorManager: toggleEasy → toggleExpertPlus 교체
- SongDetailPanel: btnEasy → btnExpertPlus 교체
- DownloadManager: DownloadHandlerFile 경로 정규화(Path.GetFullPath), mapFile 빈 값 방어 처리
- PersistentXRRig: FindObjectsOfType obsolete 경고 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:39:27 +09:00

267 lines
10 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;
// 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<string, string> DiffNames = new()
{
{ "normal", "Normal" },
{ "hard", "Hard" },
{ "expert", "Expert" },
{ "expertplus", "ExpertPlus" },
};
// Beat Sage .zip 내 .dat 파일명 매핑 (내부 → zip 내 파일명)
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 API ───────────────────────────────────────────
public IEnumerator Upload(
string audioPath,
List<string> difficulties,
float bpm,
Action<float> onProgress,
Action<Dictionary<string, List<NoteData>>> onComplete,
Action<string> 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<string, List<NoteData>> 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<string> difficulties,
Action<string> onSuccess, Action<string> onError)
{
byte[] audioBytes = File.ReadAllBytes(audioPath);
string fileName = Path.GetFileName(audioPath);
// 난이도: 알 수 없는 값(easy 등)은 건너뜀, 쉼표 구분 단일 필드로 전송
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("Beat Sage가 지원하지 않는 난이도입니다. Normal/Hard/Expert/ExpertPlus 중 선택하세요.");
yield break;
}
string diffStr = string.Join(",", mappedDiffs);
Debug.Log($"[BeatSage] 전송 difficulties: '{diffStr}'");
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", 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<string> onStatus, Action<string> 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<byte[]> onSuccess, Action<string> 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<string, List<NoteData>> ExtractAndConvert(
byte[] zipBytes, List<string> difficulties, float bpm)
{
var result = new Dictionary<string, List<NoteData>>();
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;
}