feat: SongCreator 씬 완성 — Beat Sage URL 지원, info.dat 메타데이터 자동 추출
- BeatSageUploader: audio_url 지원(UploadFromUrl), PollAndDownload 공통화, ZIP 500 오류 3회 재시도 - BeatSageConverter: info.dat 파싱(SongMetadata), BPM 자동 감지 → 노트 타이밍 변환에 적용 - SongCreatorManager: title/BPM 필수 입력 제거, 난이도 4개 자동 선택, GenerateFlowFromUrl 버그 수정 - NasPublisher: audioPath null 허용(URL 흐름에서 로컬 파일 없는 경우 스킵) - .gitignore/.gitattributes 초기 설정 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
public class NasPublisher : MonoBehaviour
|
||||
{
|
||||
[Header("NAS Connection")]
|
||||
[SerializeField] private string nasBaseUrl = "http://192.168.55.3:5000";
|
||||
[SerializeField] private string nasAccount = "admin";
|
||||
[SerializeField] private string nasRootPath = "/web/beatsaber";
|
||||
|
||||
[Header("Static Server URL (for reading songs.json)")]
|
||||
[SerializeField] private string staticBaseUrl = "http://whdwo798.synology.me/beatsaber";
|
||||
|
||||
private string _sid = "";
|
||||
private string _synoToken = "";
|
||||
private string _password = "";
|
||||
|
||||
private void Awake() => LoadConfig();
|
||||
|
||||
private void LoadConfig()
|
||||
{
|
||||
string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
|
||||
if (!File.Exists(path)) { Debug.LogWarning("[NasPublisher] nas_config.json not found: " + path); return; }
|
||||
var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
|
||||
if (cfg == null) return;
|
||||
_password = cfg.password ?? "";
|
||||
if (!string.IsNullOrEmpty(cfg.host)) nasBaseUrl = cfg.host;
|
||||
if (!string.IsNullOrEmpty(cfg.account)) nasAccount = cfg.account;
|
||||
if (!string.IsNullOrEmpty(cfg.rootPath)) nasRootPath = cfg.rootPath;
|
||||
if (!string.IsNullOrEmpty(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
|
||||
}
|
||||
|
||||
[Serializable] private class NasConfig
|
||||
{
|
||||
public string host;
|
||||
public string account;
|
||||
public string rootPath;
|
||||
public string staticUrl;
|
||||
public string password;
|
||||
}
|
||||
|
||||
public IEnumerator Publish(
|
||||
SongInfo song,
|
||||
string audioPath,
|
||||
Dictionary<string, List<NoteData>> maps,
|
||||
Action<float> onProgress,
|
||||
Action onComplete,
|
||||
Action<string> onError)
|
||||
{
|
||||
bool failed = false;
|
||||
void OnErr(string e) { onError?.Invoke(e); failed = true; }
|
||||
|
||||
yield return Login(OnErr);
|
||||
if (string.IsNullOrEmpty(_sid)) yield break;
|
||||
onProgress?.Invoke(0.1f);
|
||||
|
||||
if (!string.IsNullOrEmpty(audioPath))
|
||||
{
|
||||
yield return UploadFile(audioPath, $"{nasRootPath}/music", $"{song.id}.mp3", OnErr);
|
||||
if (failed) { yield return Logout(); yield break; }
|
||||
}
|
||||
onProgress?.Invoke(0.4f);
|
||||
|
||||
int total = maps.Count, done = 0;
|
||||
foreach (var kv in maps)
|
||||
{
|
||||
string fileName = $"Map_{song.id}_{kv.Key}.json";
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(BeatSageConverter.ToMapJson(kv.Value));
|
||||
AssignMapFile(song, kv.Key, fileName);
|
||||
|
||||
yield return UploadBytes(bytes, fileName, $"{nasRootPath}/maps", OnErr);
|
||||
if (failed) { yield return Logout(); yield break; }
|
||||
|
||||
done++;
|
||||
onProgress?.Invoke(0.4f + (float)done / total * 0.3f);
|
||||
}
|
||||
|
||||
yield return PatchSongsJson(song, OnErr);
|
||||
if (failed) { yield return Logout(); yield break; }
|
||||
onProgress?.Invoke(0.95f);
|
||||
|
||||
yield return Logout();
|
||||
onProgress?.Invoke(1f);
|
||||
onComplete?.Invoke();
|
||||
Debug.Log($"[NasPublisher] Upload complete: '{song.title}'");
|
||||
}
|
||||
|
||||
private IEnumerator Login(Action<string> onError)
|
||||
{
|
||||
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
||||
$"?api=SYNO.API.Auth&version=6&method=login" +
|
||||
$"&account={UnityWebRequest.EscapeURL(nasAccount)}" +
|
||||
$"&passwd={UnityWebRequest.EscapeURL(_password)}" +
|
||||
$"&session=FileStation&format=sid&enable_syno_token=yes";
|
||||
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"DSM login failed: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
string resp = req.downloadHandler.text;
|
||||
_sid = ParseJsonString(resp, "sid");
|
||||
_synoToken = ParseJsonString(resp, "synotoken");
|
||||
|
||||
if (string.IsNullOrEmpty(_sid))
|
||||
onError?.Invoke("DSM sid parse failed — check credentials.");
|
||||
}
|
||||
|
||||
private IEnumerator Logout()
|
||||
{
|
||||
string url = $"{nasBaseUrl}/webapi/auth.cgi" +
|
||||
$"?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
_sid = "";
|
||||
}
|
||||
|
||||
private IEnumerator UploadFile(string localPath, string nasFolder,
|
||||
string fileName, Action<string> onError)
|
||||
{
|
||||
yield return UploadBytes(File.ReadAllBytes(localPath), fileName, nasFolder, onError);
|
||||
}
|
||||
|
||||
private IEnumerator UploadBytes(byte[] bytes, string fileName,
|
||||
string nasFolder, Action<string> onError)
|
||||
{
|
||||
string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi" +
|
||||
$"?api=SYNO.FileStation.Upload&version=2&method=upload" +
|
||||
$"&_sid={UnityWebRequest.EscapeURL(_sid)}";
|
||||
|
||||
string boundary = Guid.NewGuid().ToString("N");
|
||||
const string CRLF = "\r\n";
|
||||
|
||||
using var body = new MemoryStream();
|
||||
void WriteText(string s) { var b = Encoding.UTF8.GetBytes(s); body.Write(b, 0, b.Length); }
|
||||
void WriteField(string name, string value)
|
||||
{
|
||||
WriteText($"--{boundary}{CRLF}");
|
||||
WriteText($"Content-Disposition: form-data; name=\"{name}\"{CRLF}{CRLF}");
|
||||
WriteText(value + CRLF);
|
||||
}
|
||||
|
||||
WriteField("path", nasFolder);
|
||||
WriteField("create_parents", "true");
|
||||
WriteField("overwrite", "true");
|
||||
WriteText($"--{boundary}{CRLF}");
|
||||
WriteText($"Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"{CRLF}");
|
||||
WriteText($"Content-Type: application/octet-stream{CRLF}{CRLF}");
|
||||
body.Write(bytes, 0, bytes.Length);
|
||||
WriteText(CRLF + $"--{boundary}--{CRLF}");
|
||||
|
||||
using var req = new UnityWebRequest(uploadUrl, "POST");
|
||||
req.uploadHandler = new UploadHandlerRaw(body.ToArray());
|
||||
req.downloadHandler = new DownloadHandlerBuffer();
|
||||
req.SetRequestHeader("Content-Type", $"multipart/form-data; boundary={boundary}");
|
||||
if (!string.IsNullOrEmpty(_synoToken))
|
||||
req.SetRequestHeader("X-SYNO-TOKEN", _synoToken);
|
||||
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"Upload failed ({fileName}): {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (req.downloadHandler.text.Contains("\"success\":false"))
|
||||
onError?.Invoke($"Upload rejected ({fileName}): {req.downloadHandler.text}");
|
||||
}
|
||||
|
||||
private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
|
||||
{
|
||||
SongsList list = null;
|
||||
|
||||
using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
|
||||
{
|
||||
yield return req.SendWebRequest();
|
||||
if (req.result == UnityWebRequest.Result.Success)
|
||||
list = JsonUtility.FromJson<SongsList>(req.downloadHandler.text);
|
||||
}
|
||||
|
||||
list ??= new SongsList { version = "1.0", songs = new List<SongInfo>() };
|
||||
|
||||
int idx = list.songs.FindIndex(s => s.id == newSong.id);
|
||||
if (idx >= 0) list.songs[idx] = newSong;
|
||||
else list.songs.Add(newSong);
|
||||
|
||||
yield return UploadBytes(
|
||||
Encoding.UTF8.GetBytes(JsonUtility.ToJson(list, true)),
|
||||
"songs.json", nasRootPath, onError);
|
||||
}
|
||||
|
||||
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 static void AssignMapFile(SongInfo song, string diff, string fileName)
|
||||
{
|
||||
var info = song.difficulties.Get(diff);
|
||||
if (info != null) info.mapFile = $"maps/{fileName}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user