using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using BeatSaberPlaylistsLib.Types; using UnityEngine; using IPALogger = IPA.Logging.Logger; namespace Setlist { /// /// Parses BeatLeader-style sync URLs for logging. Ownership is determined in /// from playlist JSON only (no network). /// internal static class BeatLeaderPlaylistOwnership { private const float PlatformUserPollStepSeconds = 0.5f; /// How long to wait for to appear and populate playerId (plugin runs before menu init). private const float PlatformUserWaitTimeoutSeconds = 30f; private const float PlatformUserGetUserInfoRetrySeconds = 3f; 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; } /// Reads sync URL, BeatLeader guid, and owner from playlist customData (same rules as startup scan). internal static void TryReadBeatLeaderMetadata( IPlaylist playlist, out bool hasSyncUrl, out string beatLeaderGuid, out string ownerId) { hasSyncUrl = false; beatLeaderGuid = null; ownerId = null; if (!playlist.TryGetCustomData("syncURL", out var syncObj) || !(syncObj is string syncUrl) || string.IsNullOrWhiteSpace(syncUrl)) { return; } hasSyncUrl = true; if (playlist.TryGetCustomData("owner", out var ownerObj) && ownerObj is string o && !string.IsNullOrWhiteSpace(o)) { ownerId = o.Trim(); } if (TryExtractBeatLeaderPlaylistGuid(syncUrl, out var g)) { beatLeaderGuid = g; } } /// /// Spawns a hidden coroutine runner that resolves the platform user id, then logs per-playlist /// ownership from playlist JSON. Must be called from the Unity main thread (BSIPA OnApplicationStart is fine). /// internal static void ScheduleVerifyAndLog( List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> 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); } internal static IEnumerator FetchPlatformUserIdCoroutine(Action onDone) { var waited = 0f; var lastGetUserInfoAttempt = -PlatformUserGetUserInfoRetrySeconds; while (waited < PlatformUserWaitTimeoutSeconds) { foreach (var plm in EnumerateLeaderboardModels()) { var pid = plm.playerId; if (!string.IsNullOrEmpty(pid)) { onDone(pid.Trim()); yield break; } } if (waited - lastGetUserInfoAttempt >= PlatformUserGetUserInfoRetrySeconds) { lastGetUserInfoAttempt = waited; var model = ResolvePlatformUserModel(); if (model != null) { Task task; try { task = model.GetUserInfo(CancellationToken.None); } catch (Exception) { task = null; } if (task != null) { while (!task.IsCompleted) { yield return null; } if (!task.IsFaulted) { var uid = task.Result?.platformUserId; if (!string.IsNullOrEmpty(uid)) { onDone(uid.Trim()); yield break; } } } } } waited += PlatformUserPollStepSeconds; yield return new WaitForSeconds(PlatformUserPollStepSeconds); } onDone(null); } /// /// can miss very early; sometimes finds the scene instance sooner. /// private static IEnumerable EnumerateLeaderboardModels() { var seen = new HashSet(); foreach (var plm in Resources.FindObjectsOfTypeAll()) { if (plm != null && seen.Add(plm)) { yield return plm; } } var active = UnityEngine.Object.FindObjectOfType(); if (active != null && seen.Add(active)) { yield return active; } } private static IPlatformUserModel ResolvePlatformUserModel() { var field = typeof(PlatformLeaderboardsModel).GetField( "_platformUserModel", BindingFlags.Instance | BindingFlags.NonPublic); if (field == null) { return null; } IPlatformUserModel last = null; foreach (var plm in EnumerateLeaderboardModels()) { if (field.GetValue(plm) is IPlatformUserModel m) { last = m; } } return last; } /// /// Component that drives the logging coroutine; self-destroys when finished. /// private sealed class OwnershipRunner : MonoBehaviour { private List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> _entries; private IPALogger _log; public void Initialize( List<(IPlaylist Playlist, string Title, bool HasSyncUrl, string BeatLeaderGuid, string OwnerId)> entries, IPALogger log) { _entries = entries; _log = log; StartCoroutine(Run()); } private IEnumerator Run() { string platformUserId = null; yield return StartCoroutine(FetchPlatformUserIdCoroutine(id => { platformUserId = id; })); Plugin.CachedPlatformUserId = platformUserId; _log.Info(string.IsNullOrEmpty(platformUserId) ? "platformUserId=(unknown)" : $"platformUserId={platformUserId}"); foreach (var e in _entries) { _log.Info(Plugin.FormatPlaylistLogLine( e.Title, e.HasSyncUrl, e.BeatLeaderGuid, e.OwnerId, platformUserId)); } Destroy(gameObject); } } } }