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);
}
}
}
}