using System; using System.Collections; using System.IO; using System.Reflection; using System.Text; using BeatLeader.Utils; using BeatSaberPlaylistsLib; using BeatSaberPlaylistsLib.Types; using UnityEngine; using UnityEngine.Networking; using IPALogger = IPA.Logging.Logger; namespace Setlist { /// /// Dev/test path: POST the on-disk playlist JSON to BeatLeader to prove authenticated updates work. /// Requires BeatLeader to have finished sign-in (Unity cookie cache). See docs/beatleader-playlist-api.md. /// internal static class BeatLeaderPlaylistUpdateTest { /// When this playlist title is locally owned (owner matches platform id), run POST test on startup. internal const string TargetPlaylistTitle = "2025-mapfilter-01"; private const string AuthenticationTypeName = "BeatLeader.API.Authentication"; private const string SignedInFieldName = "_signedIn"; private const float SignInWaitTimeoutSeconds = 90f; private const int PostRequestTimeoutSeconds = 120; /// POST /user/playlist with the serialized playlist body (same shape as the website sample). internal static IEnumerator RunIfTargetOwned( IPlaylist playlist, bool hasSyncUrl, string beatLeaderGuid, string ownerId, string platformUserId, IPALogger log) { if (!string.Equals(playlist.Title, TargetPlaylistTitle, StringComparison.Ordinal)) { yield break; } if (!IsLocallyOwnedBeatLeaderPlaylist(hasSyncUrl, beatLeaderGuid, ownerId, platformUserId)) { log.Info( $"Setlist BeatLeader POST test: skip \"{TargetPlaylistTitle}\" (not locally owned or missing BeatLeader fields)."); yield break; } if (!TryGetBeatLeaderServerFields(playlist, out var serverPlaylistId, out var shared)) { log.Info( $"Setlist BeatLeader POST test: skip \"{TargetPlaylistTitle}\" (customData missing id or not parseable)."); yield break; } var signedInField = TryResolveBeatLeaderSignedInField(log); if (signedInField == null) { yield break; } var waited = 0f; while (!IsSignedIn(signedInField)) { if (waited >= SignInWaitTimeoutSeconds) { log.Info( $"Setlist BeatLeader POST test: BeatLeader login did not complete within {SignInWaitTimeoutSeconds:F0}s."); yield break; } yield return new WaitForSeconds(1f); waited += 1f; } if (!TrySerializePlaylist(playlist, log, out var bodyJson)) { yield break; } var url = $"{BLConstants.BEATLEADER_API_URL}/user/playlist?id={Uri.EscapeDataString(serverPlaylistId)}&shared=" + (shared ? "true" : "false"); byte[] bodyRaw = Encoding.UTF8.GetBytes(bodyJson); using (var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST)) { request.timeout = PostRequestTimeoutSeconds; request.uploadHandler = new UploadHandlerRaw(bodyRaw); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "text/plain;charset=UTF-8"); request.SetRequestHeader("User-Agent", "Setlist/" + GetAssemblyVersion()); yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.ConnectionError) { log.Info("Setlist BeatLeader POST test: network error: " + request.error); yield break; } if (request.result == UnityWebRequest.Result.ProtocolError) { log.Info( $"Setlist BeatLeader POST test: HTTP {request.responseCode} body=" + TruncateForLog(request.downloadHandler?.text)); yield break; } log.Info( "Setlist BeatLeader POST test: success HTTP " + request.responseCode + " body=" + TruncateForLog(request.downloadHandler?.text)); } } private static bool IsLocallyOwnedBeatLeaderPlaylist( bool hasSyncUrl, string beatLeaderGuid, string ownerId, string platformUserId) { return hasSyncUrl && !string.IsNullOrEmpty(beatLeaderGuid) && !string.IsNullOrEmpty(ownerId) && !string.IsNullOrEmpty(platformUserId) && string.Equals(ownerId, platformUserId, StringComparison.Ordinal); } private static bool TryGetBeatLeaderServerFields(IPlaylist playlist, out string serverPlaylistId, out bool shared) { serverPlaylistId = null; shared = false; if (!playlist.TryGetCustomData("id", out var idObj) || idObj == null) { return false; } serverPlaylistId = Convert.ToString(idObj); if (string.IsNullOrWhiteSpace(serverPlaylistId)) { return false; } if (playlist.TryGetCustomData("shared", out var sharedObj) && sharedObj != null) { if (sharedObj is bool b) { shared = b; } else if (bool.TryParse(Convert.ToString(sharedObj), out var parsed)) { shared = parsed; } } return true; } private static bool TrySerializePlaylist(IPlaylist playlist, IPALogger log, out string json) { json = null; var mgr = PlaylistManager.DefaultManager; IPlaylistHandler handler = null; if (!string.IsNullOrEmpty(playlist.SuggestedExtension)) { handler = mgr.GetHandlerForExtension(playlist.SuggestedExtension); } if (handler == null) { handler = mgr.GetHandlerForPlaylistType(playlist.GetType()); } if (handler == null) { log.Info("Setlist BeatLeader POST test: no IPlaylistHandler for playlist type."); return false; } try { using (var stream = new MemoryStream()) { handler.Serialize(playlist, stream); json = Encoding.UTF8.GetString(stream.ToArray()); } return !string.IsNullOrEmpty(json); } catch (Exception ex) { log.Info("Setlist BeatLeader POST test: serialize failed: " + ex.Message); return false; } } private static FieldInfo TryResolveBeatLeaderSignedInField(IPALogger log) { var beatLeaderAssembly = typeof(BLConstants).Assembly; var authType = beatLeaderAssembly.GetType(AuthenticationTypeName); if (authType == null) { log.Info($"Setlist BeatLeader POST test: assembly has no {AuthenticationTypeName}."); return null; } var field = authType.GetField(SignedInFieldName, BindingFlags.NonPublic | BindingFlags.Static); if (field == null) { log.Info( $"{AuthenticationTypeName} has no static field '{SignedInFieldName}' (BeatLeader API changed?)."); return null; } return field; } private static bool IsSignedIn(FieldInfo signedInField) { var value = signedInField.GetValue(null); return value is bool b && b; } private static string GetAssemblyVersion() { var asm = typeof(BeatLeaderPlaylistUpdateTest).Assembly; return asm.GetName().Version?.ToString() ?? "0.0.0"; } private static string TruncateForLog(string s, int maxLen = 512) { if (string.IsNullOrEmpty(s)) { return ""; } return s.Length <= maxLen ? s : s.Substring(0, maxLen) + "…"; } } }