Cross reference local playlists with beatleader playlists

This commit is contained in:
pleb
2026-04-18 20:45:24 -07:00
parent 7c3bd4109e
commit abc658a4f9
8 changed files with 402 additions and 6 deletions
+262
View File
@@ -0,0 +1,262 @@
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
{
/// <summary>
/// Parses BeatLeader sync URLs and checks ownership via GET /user/playlists.
/// Reuses BeatLeader's sign-in by piggy-backing on Unity's process-wide
/// <see cref="UnityWebRequest"/> cookie cache (the shipped 0.9.x BeatLeader
/// signs in with <see cref="UnityWebRequest"/>; cookies are global per host).
/// We block on BeatLeader's <c>Authentication._signedIn</c> via reflection so
/// we don't fire the request before the cookie is in the cache.
/// </summary>
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;
}
/// <summary>
/// Spawns a hidden coroutine runner that waits for BeatLeader sign-in,
/// fetches <c>/user/playlists</c>, then logs the per-playlist ownership.
/// Must be called from the Unity main thread (BSIPA's <c>OnApplicationStart</c> is fine).
/// </summary>
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<OwnershipRunner>();
runner.Initialize(entries, log);
}
/// <summary>
/// Component that drives the verification coroutine; self-destroys when finished.
/// </summary>
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<string> 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<string> OwnedGuids;
public string Failure;
}
private IEnumerator FetchOwnedGuids(Action<FetchResult> 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<string>(StringComparer.OrdinalIgnoreCase) });
yield break;
}
HashSet<string> set;
try
{
var summaries = JsonConvert.DeserializeObject<List<UserPlaylistSummary>>(body);
set = new HashSet<string>(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";
}
}
}
}
+55 -1
View File
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using IPA;
using IPALogger = IPA.Logging.Logger;
@@ -45,15 +47,36 @@ namespace Setlist
return;
}
var entries = new List<(string Title, bool HasSyncUrl, string BeatLeaderGuid)>();
foreach (var playlist in playlists)
{
var hasSyncUrl = false;
string syncUrl = null;
if (playlist.TryGetCustomData("syncURL", out var syncObj) && syncObj is string url)
{
syncUrl = url;
hasSyncUrl = !string.IsNullOrWhiteSpace(url);
}
Log.Info($"Playlist \"{playlist.Title}\": hasSyncUrl={hasSyncUrl}");
string blGuid = null;
if (hasSyncUrl && BeatLeaderPlaylistOwnership.TryExtractBeatLeaderPlaylistGuid(syncUrl, out var g))
{
blGuid = g;
}
entries.Add((playlist.Title, hasSyncUrl, blGuid));
}
if (entries.Any(e => e.BeatLeaderGuid != null))
{
BeatLeaderPlaylistOwnership.ScheduleVerifyAndLog(entries, Log);
}
else
{
foreach (var e in entries)
{
Log.Info(FormatPlaylistLogLine(e.Title, e.HasSyncUrl, e.BeatLeaderGuid, ownedGuids: null));
}
}
}
catch (Exception ex)
@@ -66,5 +89,36 @@ namespace Setlist
public void OnApplicationQuit()
{
}
internal static string FormatPlaylistLogLine(
string title,
bool hasSyncUrl,
string beatLeaderGuid,
HashSet<string> ownedGuids)
{
string ownerPart;
if (!hasSyncUrl)
{
ownerPart = "beatLeaderOwnerConfirmed=n/a (no sync URL)";
}
else if (string.IsNullOrEmpty(beatLeaderGuid))
{
ownerPart = "beatLeaderOwnerConfirmed=n/a (sync URL is not a BeatLeader playlist)";
}
else if (ownedGuids == null)
{
ownerPart = "beatLeaderOwnerConfirmed=unknown (BeatLeader /user/playlists did not succeed; see prior log line)";
}
else if (ownedGuids.Contains(beatLeaderGuid))
{
ownerPart = "beatLeaderOwnerConfirmed=true";
}
else
{
ownerPart = "beatLeaderOwnerConfirmed=false";
}
return $"Playlist \"{title}\": hasSyncUrl={hasSyncUrl}, {ownerPart}";
}
}
}
+2 -2
View File
@@ -11,5 +11,5 @@ using System.Runtime.InteropServices;
[assembly: AssemblyCulture("")]
[assembly: ComVisible(false)]
[assembly: Guid("50F53E6E-21D5-4780-8E67-273877DAA28C")]
[assembly: AssemblyVersion("0.0.1.0")]
[assembly: AssemblyFileVersion("0.0.1.0")]
[assembly: AssemblyVersion("0.0.2.0")]
[assembly: AssemblyFileVersion("0.0.2.0")]
+14
View File
@@ -45,6 +45,7 @@
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="System.Data" />
<Reference Include="System.Xml" />
<Reference Include="System.Net.Http" />
<Reference Include="Main">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Main.dll</HintPath>
<Private>False</Private>
@@ -85,16 +86,29 @@
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UIModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UnityWebRequestModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.VRModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.VRModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Newtonsoft.Json">
<HintPath>$(BeatSaberDir)\Libs\Newtonsoft.Json.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="BeatSaberPlaylistsLib">
<HintPath>$(BeatSaberDir)\Libs\BeatSaberPlaylistsLib.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="BeatLeader">
<HintPath>$(BeatSaberDir)\Plugins\BeatLeader.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="BeatLeaderPlaylistOwnership.cs" />
<Compile Include="Plugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
+3 -2
View File
@@ -3,11 +3,12 @@
"id": "Setlist",
"name": "Setlist",
"author": "",
"version": "0.0.1",
"version": "0.0.2",
"description": "Syncs playlists with external sources.",
"gameVersion": "1.40.8",
"dependsOn": {
"BSIPA": "^4.3.0",
"BeatSaberPlaylistsLib": "^1.7.0"
"BeatSaberPlaylistsLib": "^1.7.0",
"BeatLeader": "^0.9.0"
}
}