using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; using BeatLeader.Utils; using Newtonsoft.Json; using UnityEngine; using UnityEngine.Networking; using IPALogger = IPA.Logging.Logger; namespace Setlist { /// /// Parses BeatLeader sync URLs and checks ownership via GET /user/playlists. /// Reuses BeatLeader's sign-in by piggy-backing on Unity's process-wide /// cookie cache (the shipped 0.9.x BeatLeader /// signs in with ; cookies are global per host). /// We block on BeatLeader's Authentication._signedIn via reflection so /// we don't fire the request before the cookie is in the cache. /// internal static class BeatLeaderPlaylistOwnership { private const string AuthenticationTypeName = "BeatLeader.API.Authentication"; private const string SignedInFieldName = "_signedIn"; private const float LoginWaitTimeoutSeconds = 90f; private const int RequestTimeoutSeconds = 30; private sealed class UserPlaylistSummary { [JsonProperty("guid")] public string Guid { get; set; } } internal static bool TryExtractBeatLeaderPlaylistGuid(string syncUrl, out string guid) { guid = string.Empty; if (string.IsNullOrWhiteSpace(syncUrl)) { return false; } if (!Uri.TryCreate(syncUrl.Trim(), UriKind.Absolute, out var uri)) { return false; } if (!string.Equals(uri.Host, "api.beatleader.com", StringComparison.OrdinalIgnoreCase)) { return false; } var segments = uri.AbsolutePath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); if (segments.Length != 3) { return false; } if (!string.Equals(segments[0], "playlist", StringComparison.OrdinalIgnoreCase) || !string.Equals(segments[1], "guid", StringComparison.OrdinalIgnoreCase)) { return false; } var id = segments[2]; if (id.Length != 32 || !id.All(Uri.IsHexDigit)) { return false; } guid = id; return true; } /// /// Spawns a hidden coroutine runner that waits for BeatLeader sign-in, /// fetches /user/playlists, then logs the per-playlist ownership. /// Must be called from the Unity main thread (BSIPA's OnApplicationStart is fine). /// internal static void ScheduleVerifyAndLog( List<(string Title, bool HasSyncUrl, string BeatLeaderGuid)> entries, IPALogger log) { var go = new GameObject("Setlist.OwnershipRunner"); UnityEngine.Object.DontDestroyOnLoad(go); go.hideFlags = HideFlags.HideAndDontSave; var runner = go.AddComponent(); runner.Initialize(entries, log); } /// /// Component that drives the verification coroutine; self-destroys when finished. /// private sealed class OwnershipRunner : MonoBehaviour { private List<(string Title, bool HasSyncUrl, string BeatLeaderGuid)> _entries; private IPALogger _log; public void Initialize( List<(string Title, bool HasSyncUrl, string BeatLeaderGuid)> entries, IPALogger log) { _entries = entries; _log = log; StartCoroutine(Run()); } private IEnumerator Run() { HashSet owned = null; string failure = null; FieldInfo signedInField; try { signedInField = ResolveSignedInField(_log); } catch (Exception ex) { signedInField = null; failure = "reflecting BeatLeader Authentication failed: " + ex.Message; } if (signedInField != null) { var waitedSeconds = 0f; while (!IsSignedIn(signedInField)) { if (waitedSeconds >= LoginWaitTimeoutSeconds) { failure = $"BeatLeader login did not complete within {LoginWaitTimeoutSeconds:F0}s; " + "is the BeatLeader mod actually signing in (check BeatLeader log lines)?"; break; } yield return new WaitForSeconds(1f); waitedSeconds += 1f; } if (failure == null) { var fetchEnumerator = FetchOwnedGuids(result => { owned = result.OwnedGuids; failure = result.Failure; }); yield return StartCoroutine(fetchEnumerator); } } if (owned == null && failure != null) { _log.Info("BeatLeader /user/playlists: " + failure); } foreach (var e in _entries) { _log.Info(Plugin.FormatPlaylistLogLine(e.Title, e.HasSyncUrl, e.BeatLeaderGuid, owned)); } Destroy(gameObject); } private struct FetchResult { public HashSet OwnedGuids; public string Failure; } private IEnumerator FetchOwnedGuids(Action onDone) { var url = BLConstants.BEATLEADER_API_URL + "/user/playlists"; using (var request = UnityWebRequest.Get(url)) { request.timeout = RequestTimeoutSeconds; request.SetRequestHeader("User-Agent", "Setlist/" + GetAssemblyVersion()); yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.ConnectionError) { onDone(new FetchResult { Failure = "network error: " + request.error }); yield break; } if (request.result == UnityWebRequest.Result.ProtocolError) { onDone(new FetchResult { Failure = $"HTTP {request.responseCode} (cookie session not shared? " + "check BeatLeader login completed)", }); yield break; } var body = request.downloadHandler != null ? request.downloadHandler.text : null; if (string.IsNullOrEmpty(body)) { onDone(new FetchResult { OwnedGuids = new HashSet(StringComparer.OrdinalIgnoreCase) }); yield break; } HashSet set; try { var summaries = JsonConvert.DeserializeObject>(body); set = new HashSet(StringComparer.OrdinalIgnoreCase); if (summaries != null) { foreach (var s in summaries) { if (!string.IsNullOrEmpty(s?.Guid)) { set.Add(s.Guid); } } } } catch (Exception ex) { onDone(new FetchResult { Failure = "JSON parse failed: " + ex.Message }); yield break; } onDone(new FetchResult { OwnedGuids = set }); } } private static FieldInfo ResolveSignedInField(IPALogger log) { var beatLeaderAssembly = typeof(BLConstants).Assembly; var authType = beatLeaderAssembly.GetType(AuthenticationTypeName); if (authType == null) { log.Info($"BeatLeader assembly has no {AuthenticationTypeName} type; cannot detect login state."); return null; } var field = authType.GetField(SignedInFieldName, BindingFlags.NonPublic | BindingFlags.Static); if (field == null) { log.Info($"{AuthenticationTypeName} has no static field '{SignedInFieldName}'; " + "BeatLeader may have changed its API."); 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(BeatLeaderPlaylistOwnership).Assembly; return asm.GetName().Version?.ToString() ?? "0.0.0"; } } } }