using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Text; using System.Text.RegularExpressions; 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)) { NormalizeSettings(); return; } var cfg = JsonUtility.FromJson(File.ReadAllText(path)); if (cfg == null) return; _password = cfg.password ?? ""; if (!string.IsNullOrWhiteSpace(cfg.host)) nasBaseUrl = cfg.host.Trim(); if (!string.IsNullOrWhiteSpace(cfg.account)) nasAccount = cfg.account.Trim(); if (!string.IsNullOrWhiteSpace(cfg.rootPath)) nasRootPath = cfg.rootPath.Trim(); if (!string.IsNullOrWhiteSpace(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl.Trim(); NormalizeSettings(); } [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> maps, Action onProgress, Action onComplete, Action onError) { NormalizeSettings(); 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 onError) { if (string.IsNullOrWhiteSpace(_password)) { onError?.Invoke("NAS password missing. Create Assets/StreamingAssets/nas_config.json with host, account, rootPath, staticUrl, and password."); yield break; } 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"; UnityWebRequest req; try { req = UnityWebRequest.Get(url); } catch (UriFormatException e) { onError?.Invoke($"DSM login URL invalid: '{nasBaseUrl}' — {e.Message}"); yield break; } string resp; using (req) { yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { onError?.Invoke($"DSM login failed: {req.error}"); yield break; } resp = req.downloadHandler.text; _sid = ParseJsonString(resp, "sid"); _synoToken = ParseJsonString(resp, "synotoken"); } if (string.IsNullOrEmpty(_sid)) onError?.Invoke($"DSM sid parse failed. Check NAS account/password/permissions. Response: {Shorten(resp)}"); } private IEnumerator Logout() { NormalizeSettings(); 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 onError) { yield return UploadBytes(File.ReadAllBytes(localPath), fileName, nasFolder, onError); } private IEnumerator UploadBytes(byte[] bytes, string fileName, string nasFolder, Action onError) { NormalizeSettings(); 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 onError) { NormalizeSettings(); SongsList list = null; using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json")) { yield return req.SendWebRequest(); if (req.result == UnityWebRequest.Result.Success) list = JsonUtility.FromJson(req.downloadHandler.text); } list ??= new SongsList { version = "1.0", songs = new List() }; 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) { if (string.IsNullOrEmpty(json) || string.IsNullOrEmpty(key)) return null; Match match = Regex.Match( json, $"\"{Regex.Escape(key)}\"\\s*:\\s*\"(?(?:\\\\.|[^\"])*)\""); return match.Success ? Regex.Unescape(match.Groups["value"].Value) : null; } private static void AssignMapFile(SongInfo song, string diff, string fileName) { var info = song.difficulties.Get(diff); if (info != null) info.mapFile = $"maps/{fileName}"; } private void OnValidate() { NormalizeSettings(); } private void NormalizeSettings() { nasBaseUrl = NormalizeBaseUrl(nasBaseUrl); staticBaseUrl = NormalizeBaseUrl(staticBaseUrl); nasAccount = nasAccount?.Trim() ?? ""; nasRootPath = NormalizeRootPath(nasRootPath); } private static string NormalizeBaseUrl(string value) { return (value ?? "").Trim().TrimEnd('/'); } private static string NormalizeRootPath(string value) { value = (value ?? "").Trim().Replace('\\', '/'); if (string.IsNullOrEmpty(value)) return "/"; return value.StartsWith("/") ? value.TrimEnd('/') : "/" + value.TrimEnd('/'); } private static string Shorten(string value, int maxLength = 240) { if (string.IsNullOrEmpty(value) || value.Length <= maxLength) return value ?? ""; return value.Substring(0, maxLength) + "..."; } }